Compare commits

..
302 Commits
Author SHA1 Message Date
Vamsi KuramaandGitHub 3d6f2dd3dc Merge pull request #703 from makeplane/stage-release
promote: stage-release to production
2023-04-04 19:38:37 +05:30
vamsi 8cbf75ad6c Merge branch 'stage-release' of https://github.com/makeplane/plane into stage-release 2023-04-04 19:33:32 +05:30
vamsi 9f4f1cac42 Merge branch 'develop' of https://github.com/makeplane/plane into stage-release 2023-04-04 19:31:12 +05:30
Vamsi KuramaandGitHub 1c752d7019 Merge pull request #691 from makeplane/develop
promote: develop to stage release
2023-04-04 19:29:55 +05:30
Aaryan KhandelwalandGitHub 3519be9ce8 fix: remirror empty state (#702)
* fix: minor pages ui

* fix: remirror empty state
2023-04-04 19:27:13 +05:30
sphynxuxandGitHub 9ce158fc10 add screenshots, feature, docker compose steps on readme (#701) 2023-04-04 18:50:23 +05:30
Aaryan KhandelwalandGitHub 0036ac6afb fix: minor pages ui (#700) 2023-04-04 18:31:28 +05:30
pablohashescobarandGitHub 7f7ceec24c chore: return user role in user onboard endpoint (#682) 2023-04-04 18:07:17 +05:30
pablohashescobarandGitHub adf366b325 chore: gpt environment variables in the example file (#698) 2023-04-04 18:07:01 +05:30
Aaryan KhandelwalandGitHub 2660d646ad chore: minor pages UI (#695)
* chore: fix minor ui bugs in pages

* chore: shortcut to add new block

* chore: keyboard accessibility

* chore: block options position
2023-04-04 16:21:46 +05:30
Aaryan KhandelwalandGitHub dad36b404d fix: pages ai modal (#694) 2023-04-04 14:07:17 +05:30
Aaryan KhandelwalandGitHub 51be70d814 chore: remove github importer tab (#693) 2023-04-04 00:27:08 +05:30
Aaryan KhandelwalandGitHub 4af5921991 chore: restrict users from creating workspace with reserved slugs (#692) 2023-04-04 00:13:21 +05:30
pablohashescobarandGitHub 588247f1c1 dev: back migration for project member views (#663) 2023-04-04 00:00:03 +05:30
pablohashescobarandGitHub 1bb93f1f50 dev: back migration to update label colors (#664) 2023-04-03 23:58:35 +05:30
pablohashescobarandGitHub d990f0038b fix: module link create url validation (#678) 2023-04-03 23:58:24 +05:30
Dakshesh JainandGitHub 67952bc225 feat: added user role on onboarding event (#684)
* feat: added tracker for views

* feat: added user role on onboarding event
2023-04-03 23:57:19 +05:30
a1f0f43992 feat: icon picker (#689)
* feat: icon picker

* style: icon picker modal

---------

Co-authored-by: Dakshesh Jain <dakshesh.jain14@gmail.com>
2023-04-03 23:54:26 +05:30
Aaryan KhandelwalandGitHub 007ed0afa4 fix: cmdk hover style (#690) 2023-04-03 23:52:39 +05:30
Kunal VishwakarmaandGitHub 62cca1c7cd feat: issue redirect (#685)
* feat: open issue in new tab for list and kanban view

* fix: used a tag
2023-04-03 23:51:46 +05:30
Aaryan KhandelwalandGitHub 5ba7d271b7 style: revamp pages (#688)
* feat: dnd added for blocks

* chore: added access option to pages

* style: ui fixes

* fix: polishing

* fix: build error
2023-04-03 23:30:29 +05:30
Saheb GiriandGitHub f6f9caf9e6 fix: now user can navigate using arrow keys (#681) 2023-04-03 19:04:11 +05:30
Aaryan KhandelwalandGitHub 35e40a7bec chore: added placeholder to remirror (#686)
* chore: added placeholder to remirror

* fix: build error
2023-04-03 18:14:50 +05:30
Aaryan KhandelwalandGitHub 3e08186d72 fix: kanban empty issues (#687) 2023-04-03 18:04:45 +05:30
Dakshesh JainandGitHub 150553d420 fix: tracker not creating user alias (#683) 2023-04-03 16:37:19 +05:30
Aaryan KhandelwalandGitHub 1b30e4b57f fix: user cannot create duplicate links (#680) 2023-04-03 16:22:37 +05:30
Anmol Singh BhatiaandGitHub a63c551e75 feat: completed cycle transfer issue validation added (#676)
* feat: completed cycle transfer issue validation added

* style: transfer issue section

* style: issue transfer button
2023-04-03 15:57:52 +05:30
Dakshesh JainandGitHub cfe7c5e0b7 fix: search button not working on image picker popover (#679) 2023-04-03 15:57:08 +05:30
Aaryan KhandelwalandGitHub 12ad3892f8 fix: signin page type (#671) 2023-04-03 15:07:39 +05:30
Dakshesh JainandGitHub 353197f583 feat: added tracker for views (#675) 2023-04-03 15:04:48 +05:30
Kunal VishwakarmaandGitHub 0cf498651c feat: shortcuts (#674)
* feat: added shortcut for page and view

* fix: shortcut for view

* fix: onclick method for views and pages
2023-04-03 14:23:50 +05:30
Saheb GiriandGitHub a18af1cecf fix: missing deleted icon in user activity (#673) 2023-04-03 11:51:25 +05:30
Aaryan KhandelwalandGitHub 61875722e4 chore: add auto generate description option to create issue modals (#672) 2023-04-03 11:41:46 +05:30
Vamsi KuramaandGitHub 7fed2ec6ef Merge pull request #670 from makeplane/stage-release
promote: stage-release to production
2023-04-01 20:29:39 +05:30
Vamsi KuramaandGitHub 93ba04aebc Merge pull request #669 from makeplane/develop
promote: develop to stage-release
2023-04-01 20:19:02 +05:30
Alejandro Pinar RuizandGitHub bd3ea456c3 fix: add tags for frontend build action (#445) 2023-04-01 18:30:26 +05:30
Saheb GiriandGitHub c0bf7783b1 style: designed user activity. (#666) 2023-03-31 23:00:28 +05:30
Anmol Singh BhatiaandGitHub 9f34f41982 feat: list and kanban view group by header icon (#665) 2023-03-31 21:20:23 +05:30
Kunal VishwakarmaandGitHub c940641ba1 Fix: view list bugs (#654)
* fix: viewlist link

* fix/removed side effects

* fix: deadzones in the link
2023-03-31 19:27:07 +05:30
Kunal VishwakarmaandGitHub 567966459b style: added icons to ellipses in project settings label (#662) 2023-03-31 18:31:44 +05:30
Anmol Singh BhatiaandGitHub 6055f5c4ee fix: cycle list page mutation fix (#661)
* fix: cycle list page mutation fix

* fix: cycle mutation fix
2023-03-31 18:31:21 +05:30
Saheb GiriandGitHub abe34ad7b1 fix: add scroll into view when editing label in settings page (#660) 2023-03-31 18:31:01 +05:30
Anmol Singh BhatiaandGitHub d596e41d4d fix: project setting control fix (#658)
* fix: project setting control fix

* fix: project member endpoint fix
2023-03-31 18:30:39 +05:30
Aaryan KhandelwalandGitHub fae1534887 fix: issues list flicker (#659)
* fix: issues list flicker

* fix: useeffect dependencies
2023-03-31 18:29:24 +05:30
Saheb GiriandGitHub 448d8c63f3 fix: old labels must have black color in project settings (#657) 2023-03-31 18:03:42 +05:30
Kunal VishwakarmaandGitHub 844ae4869a fix: view list link (#653)
* fix: viewlist link

* fix/removed side effects

* fix: deadzones in the link
2023-03-31 17:54:05 +05:30
Anmol Singh BhatiaandGitHub afd7741d0c fix: cycle status bug fix (#656) 2023-03-31 17:52:20 +05:30
Saheb GiriandGitHub f074f9f003 fix: graphs issue (#655)
* fix: prevent y axis to have decimal

* fix: add padding and labels to line chart
2023-03-31 17:33:51 +05:30
Anmol Singh BhatiaandGitHub f6500914be style: transfer issue modal empty state added (#652) 2023-03-31 16:05:02 +05:30
Saheb GiriandGitHub a6f306209d fix: issue modal title textfield reset (#651) 2023-03-31 16:04:51 +05:30
Anmol Singh BhatiaandGitHub e5507651c3 fix: issue sidebar module , cycle and label dropdown fix (#650) 2023-03-31 16:04:36 +05:30
Aaryan KhandelwalandGitHub e3005b7776 fix: project identifier check in project settings (#649) 2023-03-31 16:04:17 +05:30
Aaryan KhandelwalandGitHub 480e2c4d7f refactor: cycles toggle favorite (#648) 2023-03-31 16:03:58 +05:30
Aaryan KhandelwalandGitHub 09e17858fe chore: remove edit and push issue from page block (#647) 2023-03-31 16:03:48 +05:30
Saheb GiriandGitHub 13b2a6fd53 fix: persist data on tab switch in workspace (#646)
* fix: persist data on tab switch in workspace

* fix: build fail
2023-03-31 16:03:35 +05:30
Kunal VishwakarmaandGitHub 4ab82b9616 fix: style and bugs (#644)
* fix: style and bugs

* fix: removed unnecessary classes
2023-03-31 16:03:25 +05:30
Saheb GiriandGitHub 09d73c5e04 fix: issue activity (#645)
- fix: set and remove activity logs are showing problems
- fix: removing due date is showing static value of 1 Jan.
- fix:  old labels to have black color.
- fix:  word 'Description' should be bold
2023-03-31 14:46:17 +05:30
Vamsi KuramaandGitHub ed60707bae Merge pull request #643 from makeplane/develop
promote: develop to stage release
2023-03-31 04:41:24 +05:30
Aaryan KhandelwalandGitHub b2c15125fc fix: image upload (#642) 2023-03-31 04:24:57 +05:30
Aaryan KhandelwalandGitHub a8f125cfa8 fix: blocked and blocking in issue details sidebar (#641) 2023-03-31 03:50:35 +05:30
pablohashescobarandGitHub 15ce3537ad fix: recent pages endpoint (#640)
* fix: recent pages endpoint for date

* fix: order by
2023-03-31 03:32:32 +05:30
Aaryan KhandelwalandGitHub 448f383ec9 chore: cycle sidebar content updated (#639) 2023-03-31 03:21:37 +05:30
Aaryan KhandelwalandGitHub 0c39f0c563 style: custom error page (#638) 2023-03-31 03:09:55 +05:30
Aaryan KhandelwalandGitHub 65ddcb6d79 chore: pages content update, empty state for recent pages (#637) 2023-03-31 02:51:39 +05:30
Aaryan KhandelwalandGitHub 0c94b494ed refactor: calendar view (#636) 2023-03-31 02:43:38 +05:30
Aaryan KhandelwalandGitHub e2921539d0 refactor: sidebar stats mutation (#635) 2023-03-31 02:20:44 +05:30
Aaryan KhandelwalandGitHub 66d07e340b fix: delete mutations for issues, cycles and modules (#634) 2023-03-31 02:17:35 +05:30
vamsi 29ea592c4a dev: upgrade redis requirements to 4.5.4 2023-03-31 02:08:29 +05:30
vamsi 05323d4697 dev: new migrations for Pages related attributes 2023-03-31 02:04:33 +05:30
Anmol Singh BhatiaandGitHub 8fcfebf0fc fix: invalid date toast message updated (#633) 2023-03-31 01:40:06 +05:30
Anmol Singh BhatiaandGitHub 5d0533a44f style: ellipsis consistent style (#632)
* style: ellipsis consistent style

* style: consistent ellipsis menu for module and cycle
2023-03-31 00:31:00 +05:30
Anmol Singh BhatiaandGitHub 3cf2172520 style: delete workspace modal (#631) 2023-03-31 00:25:42 +05:30
Saheb GiriandGitHub a8a5873d88 fix: add icons to dropdown and replace link icon (#630) 2023-03-30 20:45:15 +05:30
Kunal VishwakarmaandGitHub 73c7b1bddc fix: modal typo (#629)
* fix: modal width inconsistency

* fix: modal typo
2023-03-30 20:25:04 +05:30
Anmol Singh BhatiaandGitHub 4441651f81 style: empty state image updated (#628) 2023-03-30 19:34:50 +05:30
Saheb GiriandGitHub 64c936b9b5 style: module modal consistent design (#625) 2023-03-30 19:28:04 +05:30
Dakshesh JainandGitHub 66ed6a1dc8 feat: added tracker for page, page block, and gpt (#623)
* refactor: made events function generic

* feat: added tracker for page, page block, and gpt
2023-03-30 19:27:46 +05:30
Anmol Singh BhatiaandGitHub 50275fd2ad fix: sidebar pending issue stats fix (#627) 2023-03-30 19:26:41 +05:30
Kunal VishwakarmaandGitHub 112fe8e7e6 fix: modal width inconsistency (#626) 2023-03-30 19:02:05 +05:30
Anmol Singh BhatiaandGitHub 02e6439bd5 feat: completed cycle transfer issue (#624)
* feat: bulk transfer issue for completed cycle added

* feat: toast alert added for issue transfer
2023-03-30 18:59:53 +05:30
pablohashescobarandGitHub f9ee898d88 chore: add workspace details and project details in page responses (#615)
* chore: add workspace details and project details in page responses

* fix: typo in workspace queryset
2023-03-30 18:59:39 +05:30
pablohashescobarandGitHub f79fdbf782 chore: create activity when a block is converter into an issue (#609) 2023-03-30 18:58:51 +05:30
pablohashescobarandGitHub 1d7b65ad83 fix: transfer issues endpoint (#620) 2023-03-30 18:58:23 +05:30
Saheb GiriandGitHub 1558f51c23 fix: show divider only when filter is selected (#618) 2023-03-30 18:54:44 +05:30
Aaryan KhandelwalandGitHub bc7dc43171 chore: ai assistant limit error (#619) 2023-03-30 18:45:33 +05:30
Kunal VishwakarmaandGitHub 70a00a6309 style: issue modal scroll (#621) 2023-03-30 18:45:11 +05:30
Saheb GiriandGitHub 4d56adba43 style: create cycle modal makeover (#617) 2023-03-30 18:35:29 +05:30
Anmol Singh BhatiaandGitHub 624d9dfd39 fix: icon and ellipsis (#622)
* fix: consistent icon for end date for cycle card

* fix: sidebar ellipsis fix
2023-03-30 18:35:05 +05:30
Aaryan KhandelwalandGitHub 3e8c375d1c fix: old labels color set to black (#614) 2023-03-30 18:07:18 +05:30
Aaryan KhandelwalandGitHub 880813685b fix: sidebar dropdowns height (#616)
* fix: old labels color set to black

* fix: issue details sidebar select dropdowns height
2023-03-30 18:07:00 +05:30
Aaryan KhandelwalandGitHub 16abbe0b3e fix: project identifier check in project settings (#613) 2023-03-30 17:48:03 +05:30
Aaryan KhandelwalandGitHub b65fa89cdb fix: role input on my profile page (#612) 2023-03-30 17:47:49 +05:30
Aaryan KhandelwalandGitHub ed4aae47a2 style: my profile page (#608) 2023-03-30 17:04:41 +05:30
Aaryan KhandelwalandGitHub 5feaed3961 fix: fetch correct list of issues on the calendar view (#611) 2023-03-30 17:03:37 +05:30
Saheb GiriandGitHub be5c4140ff refactor: issue label and make design consistent (#610) 2023-03-30 16:59:07 +05:30
pablohashescobarandGitHub 9c4fcca6c1 fix: ordering in queryset (#598) 2023-03-30 16:33:16 +05:30
pablohashescobarandGitHub 5aad20e7ed fix: project update for identifier (#604) 2023-03-30 16:33:04 +05:30
pablohashescobarandGitHub 3a599b6436 chore: workspace detail on issue activity serializer (#607) 2023-03-30 16:32:48 +05:30
Kunal VishwakarmaandGitHub c5ccc29418 feat: added invitaion pending pills (#606) 2023-03-30 16:01:24 +05:30
Anmol Singh BhatiaandGitHub f5f90dab69 fix : module and cycle invalid date fix (#605)
* fix: module and cycle modal invalid date validation

* fix: cycle and module sidebar invalid date
2023-03-30 16:00:48 +05:30
Aaryan KhandelwalandGitHub a94e38c093 fix: minor bugs in the onboarding screens (#603) 2023-03-30 13:55:50 +05:30
Saheb GiriandGitHub 63b7c1ee47 style: icons consistency and minor fixes (#601)
* fix: change discord casing

* style: icon and style consistency in cmd k

- change discord icon color
- make link copy link consistent
-  bolt to rocket icon for shortcut

* style: make icon color throughout the activity log
2023-03-30 13:54:18 +05:30
Kunal VishwakarmaandGitHub 1866fd77bb fix: changed buttons to primary (#602) 2023-03-30 13:53:03 +05:30
Kunal VishwakarmaandGitHub 531b9e3d64 style: fixed height issue in module card (#599) 2023-03-30 12:09:42 +05:30
Anmol Singh BhatiaandGitHub 06fb3e9b58 fix: calendar view bugs (#600)
* fix: text turncate added for issue

* fix: next week btn fix, style:calendar cell height fix
2023-03-30 12:09:11 +05:30
Aaryan KhandelwalandGitHub fb01e6d22c fix: module status line break (#597) 2023-03-30 02:07:48 +05:30
pablohashescobarandGitHub 89bb439d62 chore: return user request count from logger (#596) 2023-03-30 02:04:41 +05:30
e6055da150 feat: calendar view (#561)
* feat:start and last day of month helper function

* feat: start and last day of week helper function

* feat: weekday and everyday interval helper function

* feat: calendar date formater helper function

* feat: monthly calendar view , feat: weekend date toggle, feat: calendar month and year picker

* feat: monthly , weekly view and weekend toggle

* feat: drag and drop added in calendar view

* fix: drag and drop mutation fix

* chore: refactoring , feat: calendar view option added

* fix: calendar view menu fix

* style: calendar view style improvement

* style: drag and drop styling

* fix:week day format fix

* chore: calendar constant added

* feat: calendar helper function added

* feat: month and year picker, month navigator, jump to today funtionality added

* feat: weekly navigation and jump to today fix, style: month year picker fix

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-03-30 02:01:53 +05:30
Aaryan KhandelwalandGitHub 952c64d449 fix: mutation error for project issues (#595) 2023-03-30 01:50:00 +05:30
Aaryan KhandelwalandGitHub dcf8b562d3 chore: cycle and module sidebar stats filter (#594) 2023-03-30 01:31:43 +05:30
6a40dd911f fix: cycles date check for null and incomplete cycles endpoint (#580)
Co-authored-by: Vamsi Kurama <vamsi.kurama@gmail.com>
2023-03-30 01:26:14 +05:30
pablohashescobarandGitHub 9064709d5d feat: user activities endpoint (#590) 2023-03-30 01:25:55 +05:30
6d8eda9814 chore: update API endpoints for analytics (#574)
* chore: return workspace details on project create and update

* chore: update endpoints to return workspace and project details

---------

Co-authored-by: Vamsi Kurama <vamsi.kurama@gmail.com>
2023-03-30 01:25:32 +05:30
pablohashescobarandGitHub 7eaec16381 chore: add cycle id and module id in issues list endpoint (#586) 2023-03-30 01:25:16 +05:30
pablohashescobarandGitHub d7ed237f78 feat: add API limit for AI assistance (#592) 2023-03-30 01:23:56 +05:30
kunalv17andGitHub 5c1607f39b style: finished module card design (#593)
* style: finished modulecard design

* fix: use assignee list component
2023-03-30 01:17:51 +05:30
Saheb GiriandGitHub c75ca8203f feat: add label to create view modal (#591) 2023-03-30 01:08:14 +05:30
Anmol Singh BhatiaandGitHub cfd97041b8 feat: incomplete cycle endpoint added , issue sidebar cycle fix (#582)
* feat: incomplete cycle endpoint added , issue sidebar cycle fix

* fix: fetch key
2023-03-29 19:21:15 +05:30
Saheb GiriandGitHub 7337707a4e style: redesign issue activity (#579)
* style: redesign the activity feed and refactor the code

* chore: remove console logs

* revert: back to remirror rich text editor

* style: make icons, text smaller and reduce the spacing between logs
2023-03-29 19:19:37 +05:30
Dakshesh JainandGitHub b441a2ce20 feat: added labels in filters (#585)
* feat: added labels in filters

* fix: added labels in fetch keys
2023-03-29 19:18:57 +05:30
Aaryan KhandelwalandGitHub 1509c8611d fix: mutation while adding issue to cycle or module (#589) 2023-03-29 19:13:53 +05:30
kunalv17andGitHub 4e9715a5b2 style: added divider to the pages block (#571)
* style: added hr to the pages block

* style: added divider and removed hr

* chore: removed index prop

* chore: removed index
2023-03-29 18:36:52 +05:30
Dakshesh JainandGitHub 10657d4796 fix: filter menu not closing on select other parent option (#587) 2023-03-29 18:18:45 +05:30
Saheb GiriandGitHub 653cc29290 style: issue filter design (#588)
* style: redesign issue filter pill

* style: redesign issue label on the view modal
2023-03-29 18:18:08 +05:30
Dakshesh JainandGitHub fa9c6581fd fix: using proper name for tracker key (#581)
* fix: transmitting selective data for analytics

* fix: using proper name for tracker key

* fix: using proper name for tracker key
2023-03-29 16:34:56 +05:30
Aaryan KhandelwalandGitHub 96910e1897 chore: ai for issue description (#575)
* feat: block sync

* chore: ai assistant for issue description
2023-03-29 16:30:40 +05:30
sphynxuxandGitHub 2f69761130 Merge pull request #583 from makeplane/chore/analytics_payload
chore: analytics payload
2023-03-29 16:29:17 +05:30
sphynxuxandGitHub a8f5a3eda1 Merge pull request #584 from makeplane/fix/parent_issue_modal
fix: design of select parent issue modal
2023-03-29 16:28:55 +05:30
Anmol Singh BhatiaandGitHub 248d094762 feat: cycle list page (#577)
* style: cycle list page

* fix:typo fix
2023-03-29 16:27:55 +05:30
pablohashescobar 1255f4756d fix: uuid serilaizable error 2023-03-29 16:25:06 +05:30
Aaryan Khandelwal 22f8eb9a68 fix: design of select parent issue modal 2023-03-29 16:24:04 +05:30
pablohashescobar 3ba9cddc2a chore: add user id when logging for analytics 2023-03-29 16:15:11 +05:30
kunalv17andGitHub e3cb0ed13e feat/added toggle for pages in settings features (#576)
* feat/added toggle for pages in settings features

* style:changed pages icon
2023-03-29 15:21:08 +05:30
pablohashescobar 2786f09e85 Merge branch 'develop' of github.com:makeplane/plane into develop 2023-03-29 14:52:38 +05:30
Dakshesh JainandGitHub 06daf68753 fix: transmitting selective data for analytics (#578) 2023-03-29 14:38:30 +05:30
Anmol Singh BhatiaandGitHub 2138ddf1f5 fix: profile page header fix (#572) 2023-03-29 13:17:31 +05:30
pablohashescobar f9fa345b25 Merge branch 'develop' of github.com:makeplane/plane into develop 2023-03-29 12:54:33 +05:30
Dakshesh JainandGitHub c3e1d33518 feat: jitsu tracker setup (#542)
* feat: jitsu tracker setup

also using it in create, update and delete project & workspace

* refactor: added some more check condition on track-event api

* fix: added env vars in turbo.json

* feat: added user onboard event, workspace invite and workspace invite accept events

* feat: add tracker for issues, state, modules and cycles

* fix: add @jitsu/nextjs in package.json
2023-03-29 12:24:19 +05:30
kunalv17andGitHub 9eb9b7bf6c style: dashboard color (#568) 2023-03-29 11:54:37 +05:30
Saheb GiriandGitHub c60b152a7c fix: add crisp chat with us option in cmdk (#570) 2023-03-29 11:53:09 +05:30
Anmol Singh BhatiaandGitHub e07ffc3a46 fix: module sidebar link section relocation (#569) 2023-03-29 11:52:39 +05:30
Saheb GiriandGitHub dd3bca9a32 fix: cmdk integration (#567)
* fix: issues not showing on cmd k

* fix: text overflows on longer issue title

* fix: add loading state whenever there is a network call

* fix: minor ux changes

* feat: replace loading with spinner
2023-03-29 00:51:47 +05:30
628591854d fix: search endpoint to return distint results (#566)
Co-authored-by: Vamsi Kurama <vamsi.kurama@gmail.com>
2023-03-29 00:23:35 +05:30
pablohashescobarandGitHub 05e9c0f76f fix: cycle validation for completed cycles (#559) 2023-03-29 00:23:16 +05:30
pablohashescobarandGitHub 3bebcc4714 chore: return gpt response in html (#555)
* chore: return gpt response in html

* chore: update the response br
2023-03-29 00:23:02 +05:30
pablohashescobarandGitHub d16d32cea8 feat: move issues from one cycle to another (#554)
* feat: move issues from one cycle to another

* fix: push method when updating

* fix: new cycle completed validation
2023-03-29 00:22:48 +05:30
Aaryan KhandelwalandGitHub b654d30aeb chore: minor fixes on pages (#557)
* feat: block sync

* chore: minor fixes on pages

* fix: remove dangerously set inner html

* fix: pages crud operations mutation

* fix: favorites mutation for recent pages

* fix: remove dangerously set inner html
2023-03-29 00:20:00 +05:30
Saheb GiriandGitHub c0a471e916 fix: don't allow decimal in the y axis of completed issues graph (#564) 2023-03-29 00:08:34 +05:30
Anmol Singh BhatiaandGitHub e2eeec8f79 feat: completed cycle message added (#565) 2023-03-28 18:48:57 +05:30
kunalv17andGitHub ccd03a4a45 refactor: constants fetchkeys strings to upper case (#563) 2023-03-28 18:28:26 +05:30
pablohashescobar 20bbe74b57 Merge branch 'develop' of github.com:makeplane/plane into develop 2023-03-28 17:47:45 +05:30
kunalv17andGitHub b7b8d3914a style: views list page, chore: views favotire (#562) 2023-03-28 16:48:46 +05:30
afb92ea850 feat: add global search through cmd k (#560)
* feat: cmdk integration

* feat: create view, cycle, project, module and workspace from command k

* feat: user can logout directly from command menu

* feat: user can visit sub page like various settings

* feat: change state of issue from command menu

* chore: add current issue state and minor UX improvements

* refactor: moved change issue state to new file

* feat: change issue priority from command k

* feat: delete issue from command k

* feat: copy issue url to clipboard

* fix: change placeholder when settings page is selected

* chore: remove logout option from cmd k

* feat: add help options to cmd k

* feat: assign issue to member from cmd k

* feat: now assign issue to yourself from cmd k

* chore: implement new cmd k design with icons

* feat: implemented global search feature in the cmd k

* feat: add keyboard acessibility to cmd k list items

* chore: remove console logs

* fix: pages icon in cmd list

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-03-28 14:49:27 +05:30
kunalv17andGitHub fc4d06fe0c style: changed the workspace cards height (#558) 2023-03-28 14:17:24 +05:30
pablohashescobar dd90f26d93 Merge branch 'master' of github.com:makeplane/plane into develop 2023-03-28 13:58:38 +05:30
Aaryan KhandelwalandGitHub fbbf97f3a6 chore: remove docs (#553)
* feat: block sync

* chore: remove docs from repo
2023-03-28 03:54:48 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d9f31a1eb7 chore(deps): bump redis from 4.4.2 to 4.5.3 in /apiserver/requirements (#556)
Bumps [redis](https://github.com/redis/redis-py) from 4.4.2 to 4.5.3.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v4.4.2...v4.5.3)

---
updated-dependencies:
- dependency-name: redis
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-28 03:41:58 +05:30
pablohashescobarandGitHub a06d59f77d feat: sync flag for syncing issues with page blocks (#547) 2023-03-28 01:52:46 +05:30
pablohashescobarandGitHub 722a053461 fix: date for earlier this week (#540) 2023-03-28 01:52:34 +05:30
pablohashescobarandGitHub d6818e74fd feat: page view toggle in project settings (#536) 2023-03-28 01:52:21 +05:30
Aaryan KhandelwalandGitHub 37aade5ef6 fix: order by last created (#550)
* feat: block sync

* fix: order by last created order
2023-03-28 01:52:13 +05:30
Aaryan KhandelwalandGitHub b9e42d116e fix: issue modal overflow (#549)
* feat: block sync

* fix: create issue modal overflow
2023-03-28 01:52:04 +05:30
Aaryan KhandelwalandGitHub e0928d6ec5 fix: default label color (#551)
* feat: block sync

* fix: default label color
2023-03-28 01:50:55 +05:30
Aaryan KhandelwalandGitHub 909ccd578b chore: crisp integration (#552)
* feat: block sync

* chore: crisp integration

* fix: chat with us icon
2023-03-28 01:50:36 +05:30
Aaryan KhandelwalandGitHub 08ee5dc6b1 feat: block sync (#548) 2023-03-28 00:36:20 +05:30
pablohashescobarandGitHub a5a96d9f66 fix: add slack sdk in requirements file (#546) 2023-03-28 00:33:14 +05:30
pablohashescobarandGitHub 691ea0c080 feat: slack bot setup (#545) 2023-03-27 23:37:00 +05:30
pablohashescobarandGitHub 8bd557a743 feat: gzip compressor for performance upgrades (#538) 2023-03-27 23:36:13 +05:30
pablohashescobarandGitHub 35b80b422d fix: gpt task and prompt (#537) 2023-03-27 23:36:00 +05:30
pablohashescobarandGitHub d6ffc3176e fix: global search endpoint to show entities within projects and add filtering through identifiers (#543) 2023-03-27 23:34:04 +05:30
Aaryan KhandelwalandGitHub 3503b22dd9 refactor: pages folder structure (#544)
* refactor: pages folder structure, mutation issues

* fix: block edit and push

* fix: block title placeholder
2023-03-27 23:19:05 +05:30
Aaryan KhandelwalandGitHub e13b679c28 fix: join project screen flicker (#541) 2023-03-27 16:53:31 +05:30
Aaryan KhandelwalandGitHub 21dd2e703b fix: module progress bar (#535) 2023-03-26 11:37:06 +05:30
Aaryan KhandelwalandGitHub 5dd5fe2d09 feat: gpt integration for page block description (#539) 2023-03-26 11:36:10 +05:30
pablohashescobarandGitHub 52d4828e1d fix: issue filters for start date and target date (#530) 2023-03-25 23:57:31 +05:30
pablohashescobarandGitHub c5baa6183c fix: pages endpoint for dates and access (#531)
* fix: pages endpoint for dates and access

* fix: recent pages endpoint

* fix: recent pages endpoint

* fix: date object in recent pages endpoint

* dev: update nomenclature
2023-03-25 23:57:16 +05:30
pablohashescobarandGitHub 69387ffd8c fix: page labels update (#532) 2023-03-25 23:57:06 +05:30
pablohashescobarandGitHub 32ab7951f7 perf: update issue serializer (#534) 2023-03-25 23:56:53 +05:30
Aaryan KhandelwalandGitHub 5d67029b5a feat: pages (#533)
* style: page details

* style: page blocks design

* chore: pages list end points

* feat: add blocks, push blocks to issues

* feat: page labels, color options

* feat: added labels to pages

* fix: update page mutation
2023-03-25 23:39:46 +05:30
pablohashescobarandGitHub 578d724e41 feat: global search endpoint for workspace (#529) 2023-03-25 11:16:33 +05:30
pablohashescobarandGitHub a3a792741f feat: GPT integration (#526)
* feat: GPT integration

* dev: move engine value to env variable
2023-03-25 11:14:26 +05:30
pablohashescobarandGitHub 31624f3ae6 fix: start date and target date issue filter (#525) 2023-03-25 11:14:16 +05:30
pablohashescobarandGitHub 5152deb2d0 fix: model ordering (#521)
* fix: model ordering

* fix: my pages and create by others pages
2023-03-25 11:14:05 +05:30
pablohashescobarandGitHub c6ba93da72 fix: dashboard issues filters (#519)
* fix: upcoming issues filters

* dev: update dashboard endpoint to return correct month
2023-03-25 11:13:14 +05:30
pablohashescobarandGitHub 28b3c999ae dev: update recent pages endpoint and return blocks on page listing (#517)
* dev: update recent pages endpoint and return blocks on page listing

* fix: yesterday's pages
2023-03-25 11:12:54 +05:30
82b9275609 fix: deselecting filter option when clicked again (#528)
* fix: filters dropdown overflowing issue

* filters dropdown z-index

* fix: deselecting filter option when clicked again

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-03-24 23:33:28 +05:30
Dakshesh JainandGitHub f2054b6945 style: removed color text from label modal (#527)
also closing color picker on color select
2023-03-24 23:32:33 +05:30
Dakshesh JainandGitHub f3583f6415 feat: added filter 'created_by' (#524)
* feat: clear filter button

* feat: added filter 'created_by'

* style: text align left for all the issues in filters dropdown
2023-03-24 23:31:56 +05:30
Aaryan KhandelwalandGitHub 02f423bcb6 fix: workspace dashboard (#522)
* chore: completed issues graph

* style: issue stats
2023-03-24 11:06:52 +05:30
Aaryan KhandelwalandGitHub 3d34741356 chore: update view button (#523) 2023-03-24 10:56:06 +05:30
sphynxuxandGitHub 7731ee5de4 Merge pull request #520 from makeplane/feat/custom_error_page
fix: removed unnecessary variable
2023-03-24 01:15:19 +05:30
Aaryan Khandelwal 0ba2c5456a fix: removed unnecessary variable 2023-03-24 01:14:17 +05:30
sphynxuxandGitHub a0ce3f0be6 Merge pull request #518 from makeplane/feat/custom_error_page
feat: custom error page
2023-03-24 01:12:29 +05:30
Aaryan KhandelwalandGitHub 472767ab67 feat: group by created by option (#516) 2023-03-24 01:11:42 +05:30
Aaryan Khandelwal ad60b8774e feat: custom error page 2023-03-24 01:11:33 +05:30
pablohashescobarandGitHub 6c6f9a5bfd fix: dashboard endpoint for overdue and upcoming issues (#502)
* fix: dashboard endpoint for overdue and upcoming issues

* dev: update for upcoming issue to get target date null issues as well

* dev: update the filter
2023-03-24 00:13:48 +05:30
pablohashescobarandGitHub 3056727190 dev: endpoints for my, other, recent and favorite pages, add sort order and color for pages (#499)
* dev: endpoints for my, other, recent and favorite pages, add sort order and color for pages

* dev: fix state attribute error while saving page blocks
2023-03-24 00:13:26 +05:30
pablohashescobarandGitHub 053ae2063e fix: issue filtering keyerror (#494) 2023-03-24 00:13:05 +05:30
pablohashescobarandGitHub 2501c819d3 fix: keyerror on comment create and update (#492) 2023-03-24 00:12:56 +05:30
Aaryan KhandelwalandGitHub 765cfdbf7e fix: kanban loading state (#514) 2023-03-23 23:36:52 +05:30
guru_sainathandGitHub 2f2caaaf6e Feat: Github importer to sync issues, users, and labels with workspace projects. (#509)
* Dev: Github integration with issues and layout integration

* dev: Github Integration route and UI configuration
2023-03-23 23:27:11 +05:30
Aaryan KhandelwalandGitHub 7892a563b7 fix: minor ui fixes (#515)
* fix: sidebar arror positioning

* chore: show empty groups default value as true
2023-03-23 23:25:30 +05:30
Aaryan KhandelwalandGitHub f3b7fc6eb5 fix: filters dropdown overflowing issue (#513)
* fix: filters dropdown overflowing issue

* filters dropdown z-index
2023-03-23 23:25:08 +05:30
Aaryan KhandelwalandGitHub f01f2fb9bd style: my profiles page responsiveness (#512) 2023-03-23 22:55:58 +05:30
Aaryan KhandelwalandGitHub 19434342d3 fix: delete image preview after modal close (#511) 2023-03-23 22:55:12 +05:30
Aaryan KhandelwalandGitHub 6962d7718f chore: updated my issues text (#507) 2023-03-23 22:54:53 +05:30
Anmol Singh BhatiaandGitHub b6a3615f66 feat: completed cycle validation , fix: quick action and kanban fix (#505)
* feat: completed cycle card validation

* fix: unique key to hidden group

* feat: completed cycle sidebar validation

* fix: remove console log from progress chart hover

* feat: kanban and list view completed cycle validation

* feat: quick action validation

* refactor: code refactor

* fix: sidebar draft cycle status
2023-03-23 22:42:08 +05:30
Anmol Singh BhatiaandGitHub 5191fc5f7c style: create workspace bg fix (#510) 2023-03-23 22:41:30 +05:30
Aaryan KhandelwalandGitHub 567afa6d39 fix: kanban drag and drop (#508) 2023-03-23 18:52:18 +05:30
Dakshesh JainandGitHub feb0e40559 refactor: state with group 'completed' or 'cancelled' are collapsed by default (#506) 2023-03-23 18:10:28 +05:30
4a81b988b4 feat: implemented new pages design with bare minimum functionality (#503)
* chore: add page types and page api service

* chore: add create, list, update and delete on pages

* chore: add create, delete and patch page blocks

* feat: add and remove pages to favorite

* fix: made neccessary changes

- used tailwind for hover events
- add error toast alert
- used partial for patch request

* fix: replace absolute positiong with a flex box

* fix: design list page view to match with ui

* feat: add top large textarea for page title and description

* refactor: add page label with types

* feat: add pages grid layout

* feat: add tabs and masonry layout

* fix: build errors

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-03-23 16:12:14 +05:30
Aaryan KhandelwalandGitHub c755907d99 chore: new workspace endpoint (#504)
* chore: new workspace dashboard endpoint

* chore: overdue and upcoming issues
2023-03-23 15:54:59 +05:30
Aaryan KhandelwalandGitHub ad2fa91a2b chore: new global select filters component (#501) 2023-03-23 12:01:50 +05:30
Saheb GiriandGitHub 10e5ba7b3e feat: add pages and page blocks (#495)
* chore: add page types and page api service

* chore: add create, list, update and delete on pages

* chore: add create, delete and patch page blocks

* feat: add and remove pages to favorite

* fix: made neccessary changes

- used tailwind for hover events
- add error toast alert
- used partial for patch request

* fix: replace absolute positiong with a flex box
2023-03-23 11:01:06 +05:30
Aaryan KhandelwalandGitHub d477c19ad9 feat: show empty states toggle button in the views dropdown (#500)
* feat: show empty states toggle button in views dropdown

* refactor: empty state toggle naming convention, feat: hidden groups in section in the kanban board
2023-03-23 02:13:52 +05:30
Aaryan KhandelwalandGitHub 79249c5c9b fix: states order (#498)
* fix: order of states in the kanban board

* fix: state name in list view
2023-03-23 01:00:50 +05:30
pablohashescobarandGitHub 4e3c9397ea feat: page labels and favorites (#487)
* dev: initiate page labels

* dev: page labels

* dev: my pages endpoint
2023-03-22 23:41:30 +05:30
Aaryan KhandelwalandGitHub cd26b2e096 Revert "fix: sidebar and shortcut modal (#493)" (#497)
This reverts commit f615f8ac0c.
2023-03-22 23:40:25 +05:30
pablohashescobarandGitHub 27b1308227 fix: file asset uploading and deleting (#496) 2023-03-22 23:38:47 +05:30
Anmol Singh BhatiaandGitHub f615f8ac0c fix: sidebar and shortcut modal (#493)
* fix: shortcut modal cmd and ctrl fix

* fix: sidebar collapse state ordering
2023-03-22 19:08:04 +05:30
Dakshesh JainandGitHub 031f6443a0 style: kanban loader (#491) 2023-03-22 19:06:36 +05:30
Dakshesh JainandGitHub e48d98cea6 feat: showing progress while page is being loaded (#485) 2023-03-22 19:05:36 +05:30
Dakshesh JainandGitHub a830808f9d refactor: views & filter (#490)
* fix: not saving filters on views detail page

* refactor: using issues endpoint to get issues in views detail page

feat: showing toast alert on saving view
2023-03-22 18:18:19 +05:30
Aaryan KhandelwalandGitHub 9a97c97336 chore: new modules and cycles response (#489) 2023-03-22 18:10:38 +05:30
Aaryan KhandelwalandGitHub 2e346158ba fix: minor ui fixes (#488) 2023-03-22 16:58:32 +05:30
Aaryan KhandelwalandGitHub 283950c8e2 style: views (#486) 2023-03-22 14:47:13 +05:30
vamsi 818d1147d5 dev: new migrations for added fields and introducing Pages 2023-03-22 01:39:53 +05:30
pablohashescobarandGitHub c4594bff01 feat: cycles and modules issues state group percentages (#484)
* dev: state group issue percentage on cycle list

* dev: add issue percentage fields for modules and query updates on cycle apis
2023-03-22 01:36:52 +05:30
pablohashescobarandGitHub 5e81600e38 feat: jira issue importer (#476)
* dev: initialize jira importer

* dev: create service import for jira

* dev: update task to create all users for project and workspace and also create assignees when importing bulk assignees

* dev: create bulk modules import endpoint for jira epics

* dev: create bulk module issues when importing modules
2023-03-22 01:36:38 +05:30
pablohashescobarandGitHub 846e73e3b8 feat: add issue filters for cycles and modules (#475) 2023-03-22 01:36:06 +05:30
pablohashescobarandGitHub 4dc76eac19 fix: project url max length (#471) 2023-03-22 01:35:53 +05:30
pablohashescobarandGitHub f7dbc5e9c0 feat: page and page-blocks (#468)
* dev: initiate paper models

* feat: page and page-blocks

* dev: page id filter for page blocks
2023-03-22 01:34:10 +05:30
pablohashescobarandGitHub 025c8b3835 feat: sub issue toggle (#464) 2023-03-22 01:33:56 +05:30
pablohashescobarandGitHub 9339138c0e feat: user workspace dashboard endpoint (#461)
* dev: initiate new dashboard endpoint

* fix: updated issue week for this count and pending issues

* dev: add try catch block

* dev: add exception

* dev: issue state distribution endpoint

* dev: move from state name to state group

* dev: add overdue issues and upcoming issues
2023-03-22 01:33:44 +05:30
pablohashescobarandGitHub 988b27f909 refactor: users endpoint to return invites and assigned issues count (#457) 2023-03-22 01:33:31 +05:30
pablohashescobarandGitHub a3bacbfef1 fix: workspace invitation datetime import (#456)
* chore: update the endpoint to return last 6 months data

* dev: add datetime import
2023-03-22 01:33:19 +05:30
pablohashescobarandGitHub 698718aa8d chore: rename analytics env variable keys (#455) 2023-03-22 01:33:06 +05:30
pablohashescobarandGitHub 7fd775ab05 chore: update the endpoint to return last 6 months data (#453) 2023-03-22 01:32:51 +05:30
pablohashescobarandGitHub e5f8c94de8 feat: add flag for toggling issue views (#452) 2023-03-22 01:32:37 +05:30
Dakshesh JainandGitHub 9c388d8e50 refactor: setting filters as null if value is an empty array (#483)
* refractor: added params to fetch key

* feat: create views directly from views list page

fix: selected filter not showing up in multi-level dropdown, refactor: arranged imports

* refactor: setting filters as null if value is an empty array
2023-03-21 17:11:04 +05:30
Dakshesh JainandGitHub bf09673d09 refactor: making unsplash api request from api folder (#482)
* refractor: added params to fetch key

* feat: create views directly from views list page

fix: selected filter not showing up in multi-level dropdown, refactor: arranged imports

* refactor: making unsplash api request from api folder

to hide acces key from client side
2023-03-21 16:31:01 +05:30
Dakshesh JainandGitHub 5869c91d70 fix: added redirection to sign-in page if fetching user was a fail in the context (#481)
* refractor: added params to fetch key

* feat: create views directly from views list page

fix: selected filter not showing up in multi-level dropdown, refactor: arranged imports

* fix: added redirection to sign-in page if fetching user was a fail in the context
2023-03-21 12:48:17 +05:30
Dakshesh JainandGitHub 53df658b60 refactor: added params to filters, and removed manual mutation (#480)
* refractor: added params to fetch key

* feat: create views directly from views list page

fix: selected filter not showing up in multi-level dropdown, refactor: arranged imports

* refactor: added params to filters, and removed manual mutation
2023-03-21 12:47:47 +05:30
Anmol Singh BhatiaandGitHub 505b14e3a6 fix: ui fixes and improvement (#479)
* fix: list view longer title tooltip fix

* fix: module and cycle sidebar ui improvement
2023-03-21 12:47:10 +05:30
Anmol Singh BhatiaandGitHub b96d40f106 style: auth screens (#478)
* style: sign in page

* style: github and google sign

* style: sign with code and password

* style: not a member and not authorized for project setting

* style: join project icon

* chore: comment removed
2023-03-21 12:46:12 +05:30
Dakshesh JainandGitHub 68150a9d2b feat: create views directly from the views list page (#472)
* refractor: added params to fetch key

* feat: create views directly from views list page

fix: selected filter not showing up in multi-level dropdown, refactor: arranged imports
2023-03-18 11:34:49 +05:30
Dakshesh JainandGitHub e6b0012fe2 fix: mutation on issue create (#473)
* refractor: added params to fetch key

* feat: create views directly from views list page

fix: selected filter not showing up in multi-level dropdown, refactor: arranged imports

* fix: mutation on project create
2023-03-18 11:34:29 +05:30
Anmol Singh BhatiaandGitHub 5739d95ab4 style: onboarding, chore: refactoring (#474)
* style: onboarding screens

* style: onboarding card component and refactoring

* fix: onboarding card text fix

* fix: merge conflict fix
2023-03-18 11:34:09 +05:30
pablohashescobarandGitHub 350e183375 dev: update importer task to create user automatically and adding it to project and workspace (#467) 2023-03-17 22:57:10 +05:30
Aaryan KhandelwalandGitHub e7ef6275cd style: new buttons added (#470) 2023-03-17 10:40:38 +05:30
Aaryan KhandelwalandGitHub 4de0abfc22 fix: minor ui fixes (#469) 2023-03-17 10:39:06 +05:30
Aaryan KhandelwalandGitHub 5f796e732a fix: bot activity and comment log name (#463)
* fix: github bot activity log details

* fix: bot comment details

* fix: updated bot logs

* refactor: bot name logic
2023-03-17 10:38:01 +05:30
Dakshesh JainandGitHub 0fb9a14f15 refractor: added params to fetch key (#465) 2023-03-16 18:15:08 +05:30
Aaryan KhandelwalandGitHub 23c468786d style: filter issues dropdown (#466) 2023-03-16 18:14:07 +05:30
Dakshesh JainandGitHub 0f06589b83 style: made new issue filter dropdown (#462) 2023-03-16 16:27:18 +05:30
Aaryan KhandelwalandGitHub a84abc60b2 style: workspace dashboard (#460)
* style: workspace dashboard

* feat: activity graph

* chore: change tile colors for activity graph

* fix: activity graph tiles order, color

* style: activity intensity
2023-03-16 15:53:49 +05:30
Aaryan KhandelwalandGitHub 27324ddd93 fix: github description not appearing (#459) 2023-03-16 15:53:25 +05:30
Aaryan KhandelwalandGitHub d413dd1169 chore: update user profile stats (#458) 2023-03-16 15:53:09 +05:30
Dakshesh JainandGitHub ef0e326ca0 feat: issues filter using views (#448)
* fix: made basic UI for views, binded services and logic for views

* feat: views list, delete view, and conditionally updating filters or my view props
2023-03-16 14:07:19 +05:30
Aaryan KhandelwalandGitHub 96ad751e11 style: new workspace dashboard design (#454)
* style: workspace dashboard

* feat: activity graph
2023-03-16 01:36:21 +05:30
vamsi 8370511a66 dev: new migrations Importer, IssueView, IssueViewFavorite and a few alterations 2023-03-16 00:41:50 +05:30
pablohashescobarandGitHub 836dc4027b feat: jitsu events for sign in and sign up (#423)
* feat: jitsu events for sign in and sign up

* dev: update event data
2023-03-15 23:25:38 +05:30
pablohashescobarandGitHub 88754e6fc0 dev: separate endpoints for workspace assets and user assets (#420) 2023-03-15 23:25:23 +05:30
pablohashescobarandGitHub b6ee197b40 feat: issue filter views (#418)
* dev: views initiated

* dev: refactor filtering logic

* dev: move state grouping filter to util function

* dev: view issues create endpoint and update on filters for time

* dev: rename views to issue views

* dev: rename in serilaizer and views

* dev: update issue filters

* dev: update filter

* feat: create issue favorites

* dev: update query keys

* dev: update create and update method
2023-03-15 23:25:09 +05:30
pablohashescobarandGitHub 46f6b61928 refactor: grouper function to fix priority keys (#415) 2023-03-15 23:24:55 +05:30
pablohashescobarandGitHub 5d8f2b6b75 feat: github importer (#425)
* dev: init github importer

* dev: add endpoint for creating import

* dev: create endpoint to bulk create issues

* dev: bulk issue importer

* dev: bulk create endpoints for labels and updates in issue bulk create endpoint to create labels and links

* dev: add comments in bluk create

* dev: status import endpoint and user invitaion workflow

* dev: initiate github repo sync

* dev: bulk issue sync endpoint and fix key issue in bg task

* dev: update endpoints for service imports

* dev: update labels logic

* dev: update importer task

* dev: bulk issue activities

* dev: update importer task for mapped users

* dev: update importer endpoint to send github token

* dev: update bulk import endpoint

* fix: workspace get query

* dev: update bulk import endpoints
2023-03-15 23:24:44 +05:30
pablohashescobarandGitHub d3ca8560fc fix: project list ordering (#427) 2023-03-15 23:24:26 +05:30
pablohashescobarandGitHub ab9e0cf559 refactor: update response structure for cycle issue and module issues (#432) 2023-03-15 23:21:37 +05:30
pablohashescobarandGitHub c07cfee018 refactor: update favorites for project, cycle and module (#434)
* dev: refactor favorites

* dev: fix typo
2023-03-15 23:21:23 +05:30
pablohashescobarandGitHub ed8f0b8473 feat: user activity graph (#437)
* feat: user activity graph

* dev: issue completed and activity graph
2023-03-15 23:21:08 +05:30
pablohashescobarandGitHub 0082a98d53 dev: add cycle date validation (#442) 2023-03-15 23:20:49 +05:30
pablohashescobarandGitHub d6aadb115d refactor: users endpoint to return invites and assigned issues count (#449) 2023-03-15 23:20:31 +05:30
Aaryan KhandelwalandGitHub 4639ab3d9c fix: workspace dropdown (#447)
* fix: workspace dropdown links

* fix: module progress round off
2023-03-15 18:50:37 +05:30
Aaryan KhandelwalandGitHub c11bf7c7de feat: progress bar for module (#446) 2023-03-15 18:00:40 +05:30
Anmol Singh BhatiaandGitHub ae8902e815 style: sidebar icon color (#444) 2023-03-15 17:41:52 +05:30
Anmol Singh BhatiaandGitHub 164072e3cc fix: date checker edge case fix (#443) 2023-03-15 17:41:24 +05:30
928ebdf632 fix: mutation for issue update on both kanban & list (#436)
* refactor: issues filter logic

* fix: removed fetch logic from hooks

* feat: filter by assignee and label

* chore: remove filter buttons

* feat: filter options

* fix: mutation for issue update on both kanban & list

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-03-15 11:44:44 +05:30
Anmol Singh BhatiaandGitHub 636e8e6c60 style: empty state global component (#435) 2023-03-15 11:01:54 +05:30
Aaryan KhandelwalandGitHub bfab4865cd chore: update user file assets endpoint (#438)
* chore: new service for user assets

* chore: update user file assets endpoint
2023-03-15 11:00:42 +05:30
Aaryan KhandelwalandGitHub dbd6de0988 feat: views template created (#439) 2023-03-15 11:00:05 +05:30
Aaryan KhandelwalandGitHub 32d37ec45e refactor: custom search select component (#440) 2023-03-15 10:59:43 +05:30
Aaryan KhandelwalandGitHub bcd2ac1317 feat: issue filters dropdown created (#441) 2023-03-15 10:59:23 +05:30
Dakshesh JainandGitHub 0117ccfca2 fix: made project list authenticated (#428) 2023-03-14 12:21:06 +05:30
Aaryan KhandelwalandGitHub 0ba81a10f1 style: made the paddings and text sizes smaller (#433) 2023-03-14 12:18:14 +05:30
Aaryan KhandelwalandGitHub 3f5bbf336c fix: truncate text function (#431) 2023-03-14 12:16:38 +05:30
Aaryan KhandelwalandGitHub d6d51c2f43 fix: modules sidebar lead select not working (#429) 2023-03-14 12:16:26 +05:30
Anmol Singh BhatiaandGitHub d5d64e09d4 style: design (#430)
* style: shortcut modal

* style: feature setting module icon

* style: delete issue modal

* style: delete project modal

* style: sidebar prompt for chart and other info

* fix: create issue modal state icon

* fix: workspace dropdown
2023-03-13 23:38:43 +05:30
Narayana VadapalliandGitHub 7d7683ae6f Merge pull request #424 from pinarruiz/feature/upload-images
Github actions to push images
2023-03-13 10:20:37 +05:30
Narayana VadapalliandGitHub fcd64de8af Update push-image-frontend.yml 2023-03-13 10:19:17 +05:30
Narayana VadapalliandGitHub b2765d47b4 Update push-image-backend.yml 2023-03-13 10:15:05 +05:30
Alejandro Pinar Ruiz aca0c251b8 Github actions to push images 2023-03-12 20:35:47 +01:00
pablohashescobarandGitHub 6de6522a41 chore: permissions for api endpoints (#419) 2023-03-11 23:51:06 +05:30
Vihar KuramaandGitHub bff89ee4c6 Merge pull request #422 from makeplane/feat/issue_templates
feat: new issue templates
2023-03-11 23:50:16 +05:30
pablohashescobar 7744d9b69a feat: new issue templates 2023-03-11 23:49:02 +05:30
Aaryan KhandelwalandGitHub 441cf39d2c refactor: global workspace form (#421) 2023-03-11 17:23:23 +05:30
Anmol Singh BhatiaandGitHub 4a7f80712b style: workspace sidebar (#417) 2023-03-10 16:05:10 +05:30
Aaryan KhandelwalandGitHub c7923f6d44 feat: added load more button to github repos dropdown (#414) 2023-03-10 16:03:49 +05:30
Anmol Singh BhatiaandGitHub 4fad685ec8 style: workspace dropdown (#416)
* style: workspace dropdown

* style: workspace dropdown hover fix
2023-03-10 12:32:29 +05:30
Anmol Singh BhatiaandGitHub 704b7d02ef style : ui fixes (#412)
* fix: kanban view vertical scroll fix

* fix: delete option remove from my issue page

* fix: my issue filter key renamed with id

* fix: sidebar ellipsis alignment

* fix: cycle card favorite icon alignment

* style: icon added in card options

* fix: progress icon alignment

* style: my issue page list view
2023-03-09 22:50:34 +05:30
Anmol Singh BhatiaandGitHub 4e9149a27c style: empty cycle state (#410) 2023-03-09 22:49:03 +05:30
pablohashescobarandGitHub 0416e07f46 refactor: self hosting setup (#411)
* merge-commit: self hosted updates

* dev: updates in self hosting setup

* dev: update script to get the instance IP

* dev: update script to generate backend secret key
2023-03-09 20:49:12 +05:30
Anmol Singh BhatiaandGitHub e3e57df4a2 style: list view (#409)
* style: list view

* style: list board header spacing fix
2023-03-09 16:05:25 +05:30
460 changed files with 23284 additions and 12161 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Bug report
description: Create a bug report to help us improve Plane
title: "[bug]: "
labels: [bug, need testing]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this bug report.
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered
options:
- label: I have searched the existing issues
required: true
- type: textarea
attributes:
label: Current behavior
description: A concise description of what you're experiencing and what you expect
placeholder: |
When I do <X>, <Y> happens and I see the error message attached below:
```...```
What I expect is <Z>
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: Add steps to reproduce this behaviour, include console or network logs and screenshots
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: dropdown
id: env
attributes:
label: Environment
options:
- Production
- Deploy preview
validations:
required: true
- type: dropdown
id: version
attributes:
label: Version
options:
- Cloud
- Self-hosted
- Local
validations:
required: true
@@ -0,0 +1,28 @@
name: Feature request
description: Suggest a feature to improve Plane
title: "[feature]: "
labels: [feature]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to request a feature for Plane
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue related to this feature request already exists
options:
- label: I have searched the existing issues
required: true
- type: textarea
attributes:
label: Summary
description: One paragraph description of the feature
validations:
required: true
- type: textarea
attributes:
label: Why should this be worked on?
description: A concise description of the problems or use cases for this feature request
validations:
required: true
+6
View File
@@ -0,0 +1,6 @@
contact_links:
- name: Help and support
about: Reach out to us on our Discord server or GitHub discussions.
- name: Dedicated support
url: mailto:support@plane.so
about: Write to us if you'd like dedicated support using Plane
+54
View File
@@ -0,0 +1,54 @@
name: Build Api Server Docker Image
on:
push:
branches:
- 'develop'
- 'master'
tags:
- '*'
jobs:
build_push_backend:
name: Build Api Server Docker Image
runs-on: ubuntu-20.04
permissions:
contents: read
packages: write
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.1.0
with:
platforms: linux/arm64,linux/amd64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Github Container Registry
uses: docker/login-action@v2.1.0
with:
registry: "ghcr.io"
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4.3.0
with:
images: ghcr.io/${{ github.repository }}-backend
- name: Build Api Server
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+54
View File
@@ -0,0 +1,54 @@
name: Build Frontend Docker Image
on:
push:
branches:
- 'develop'
- 'master'
tags:
- '*'
jobs:
build_push_frontend:
name: Build Frontend Docker Image
runs-on: ubuntu-20.04
permissions:
contents: read
packages: write
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.1.0
with:
platforms: linux/arm64,linux/amd64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Github Container Registry
uses: docker/login-action@v2.1.0
with:
registry: "ghcr.io"
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4.3.0
with:
images: ghcr.io/${{ github.repository }}-frontend
- name: Build Frontend Server
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/app/Dockerfile.web
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+104 -11
View File
@@ -2,35 +2,128 @@
<p align="center">
<a href="https://plane.so">
<img src="https://res.cloudinary.com/dgxawjvpo/image/upload/v1673379660/Plane/plane-logo_0m83xue7R_f0v9r9.png" alt="Plane Logo" width="350">
<img src="https://res.cloudinary.com/toolspacedev/image/upload/v1680596414/Plane/Plane_Icon_Blue_on_White_150x150_muysa3.jpg" alt="Plane Logo" width="70">
</a>
</p>
<h3 align="center"><b>Plane</b></h3>
<p align="center"><b>Open-source, self-hosted project planning tool</b></p>
<p align="center">
<a href="https://discord.com/invite/A92xrEGCge">
<img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
</a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
</p>
<br />
Plane is an open-source project planning tool that is designed to help individuals and teams streamline their issues, sprints, and product roadmaps. It is easy to use and can be accessed by anyone, making it an ideal choice for a wide range of projects and organizations.
<br /> <br />
<p>
<a href="https://app.plane.so/" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680599798/Plane/plane_1_1_tnb32j.png"
alt="Plane Screens"
width="100%"
/>
</a>
</p>
Meet Plane. An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️.
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/29tPNhaV) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
## Getting Started
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting).
Visit https://app.plane.so to get started with Plane.
## Documentation
## ⚡️ Quick start with Docker Compose
### Docker Compose Setup
- Clone the Repository
```bash
git clone https://github.com/makeplane/plane
```
- Change Directory
```bash
cd plane
```
- Run setup.sh
```bash
./setup.sh localhost
```
> If running in a cloud env replace localhost with public facing IP address of the VM
- Run Docker compose up
```bash
docker-compose up
```
## 🚀 Features
* **Issue Planning and Tracking**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to issues for better organization and tracking.
* **Issue Attachments**: Collaborate effectively by attaching files to issues, making it easy for your team to find and share important project-related documents.
* **Layouts**: Customize your project view with your preferred layout - choose from List, Kanban, or Calendar to visualize your project in a way that makes sense to you.
* **Cycles**: Plan sprints with Cycles to keep your team on track and productive. Gain insights into your project's progress with burn-down charts and other useful features.
* **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to easily track and plan your project's progress.
* **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.
* **Pages**: Plane pages function as an AI-powered notepad, allowing you to easily document issues, cycle plans, and module details, and then synchronize them with your issues.
* **Command K**: Enjoy a better user experience with the new Command + K menu. Easily manage and navigate through your projects from one convenient location.
* **GitHub Sync**: Streamline your planning process by syncing your GitHub issues with Plane. Keep all your issues in one place for better tracking and collaboration.
## 📸 Screenshots
<p>
<a href="https://app.plane.so/" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601719/Plane/plane_2_iqao52.png"
alt="Plane Issue Details"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680604273/Plane/plane_5_1_nwsl3a.png"
alt="Plane Cycles and Modules"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601713/Plane/plane_4_cqm0g8.png"
alt="Plane Quick Lists"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601712/Plane/plane_3_1_cu4fsc.png"
alt="Plane Command K"
width="100%"
/>
</a>
</p>
## 📚Documentation
For full documentation, visit [docs.plane.so](https://docs.plane.so/)
To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md).
## Status
## 🔋 Status
- [x] Early Community Previews: We are open-sourcing and sharing the development version of Plane
- [ ] Alpha: We are testing Plane with a closed set of customers
@@ -38,7 +131,7 @@ To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/m
- [ ] Public Beta: Stable enough for most non-enterprise use-cases
- [ ] Public: Production-ready
## Community
## ❤️ Community
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
@@ -46,6 +139,6 @@ To chat with other community members you can join the [Plane Discord](https://di
Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels.
## Security
## ⛓️ Security
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities.
+12 -10
View File
@@ -1,22 +1,24 @@
SECRET_KEY="<-- django secret -->"
DJANGO_SETTINGS_MODULE="plane.settings.production"
# Database
DATABASE_URL=postgres://plane:plane@db:5432/plane
DATABASE_URL=postgres://plane:xyzzyspoon@db:5432/plane
# Cache
REDIS_URL=redis://redis:6379/
# SMPT
EMAIL_HOST="<-- email smtp -->"
EMAIL_HOST_USER="<-- email host user -->"
EMAIL_HOST_PASSWORD="<-- email host password -->"
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
# AWS
AWS_REGION="<-- aws region -->"
AWS_ACCESS_KEY_ID="<-- aws access key -->"
AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->"
AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->"
AWS_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_S3_BUCKET_NAME=""
# FE
WEB_URL="localhost/"
# OAUTH
GITHUB_CLIENT_SECRET="<-- github secret -->"
GITHUB_CLIENT_SECRET=""
# Flags
DISABLE_COLLECTSTATIC=1
DOCKERIZED=1
# GPT Envs
OPENAI_API_KEY=0
GPT_ENGINE=0
+41 -1
View File
@@ -3,7 +3,8 @@ import uuid
import random
from django.contrib.auth.hashers import make_password
from plane.db.models import ProjectIdentifier
from plane.db.models import Issue, IssueComment, User, Project
from plane.db.models import Issue, IssueComment, User, Project, ProjectMember
# Update description and description html values for old descriptions
@@ -134,3 +135,42 @@ def update_project_cover_images():
except Exception as e:
print(e)
print("Failed")
def update_user_view_property():
try:
project_members = ProjectMember.objects.all()
updated_project_members = []
for project_member in project_members:
project_member.default_props = {
"filters": {"type": None},
"orderBy": "-created_at",
"collapsed": True,
"issueView": "list",
"filterIssue": None,
"groupByProperty": True,
"showEmptyGroups": True,
}
updated_project_members.append(project_member)
ProjectMember.objects.bulk_update(
updated_project_members, ["default_props"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_label_color():
try:
labels = Label.objects.filter(color="")
updated_labels = []
for label in labels:
label.color = "#" + "%06x" % random.randint(0, 0xFFFFFF)
updated_labels.append(label)
Label.objects.bulk_update(updated_labels, ["color"], batch_size=100)
print("Success")
except Exception as e:
print(e)
print("Failed")
+9 -2
View File
@@ -10,6 +10,7 @@ from .workspace import (
WorkSpaceMemberSerializer,
TeamSerializer,
WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer,
)
from .project import (
ProjectSerializer,
@@ -18,10 +19,11 @@ from .project import (
ProjectMemberInviteSerializer,
ProjectIdentifierSerializer,
ProjectFavoriteSerializer,
ProjectLiteSerializer,
)
from .state import StateSerializer
from .state import StateSerializer, StateLiteSerializer
from .shortcut import ShortCutSerializer
from .view import ViewSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer
from .asset import FileAssetSerializer
from .issue import (
@@ -38,6 +40,7 @@ from .issue import (
IssueFlatSerializer,
IssueStateSerializer,
IssueLinkSerializer,
IssueLiteSerializer,
)
from .module import (
@@ -58,3 +61,7 @@ from .integration import (
GithubRepositorySyncSerializer,
GithubCommentSyncSerializer,
)
from .importer import ImporterSerializer
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
+11
View File
@@ -5,12 +5,23 @@ from rest_framework import serializers
from .base import BaseSerializer
from .user import UserLiteSerializer
from .issue import IssueStateSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import Cycle, CycleIssue, CycleFavorite
class CycleSerializer(BaseSerializer):
owned_by = UserLiteSerializer(read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Cycle
@@ -0,0 +1,12 @@
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from plane.db.models import Importer
class ImporterSerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
class Meta:
model = Importer
fields = "__all__"
+44 -5
View File
@@ -4,10 +4,10 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .state import StateSerializer
from .state import StateSerializer, StateLiteSerializer
from .user import UserLiteSerializer
from .project import ProjectSerializer
from .workspace import WorkSpaceSerializer
from .project import ProjectSerializer, ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from plane.db.models import (
User,
Issue,
@@ -50,8 +50,8 @@ class IssueFlatSerializer(BaseSerializer):
class IssueCreateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state")
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
project_detail = ProjectSerializer(read_only=True, source="project")
workspace_detail = WorkSpaceSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
assignees_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
@@ -244,6 +244,7 @@ class IssueCreateSerializer(BaseSerializer):
class IssueActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
class Meta:
model = IssueActivity
@@ -305,6 +306,16 @@ class LabelSerializer(BaseSerializer):
]
class LabelLiteSerializer(BaseSerializer):
class Meta:
model = Label
fields = [
"id",
"name",
"color",
]
class IssueLabelSerializer(BaseSerializer):
# label_details = LabelSerializer(read_only=True, source="label")
@@ -434,6 +445,8 @@ class IssueStateSerializer(BaseSerializer):
project_detail = ProjectSerializer(read_only=True, source="project")
label_details = LabelSerializer(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)
class Meta:
model = Issue
@@ -466,3 +479,29 @@ class IssueSerializer(BaseSerializer):
"created_at",
"updated_at",
]
class IssueLiteSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
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)
cycle_id = serializers.UUIDField(read_only=True)
module_id = serializers.UUIDField(read_only=True)
class Meta:
model = Issue
fields = "__all__"
read_only_fields = [
"start_date",
"target_date",
"completed_at",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
+30 -3
View File
@@ -4,10 +4,18 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .project import ProjectSerializer
from .project import ProjectSerializer, ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .issue import IssueStateSerializer
from plane.db.models import User, Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
from plane.db.models import (
User,
Module,
ModuleMember,
ModuleIssue,
ModuleLink,
ModuleFavorite,
)
class ModuleWriteSerializer(BaseSerializer):
@@ -17,6 +25,9 @@ class ModuleWriteSerializer(BaseSerializer):
required=False,
)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = Module
fields = "__all__"
@@ -128,14 +139,29 @@ class ModuleLinkSerializer(BaseSerializer):
"module",
]
# Validation if url already exists
def create(self, validated_data):
if ModuleLink.objects.filter(
url=validated_data.get("url"), module_id=validated_data.get("module_id")
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
return ModuleLink.objects.create(**validated_data)
class ModuleSerializer(BaseSerializer):
project_detail = ProjectSerializer(read_only=True, source="project")
lead_detail = UserLiteSerializer(read_only=True, source="lead")
members_detail = UserLiteSerializer(read_only=True, many=True, source="members")
issue_module = ModuleIssueSerializer(read_only=True, many=True)
link_module = ModuleLinkSerializer(read_only=True, many=True)
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
class Meta:
model = Module
@@ -149,6 +175,7 @@ class ModuleSerializer(BaseSerializer):
"updated_at",
]
class ModuleFavoriteSerializer(BaseSerializer):
module_detail = ModuleFlatSerializer(source="module", read_only=True)
+105
View File
@@ -0,0 +1,105 @@
# Third party imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import IssueFlatSerializer, LabelSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label
class PageBlockSerializer(BaseSerializer):
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = PageBlock
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"page",
]
class PageSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
label_details = LabelSerializer(read_only=True, source="labels", many=True)
labels_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
)
blocks = PageBlockSerializer(read_only=True, many=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = Page
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"owned_by",
]
def create(self, validated_data):
labels = validated_data.pop("labels_list", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
page = Page.objects.create(
**validated_data, project_id=project_id, owned_by_id=owned_by_id
)
if labels is not None:
PageLabel.objects.bulk_create(
[
PageLabel(
label=label,
page=page,
project_id=project_id,
workspace_id=page.workspace_id,
created_by_id=page.created_by_id,
updated_by_id=page.updated_by_id,
)
for label in labels
],
batch_size=10,
)
return page
def update(self, instance, validated_data):
labels = validated_data.pop("labels_list", None)
if labels is not None:
PageLabel.objects.filter(page=instance).delete()
PageLabel.objects.bulk_create(
[
PageLabel(
label=label,
page=instance,
project_id=instance.project_id,
workspace_id=instance.workspace_id,
created_by_id=instance.created_by_id,
updated_by_id=instance.updated_by_id,
)
for label in labels
],
batch_size=10,
)
return super().update(instance, validated_data)
class PageFavoriteSerializer(BaseSerializer):
page_detail = PageSerializer(source="page", read_only=True)
class Meta:
model = PageFavorite
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"user",
]
+16 -4
View File
@@ -6,7 +6,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.api.serializers.workspace import WorkSpaceSerializer
from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSerializer
from plane.api.serializers.user import UserLiteSerializer
from plane.db.models import (
Project,
@@ -18,6 +18,8 @@ from plane.db.models import (
class ProjectSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = Project
fields = "__all__"
@@ -56,12 +58,15 @@ class ProjectSerializer(BaseSerializer):
project_identifier = ProjectIdentifier.objects.filter(
name=identifier, workspace_id=instance.workspace_id
).first()
if project_identifier is None:
project = super().update(instance, validated_data)
_ = ProjectIdentifier.objects.update(name=identifier, project=project)
project_identifier = ProjectIdentifier.objects.filter(
project=project
).first()
if project_identifier is not None:
project_identifier.name = identifier
project_identifier.save()
return project
# If found check if the project_id to be updated and identifier project id is same
if project_identifier.project_id == instance.id:
# If same pass update
@@ -118,3 +123,10 @@ class ProjectFavoriteSerializer(BaseSerializer):
"workspace",
"user",
]
class ProjectLiteSerializer(BaseSerializer):
class Meta:
model = Project
fields = ["id", "identifier", "name"]
read_only_fields = fields
+17
View File
@@ -1,10 +1,15 @@
# Module imports
from .base import BaseSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import State
class StateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = State
fields = "__all__"
@@ -12,3 +17,15 @@ class StateSerializer(BaseSerializer):
"workspace",
"project",
]
class StateLiteSerializer(BaseSerializer):
class Meta:
model = State
fields = [
"id",
"name",
"color",
"group",
]
read_only_fields = fields
+48 -4
View File
@@ -1,14 +1,58 @@
# Third party imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import View
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import IssueView, IssueViewFavorite
from plane.utils.issue_filters import issue_filters
class ViewSerializer(BaseSerializer):
class IssueViewSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = View
model = IssueView
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"query",
]
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
if not bool(query_params):
raise serializers.ValidationError(
{"query_data": ["Query data field cannot be empty"]}
)
validated_data["query"] = issue_filters(query_params, "POST")
return IssueView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if not bool(query_params):
raise serializers.ValidationError(
{"query_data": ["Query data field cannot be empty"]}
)
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
class IssueViewFavoriteSerializer(BaseSerializer):
view_detail = IssueViewSerializer(source="issue_view", read_only=True)
class Meta:
model = IssueViewFavorite
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"user",
]
+11 -4
View File
@@ -10,7 +10,6 @@ from plane.db.models import Workspace, WorkspaceMember, Team, WorkspaceMemberInv
class WorkSpaceSerializer(BaseSerializer):
owner = UserLiteSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True)
@@ -28,7 +27,6 @@ class WorkSpaceSerializer(BaseSerializer):
class WorkSpaceMemberSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
workspace = WorkSpaceSerializer(read_only=True)
@@ -38,7 +36,6 @@ class WorkSpaceMemberSerializer(BaseSerializer):
class WorkSpaceMemberInviteSerializer(BaseSerializer):
workspace = WorkSpaceSerializer(read_only=True)
class Meta:
@@ -47,7 +44,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
class TeamSerializer(BaseSerializer):
members_detail = UserLiteSerializer(read_only=True, source="members", many=True)
members = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
@@ -93,3 +89,14 @@ class TeamSerializer(BaseSerializer):
return super().update(instance, validated_data)
else:
return super().update(instance, validated_data)
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = [
"name",
"slug",
"id",
]
read_only_fields = fields
+247 -7
View File
@@ -21,6 +21,7 @@ from plane.api.views import (
# User
UserEndpoint,
UpdateUserOnBoardedEndpoint,
UserActivityEndpoint,
## End User
# Workspaces
WorkSpaceViewSet,
@@ -38,9 +39,13 @@ from plane.api.views import (
AddTeamToProjectEndpoint,
UserLastProjectWithWorkspaceEndpoint,
UserWorkspaceInvitationEndpoint,
UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
## End Workspaces
# File Assets
FileAssetEndpoint,
UserAssetsEndpoint,
## End File Assets
# Projects
ProjectViewSet,
@@ -61,13 +66,14 @@ from plane.api.views import (
IssueCommentViewSet,
UserWorkSpaceIssues,
BulkDeleteIssuesEndpoint,
BulkImportIssuesEndpoint,
ProjectUserViewsEndpoint,
TimeLineIssueViewSet,
IssuePropertyViewSet,
LabelViewSet,
SubIssuesEndpoint,
IssueLinkViewSet,
ModuleLinkViewSet,
BulkCreateIssueLabelsEndpoint,
## End Issues
# States
StateViewSet,
@@ -76,7 +82,9 @@ from plane.api.views import (
ShortCutViewSet,
## End Shortcuts
# Views
ViewViewSet,
IssueViewViewSet,
ViewIssuesEndpoint,
IssueViewFavoriteViewSet,
## End Views
# Cycles
CycleViewSet,
@@ -86,12 +94,26 @@ from plane.api.views import (
CompletedCyclesEndpoint,
CycleFavoriteViewSet,
DraftCyclesEndpoint,
TransferCycleIssueEndpoint,
InCompleteCyclesEndpoint,
## End Cycles
# Modules
ModuleViewSet,
ModuleIssueViewSet,
ModuleFavoriteViewSet,
ModuleLinkViewSet,
BulkImportModulesEndpoint,
## End Modules
# Pages
PageViewSet,
PageBlockViewSet,
PageFavoriteViewSet,
CreateIssueFromPageBlockEndpoint,
RecentPagesEndpoint,
FavoritePagesEndpoint,
MyPagesEndpoint,
CreatedbyOtherPagesEndpoint,
## End Pages
# Api Tokens
ApiTokenEndpoint,
## End Api Tokens
@@ -102,7 +124,19 @@ from plane.api.views import (
GithubRepositorySyncViewSet,
GithubIssueSyncViewSet,
GithubCommentSyncViewSet,
BulkCreateGithubIssueSyncEndpoint,
## End Integrations
# Importer
ServiceIssueImportSummaryEndpoint,
ImportServiceEndpoint,
UpdateServiceImportStatusEndpoint,
## End importer
# Search
GlobalSearchEndpoint,
## End Search
# Gpt
GPTIntegrationEndpoint,
## End Gpt
)
@@ -153,6 +187,7 @@ urlpatterns = [
UpdateUserOnBoardedEndpoint.as_view(),
name="change-password",
),
path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
# user workspaces
path(
"users/me/workspaces/",
@@ -176,6 +211,23 @@ urlpatterns = [
name="workspace",
),
# user join workspace
# User Graphs
path(
"users/me/workspaces/<str:slug>/activity-graph/",
UserActivityGraphEndpoint.as_view(),
name="user-activity-graph",
),
path(
"users/me/workspaces/<str:slug>/issues-completed-graph/",
UserIssueCompletedGraphEndpoint.as_view(),
name="completed-graph",
),
path(
"users/me/workspaces/<str:slug>/dashboard/",
UserWorkspaceDashboardEndpoint.as_view(),
name="user-workspace-dashboard",
),
## User Graph
path(
"users/me/invitations/workspaces/<str:slug>/<uuid:pk>/join/",
JoinWorkspaceEndpoint.as_view(),
@@ -452,7 +504,7 @@ urlpatterns = [
# Views
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/",
ViewViewSet.as_view(
IssueViewViewSet.as_view(
{
"get": "list",
"post": "create",
@@ -462,7 +514,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
ViewViewSet.as_view(
IssueViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
@@ -472,6 +524,30 @@ urlpatterns = [
),
name="project-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:view_id>/issues/",
ViewIssuesEndpoint.as_view(),
name="project-view-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
IssueViewFavoriteViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="user-favorite-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/<uuid:view_id>/",
IssueViewFavoriteViewSet.as_view(
{
"delete": "destroy",
}
),
name="user-favorite-view",
),
## End Views
## Cycles
path(
@@ -557,6 +633,16 @@ urlpatterns = [
),
name="user-favorite-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/transfer-issues/",
TransferCycleIssueEndpoint.as_view(),
name="transfer-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/incomplete-cycles/",
InCompleteCyclesEndpoint.as_view(),
name="transfer-issues",
),
## End Cycles
# Issue
path(
@@ -608,9 +694,20 @@ urlpatterns = [
),
name="project-issue-labels",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-create-labels/",
BulkCreateIssueLabelsEndpoint.as_view(),
name="project-bulk-labels",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-delete-issues/",
BulkDeleteIssuesEndpoint.as_view(),
name="project-issues-bulk",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-issues/<str:service>/",
BulkImportIssuesEndpoint.as_view(),
name="project-issues-bulk",
),
path(
"workspaces/<str:slug>/my-issues/",
@@ -728,12 +825,22 @@ urlpatterns = [
path(
"workspaces/<str:slug>/file-assets/",
FileAssetEndpoint.as_view(),
name="File Assets",
name="file-assets",
),
path(
"workspaces/<str:slug>/file-assets/<uuid:pk>/",
"workspaces/file-assets/<uuid:workspace_id>/<str:asset_key>/",
FileAssetEndpoint.as_view(),
name="File Assets",
name="file-assets",
),
path(
"users/file-assets/",
UserAssetsEndpoint.as_view(),
name="user-file-assets",
),
path(
"users/file-assets/<str:asset_key>/",
UserAssetsEndpoint.as_view(),
name="user-file-assets",
),
## End File Assets
## Modules
@@ -822,7 +929,100 @@ urlpatterns = [
),
name="user-favorite-module",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-modules/<str:service>/",
BulkImportModulesEndpoint.as_view(),
name="bulk-modules-create",
),
## End Modules
# Pages
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
PageViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/",
PageViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/page-blocks/",
PageBlockViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-page-blocks",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/page-blocks/<uuid:pk>/",
PageBlockViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-page-blocks",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/",
PageFavoriteViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="user-favorite-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/<uuid:page_id>/",
PageFavoriteViewSet.as_view(
{
"delete": "destroy",
}
),
name="user-favorite-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/page-blocks/<uuid:page_block_id>/issues/",
CreateIssueFromPageBlockEndpoint.as_view(),
name="page-block-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/recent-pages/",
RecentPagesEndpoint.as_view(),
name="recent-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/favorite-pages/",
FavoritePagesEndpoint.as_view(),
name="recent-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/my-pages/",
MyPagesEndpoint.as_view(),
name="user-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/created-by-other-pages/",
CreatedbyOtherPagesEndpoint.as_view(),
name="created-by-other-pages",
),
## End Pages
# API Tokens
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"),
@@ -909,6 +1109,10 @@ urlpatterns = [
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/bulk-create-github-issue-sync/",
BulkCreateGithubIssueSyncEndpoint.as_view(),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
GithubIssueSyncViewSet.as_view(
@@ -938,4 +1142,40 @@ urlpatterns = [
),
## End Github Integrations
## End Integrations
# Importer
path(
"workspaces/<str:slug>/importers/<str:service>/",
ServiceIssueImportSummaryEndpoint.as_view(),
name="importer",
),
path(
"workspaces/<str:slug>/projects/importers/<str:service>/",
ImportServiceEndpoint.as_view(),
name="importer",
),
path(
"workspaces/<str:slug>/importers/",
ImportServiceEndpoint.as_view(),
name="importer",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/service/<str:service>/importers/<uuid:importer_id>/",
UpdateServiceImportStatusEndpoint.as_view(),
name="importer",
),
## End Importer
# Search
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/search/",
GlobalSearchEndpoint.as_view(),
name="global-search",
),
## End Search
# Gpt
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
GPTIntegrationEndpoint.as_view(),
name="importer",
),
## End Gpt
]
+34 -2
View File
@@ -16,6 +16,7 @@ from .project import (
from .people import (
UserEndpoint,
UpdateUserOnBoardedEndpoint,
UserActivityEndpoint,
)
from .oauth import OauthEndpoint
@@ -36,10 +37,13 @@ from .workspace import (
UserLastProjectWithWorkspaceEndpoint,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
)
from .state import StateViewSet
from .shortcut import ShortCutViewSet
from .view import ViewViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .cycle import (
CycleViewSet,
CycleIssueViewSet,
@@ -48,8 +52,10 @@ from .cycle import (
CompletedCyclesEndpoint,
CycleFavoriteViewSet,
DraftCyclesEndpoint,
TransferCycleIssueEndpoint,
InCompleteCyclesEndpoint,
)
from .asset import FileAssetEndpoint
from .asset import FileAssetEndpoint, UserAssetsEndpoint
from .issue import (
IssueViewSet,
WorkSpaceIssuesEndpoint,
@@ -62,6 +68,7 @@ from .issue import (
UserWorkSpaceIssues,
SubIssuesEndpoint,
IssueLinkViewSet,
BulkCreateIssueLabelsEndpoint,
)
from .auth_extended import (
@@ -96,4 +103,29 @@ from .integration import (
GithubRepositorySyncViewSet,
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
BulkCreateGithubIssueSyncEndpoint,
)
from .importer import (
ServiceIssueImportSummaryEndpoint,
ImportServiceEndpoint,
UpdateServiceImportStatusEndpoint,
BulkImportIssuesEndpoint,
BulkImportModulesEndpoint,
)
from .page import (
PageViewSet,
PageBlockViewSet,
PageFavoriteViewSet,
CreateIssueFromPageBlockEndpoint,
RecentPagesEndpoint,
FavoritePagesEndpoint,
MyPagesEndpoint,
CreatedbyOtherPagesEndpoint,
)
from .search import GlobalSearchEndpoint
from .gpt import GPTIntegrationEndpoint
+51 -4
View File
@@ -17,8 +17,9 @@ class FileAssetEndpoint(BaseAPIView):
A viewset for viewing and editing task instances.
"""
def get(self, request, slug):
files = FileAsset.objects.filter(workspace__slug=slug)
def get(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
files = FileAsset.objects.filter(asset=asset_key)
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
return Response(serializer.data)
@@ -42,9 +43,55 @@ class FileAssetEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
def delete(self, request, slug, pk):
def delete(self, request, workspace_id, asset_key):
try:
file_asset = FileAsset.objects.get(pk=pk, workspace__slug=slug)
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
# Delete the file from storage
file_asset.asset.delete(save=False)
# Delete the file object
file_asset.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except FileAsset.DoesNotExist:
return Response(
{"error": "File Asset doesn't exist"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UserAssetsEndpoint(BaseAPIView):
def get(self, request, asset_key):
try:
files = FileAsset.objects.filter(asset=asset_key, created_by=request.user)
serializer = FileAssetSerializer(files, context={"request": request})
return Response(serializer.data)
except FileAsset.DoesNotExist:
return Response(
{"error": "File Asset does not exist"}, status=status.HTTP_404_NOT_FOUND
)
def post(self, request):
try:
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, 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 delete(self, request, asset_key):
try:
file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user)
# Delete the file from storage
file_asset.asset.delete(save=False)
# Delete the file object
+90 -1
View File
@@ -3,6 +3,7 @@ import uuid
import random
import string
import json
import requests
# Django imports
from django.utils import timezone
@@ -85,6 +86,28 @@ class SignInEndpoint(BaseAPIView):
"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)
# Sign in Process
else:
@@ -114,7 +137,27 @@ class SignInEndpoint(BaseAPIView):
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,
@@ -268,6 +311,29 @@ class MagicSignInEndpoint(BaseAPIView):
if str(token) == str(user_token):
if User.objects.filter(email=email).exists():
user = User.objects.get(email=email)
# Send event to Jitsu for tracking
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": "code",
},
"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",
},
)
else:
user = User.objects.create(
email=email,
@@ -275,6 +341,29 @@ class MagicSignInEndpoint(BaseAPIView):
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
# Send event to Jitsu for tracking
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": "code",
},
"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.last_active = timezone.now()
user.last_login_time = timezone.now()
+398 -45
View File
@@ -3,9 +3,11 @@ import json
# Django imports
from django.db import IntegrityError
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef, Count, Prefetch
from django.core import serializers
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
# Third party imports
from rest_framework.response import Response
@@ -18,11 +20,18 @@ from plane.api.serializers import (
CycleSerializer,
CycleIssueSerializer,
CycleFavoriteSerializer,
IssueStateSerializer,
)
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Cycle, CycleIssue, Issue, CycleFavorite
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
CycleFavorite,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
class CycleViewSet(BaseViewSet):
@@ -38,6 +47,12 @@ class CycleViewSet(BaseViewSet):
)
def get_queryset(self):
subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
@@ -47,26 +62,42 @@ class CycleViewSet(BaseViewSet):
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.order_by("-is_favorite", "name")
.distinct()
)
def list(self, request, slug, project_id):
try:
subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
cycles = self.get_queryset().annotate(is_favorite=Exists(subquery))
return Response(CycleSerializer(cycles, many=True).data)
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):
try:
if (
@@ -98,6 +129,36 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, pk):
try:
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if cycle.end_date is not None and cycle.end_date < timezone.now().date():
return Response(
{
"error": "The Cycle has already been completed so it cannot be edited"
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleSerializer(cycle, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Cycle.DoesNotExist:
return Response(
{"error": "Cycle 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,
)
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
@@ -140,22 +201,43 @@ class CycleIssueViewSet(BaseViewSet):
.distinct()
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id, cycle_id):
try:
order_by = request.GET.get("order_by", "created_at")
queryset = self.get_queryset().order_by(f"issue__{order_by}")
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)
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(bridge_id=F("issue_cycle__id"))
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by(order_by)
.filter(**filters)
)
cycle_issues = CycleIssueSerializer(queryset, many=True).data
issues_data = IssueStateSerializer(issues, many=True).data
if group_by:
return Response(
group_results(cycle_issues, f"issue_detail.{group_by}"),
group_results(issues_data, group_by),
status=status.HTTP_200_OK,
)
return Response(
cycle_issues,
issues_data,
status=status.HTTP_200_OK,
)
except Exception as e:
@@ -178,6 +260,14 @@ class CycleIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
if cycle.end_date is not None and cycle.end_date < timezone.now().date():
return Response(
{
"error": "The Cycle has already been completed so no new issues can be added"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get all CycleIssues already created
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
records_to_update = []
@@ -263,10 +353,20 @@ class CycleIssueViewSet(BaseViewSet):
class CycleDateCheckEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
try:
start_date = request.data.get("start_date")
end_date = request.data.get("end_date")
start_date = request.data.get("start_date", False)
end_date = request.data.get("end_date", False)
if not start_date or not end_date:
return Response(
{"error": "Start date and end date both are required"},
status=status.HTTP_400_BAD_REQUEST,
)
cycles = Cycle.objects.filter(
Q(start_date__lte=start_date, end_date__gte=start_date)
@@ -294,6 +394,10 @@ class CycleDateCheckEndpoint(BaseAPIView):
class CurrentUpcomingCyclesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = CycleFavorite.objects.filter(
@@ -302,18 +406,94 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
project_id=project_id,
workspace__slug=slug,
)
current_cycle = Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
).annotate(is_favorite=Exists(subquery))
current_cycle = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.order_by("name", "-is_favorite")
)
upcoming_cycle = Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
start_date__gt=timezone.now(),
).annotate(is_favorite=Exists(subquery))
upcoming_cycle = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
start_date__gt=timezone.now(),
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.order_by("name", "-is_favorite")
)
return Response(
{
@@ -332,6 +512,10 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
class CompletedCyclesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = CycleFavorite.objects.filter(
@@ -340,11 +524,49 @@ class CompletedCyclesEndpoint(BaseAPIView):
project_id=project_id,
workspace__slug=slug,
)
completed_cycles = Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
end_date__lt=timezone.now(),
).annotate(is_favorite=Exists(subquery))
completed_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
end_date__lt=timezone.now(),
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.order_by("name", "-is_favorite")
)
return Response(
{
@@ -364,13 +586,61 @@ class CompletedCyclesEndpoint(BaseAPIView):
class DraftCyclesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
draft_cycles = Cycle.objects.filter(
workspace__slug=slug,
subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=project_id,
end_date=None,
start_date=None,
workspace__slug=slug,
)
draft_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
end_date=None,
start_date=None,
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.order_by("name", "-is_favorite")
)
return Response(
@@ -386,6 +656,10 @@ class DraftCyclesEndpoint(BaseAPIView):
class CycleFavoriteViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = CycleFavoriteSerializer
model = CycleFavorite
@@ -445,3 +719,82 @@ class CycleFavoriteViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class TransferCycleIssueEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id, cycle_id):
try:
new_cycle_id = request.data.get("new_cycle_id", False)
if not new_cycle_id:
return Response(
{"error": "New Cycle Id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
new_cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
)
if (
new_cycle.end_date is not None
and new_cycle.end_date < timezone.now().date()
):
return Response(
{
"error": "The cycle where the issues are transferred is already completed"
},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
issue__state__group__in=["backlog", "unstarted", "started"],
)
updated_cycles = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
except Cycle.DoesNotExist:
return Response(
{"error": "New Cycle 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,
)
class InCompleteCyclesEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
try:
cycles = Cycle.objects.filter(
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
workspace__slug=slug,
project_id=project_id,
).select_related("owned_by")
serializer = CycleSerializer(cycles, many=True)
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,
)
+101
View File
@@ -0,0 +1,101 @@
# Python imports
import requests
# Third party imports
from rest_framework.response import Response
from rest_framework import status
import openai
from sentry_sdk import capture_exception
# Django imports
from django.conf import settings
# Module imports
from .base import BaseAPIView
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Workspace, Project
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
class GPTIntegrationEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
try:
if not settings.OPENAI_API_KEY or not settings.GPT_ENGINE:
return Response(
{"error": "OpenAI API key and engine is required"},
status=status.HTTP_400_BAD_REQUEST,
)
count = 0
# If logger is enabled check for request limit
if settings.LOGGER_BASE_URL:
try:
headers = {
"Content-Type": "application/json",
}
response = requests.post(
settings.LOGGER_BASE_URL,
json={"user_id": str(request.user.id)},
headers=headers,
)
count = response.json().get("count", 0)
if not response.json().get("success", False):
return Response(
{
"error": "You have surpassed the monthly limit for AI assistance"
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
except Exception as e:
capture_exception(e)
prompt = request.data.get("prompt", False)
task = request.data.get("task", False)
if not task:
return Response(
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
)
final_text = task + "\n" + prompt
openai.api_key = settings.OPENAI_API_KEY
response = openai.Completion.create(
engine=settings.GPT_ENGINE,
prompt=final_text,
temperature=0.7,
max_tokens=1024,
)
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=project_id)
text = response.choices[0].text.strip()
text_html = text.replace("\n", "<br/>")
return Response(
{
"response": text,
"response_html": text_html,
"count": count,
"project_detail": ProjectLiteSerializer(project).data,
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
},
status=status.HTTP_200_OK,
)
except (Workspace.DoesNotExist, Project.DoesNotExist) as e:
return Response(
{"error": "Workspace or Project 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,
)
+519
View File
@@ -0,0 +1,519 @@
# Python imports
import uuid
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Django imports
from django.db.models import Max
# Module imports
from plane.api.views import BaseAPIView
from plane.db.models import (
WorkspaceIntegration,
Importer,
APIToken,
Project,
State,
IssueSequence,
Issue,
IssueActivity,
IssueComment,
IssueLink,
IssueLabel,
Workspace,
IssueAssignee,
Module,
ModuleLink,
ModuleIssue,
)
from plane.api.serializers import (
ImporterSerializer,
IssueFlatSerializer,
ModuleSerializer,
)
from plane.utils.integrations.github import get_github_repo_details
from plane.utils.importers.jira import jira_project_issue_summary
from plane.bgtasks.importer_task import service_importer
from plane.utils.html_processor import strip_tags
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
def get(self, request, slug, service):
try:
if service == "github":
workspace_integration = WorkspaceIntegration.objects.get(
integration__provider="github", workspace__slug=slug
)
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
owner = request.GET.get("owner")
repo = request.GET.get("repo")
issue_count, labels, collaborators = get_github_repo_details(
access_tokens_url, owner, repo
)
return Response(
{
"issue_count": issue_count,
"labels": labels,
"collaborators": collaborators,
},
status=status.HTTP_200_OK,
)
if service == "jira":
project_name = request.data.get("project_name", "")
api_token = request.data.get("api_token", "")
email = request.data.get("email", "")
cloud_hostname = request.data.get("cloud_hostname", "")
if (
not bool(project_name)
or not bool(api_token)
or not bool(email)
or not bool(cloud_hostname)
):
return Response(
{
"error": "Project name, Project key, API token, Cloud hostname and email are requied"
},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
jira_project_issue_summary(
email, api_token, project_name, cloud_hostname
),
status=status.HTTP_200_OK,
)
return Response(
{"error": "Service not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
)
except WorkspaceIntegration.DoesNotExist:
return Response(
{"error": "Requested integration was not installed in the workspace"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ImportServiceEndpoint(BaseAPIView):
def post(self, request, slug, service):
try:
project_id = request.data.get("project_id", False)
if not project_id:
return Response(
{"error": "Project ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
if service == "github":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
if not data or not metadata or not config:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
if service == "jira":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
if not data or not metadata:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
{"error": "Servivce not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
)
except (
Workspace.DoesNotExist,
WorkspaceIntegration.DoesNotExist,
Project.DoesNotExist,
) as e:
return Response(
{"error": "Workspace Integration or Project does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def get(self, request, slug):
try:
imports = Importer.objects.filter(workspace__slug=slug).order_by(
"-created_at"
)
serializer = ImporterSerializer(imports, many=True)
return Response(serializer.data)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UpdateServiceImportStatusEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service, importer_id):
try:
importer = Importer.objects.get(
pk=importer_id,
workspace__slug=slug,
project_id=project_id,
service=service,
)
importer.status = request.data.get("status", "processing")
importer.save()
return Response(status.HTTP_200_OK)
except Importer.DoesNotExist:
return Response(
{"error": "Importer does not exist"}, status=status.HTTP_404_NOT_FOUND
)
class BulkImportIssuesEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service):
try:
# Get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
# Get the default state
default_state = State.objects.filter(
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()
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(project_id=project_id).aggregate(
largest=Max("sequence")
)["largest"]
last_id = 1 if last_id is None else last_id + 1
# Get the maximum sort order
largest_sort_order = Issue.objects.filter(
project_id=project_id, state=default_state
).aggregate(largest=Max("sort_order"))["largest"]
largest_sort_order = (
65535 if largest_sort_order is None else largest_sort_order + 10000
)
# Get the issues_data
issues_data = request.data.get("issues_data", [])
if not len(issues_data):
return Response(
{"error": "Issue data is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Issues
bulk_issues = []
for issue_data in issues_data:
bulk_issues.append(
Issue(
project_id=project_id,
workspace_id=project.workspace_id,
state_id=issue_data.get("state")
if issue_data.get("state", False)
else default_state.id,
name=issue_data.get("name", "Issue Created through Bulk"),
description_html=issue_data.get("description_html", "<p></p>"),
description_stripped=(
None
if (
issue_data.get("description_html") == ""
or issue_data.get("description_html") is None
)
else strip_tags(issue_data.get("description_html"))
),
sequence_id=last_id,
sort_order=largest_sort_order,
start_date=issue_data.get("start_date", None),
target_date=issue_data.get("target_date", None),
priority=issue_data.get("priority", None),
)
)
largest_sort_order = largest_sort_order + 10000
last_id = last_id + 1
issues = Issue.objects.bulk_create(
bulk_issues,
batch_size=100,
ignore_conflicts=True,
)
# Sequences
_ = IssueSequence.objects.bulk_create(
[
IssueSequence(
issue=issue,
sequence=issue.sequence_id,
project_id=project_id,
workspace_id=project.workspace_id,
)
for issue in issues
],
batch_size=100,
)
# Attach Labels
bulk_issue_labels = []
for issue, issue_data in zip(issues, issues_data):
labels_list = issue_data.get("labels_list", [])
bulk_issue_labels = bulk_issue_labels + [
IssueLabel(
issue=issue,
label_id=label_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for label_id in labels_list
]
_ = IssueLabel.objects.bulk_create(
bulk_issue_labels, batch_size=100, ignore_conflicts=True
)
# Attach Assignees
bulk_issue_assignees = []
for issue, issue_data in zip(issues, issues_data):
assignees_list = issue_data.get("assignees_list", [])
bulk_issue_assignees = bulk_issue_assignees + [
IssueAssignee(
issue=issue,
assignee_id=assignee_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for assignee_id in assignees_list
]
_ = IssueAssignee.objects.bulk_create(
bulk_issue_assignees, batch_size=100, ignore_conflicts=True
)
# Track the issue activities
IssueActivity.objects.bulk_create(
[
IssueActivity(
issue=issue,
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
comment=f"{request.user.email} importer the issue from {service}",
verb="created",
)
for issue in issues
],
batch_size=100,
)
# Create Comments
bulk_issue_comments = []
for issue, issue_data in zip(issues, issues_data):
comments_list = issue_data.get("comments_list", [])
bulk_issue_comments = bulk_issue_comments + [
IssueComment(
issue=issue,
comment_html=comment.get("comment_html", "<p></p>"),
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for comment in comments_list
]
_ = IssueComment.objects.bulk_create(bulk_issue_comments, batch_size=100)
# Attach Links
_ = IssueLink.objects.bulk_create(
[
IssueLink(
issue=issue,
url=issue_data.get("link", {}).get("url", "https://github.com"),
title=issue_data.get("link", {}).get("title", "Original Issue"),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue, issue_data in zip(issues, issues_data)
]
)
return Response(
{"issues": IssueFlatSerializer(issues, many=True).data},
status=status.HTTP_201_CREATED,
)
except Project.DoesNotExist:
return Response(
{"error": "Project Does not exist"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class BulkImportModulesEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service):
try:
modules_data = request.data.get("modules_data", [])
project = Project.objects.get(pk=project_id, workspace__slug=slug)
modules = Module.objects.bulk_create(
[
Module(
name=module.get("name", uuid.uuid4().hex),
description=module.get("description", ""),
start_date=module.get("start_date", None),
target_date=module.get("target_date", None),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for module in modules_data
],
batch_size=100,
ignore_conflicts=True,
)
_ = ModuleLink.objects.bulk_create(
[
ModuleLink(
module=module,
url=module_data.get("link", {}).get("url", "https://plane.so"),
title=module_data.get("link", {}).get(
"title", "Original Issue"
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for module, module_data in zip(modules, modules_data)
],
batch_size=100,
ignore_conflicts=True,
)
bulk_module_issues = []
for module, module_data in zip(modules, modules_data):
module_issues_list = module_data.get("module_issues_list", [])
bulk_module_issues = bulk_module_issues + [
ModuleIssue(
issue_id=issue,
module=module,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in module_issues_list
]
_ = ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=100, ignore_conflicts=True
)
serializer = ModuleSerializer(modules, many=True)
return Response(
{"modules": serializer.data}, status=status.HTTP_201_CREATED
)
except Project.DoesNotExist:
return Response(
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -2,6 +2,7 @@ from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
from .github import (
GithubRepositorySyncViewSet,
GithubIssueSyncViewSet,
BulkCreateGithubIssueSyncEndpoint,
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
)
+23 -1
View File
@@ -25,7 +25,7 @@ from plane.utils.integrations.github import (
get_github_metadata,
delete_github_installation,
)
from plane.api.permissions import WorkSpaceAdminPermission
class IntegrationViewSet(BaseViewSet):
serializer_class = IntegrationSerializer
@@ -75,11 +75,33 @@ class IntegrationViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, pk):
try:
integration = Integration.objects.get(pk=pk)
if integration.verified:
return Response(
{"error": "Verified integrations cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
integration.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Integration.DoesNotExist:
return Response(
{"error": "Integration Does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
class WorkspaceIntegrationViewSet(BaseViewSet):
serializer_class = WorkspaceIntegrationSerializer
model = WorkspaceIntegration
permission_classes = [
WorkSpaceAdminPermission,
]
def get_queryset(self):
return (
super()
@@ -13,6 +13,7 @@ from plane.db.models import (
ProjectMember,
Label,
GithubCommentSync,
Project,
)
from plane.api.serializers import (
GithubIssueSyncSerializer,
@@ -20,15 +21,27 @@ from plane.api.serializers import (
GithubCommentSyncSerializer,
)
from plane.utils.integrations.github import get_github_repos
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
class GithubRepositoriesEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
]
def get(self, request, slug, workspace_integration_id):
try:
page = request.GET.get("page", 1)
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
)
if workspace_integration.integration.provider != "github":
return Response(
{"error": "Not a github integration"},
status=status.HTTP_400_BAD_REQUEST,
)
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
repositories_url = (
workspace_integration.metadata["repositories_url"]
@@ -44,6 +57,10 @@ class GithubRepositoriesEndpoint(BaseAPIView):
class GithubRepositorySyncViewSet(BaseViewSet):
permission_classes = [
ProjectBasePermission,
]
serializer_class = GithubRepositorySyncSerializer
model = GithubRepositorySync
@@ -84,10 +101,6 @@ class GithubRepositorySyncViewSet(BaseViewSet):
GithubRepository.objects.filter(
project_id=project_id, workspace__slug=slug
).delete()
# Project member delete
ProjectMember.objects.filter(
member=workspace_integration.actor, role=20, project_id=project_id
).delete()
# Create repository
repo = GithubRepository.objects.create(
@@ -124,7 +137,7 @@ class GithubRepositorySyncViewSet(BaseViewSet):
)
# Add bot as a member in the project
_ = ProjectMember.objects.create(
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor, role=20, project_id=project_id
)
@@ -148,6 +161,10 @@ class GithubRepositorySyncViewSet(BaseViewSet):
class GithubIssueSyncViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = GithubIssueSyncSerializer
model = GithubIssueSync
@@ -158,7 +175,52 @@ class GithubIssueSyncViewSet(BaseViewSet):
)
class BulkCreateGithubIssueSyncEndpoint(BaseAPIView):
def post(self, request, slug, project_id, repo_sync_id):
try:
project = Project.objects.get(pk=project_id, workspace__slug=slug)
github_issue_syncs = request.data.get("github_issue_syncs", [])
github_issue_syncs = GithubIssueSync.objects.bulk_create(
[
GithubIssueSync(
issue_id=github_issue_sync.get("issue"),
repo_issue_id=github_issue_sync.get("repo_issue_id"),
issue_url=github_issue_sync.get("issue_url"),
github_issue_id=github_issue_sync.get("github_issue_id"),
repository_sync_id=repo_sync_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for github_issue_sync in github_issue_syncs
],
batch_size=100,
ignore_conflicts=True,
)
serializer = GithubIssueSyncSerializer(github_issue_syncs, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Project.DoesNotExist:
return Response(
{"error": "Project does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class GithubCommentSyncViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = GithubCommentSyncSerializer
model = GithubCommentSync
+83 -117
View File
@@ -1,10 +1,13 @@
# Python imports
import json
import random
from itertools import groupby, chain
# Django imports
from django.db.models import Prefetch, OuterRef, Func, F, Q
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
# Third Party imports
from rest_framework.response import Response
@@ -24,6 +27,7 @@ from plane.api.serializers import (
LabelSerializer,
IssueFlatSerializer,
IssueLinkSerializer,
IssueLiteSerializer,
)
from plane.api.permissions import (
ProjectEntityPermission,
@@ -38,13 +42,11 @@ from plane.db.models import (
TimelineIssue,
IssueProperty,
Label,
IssueBlocker,
CycleIssue,
ModuleIssue,
IssueLink,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
class IssueViewSet(BaseViewSet):
@@ -133,59 +135,29 @@ class IssueViewSet(BaseViewSet):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocked_issues",
queryset=IssueBlocker.objects.select_related("blocked_by", "block"),
)
)
.prefetch_related(
Prefetch(
"blocker_issues",
queryset=IssueBlocker.objects.select_related("block", "blocked_by"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle",
queryset=CycleIssue.objects.select_related("cycle", "issue"),
),
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.select_related(
"module", "issue"
).prefetch_related("module__members"),
),
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("issue").select_related(
"created_by"
),
)
)
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
try:
# Issue State groups
type = request.GET.get("type", "all")
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
if type == "backlog":
group = ["backlog"]
if type == "active":
group = ["unstarted", "started"]
filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true")
issue_queryset = (
self.get_queryset()
.order_by(request.GET.get("order_by", "created_at"))
.filter(state__group__in=group)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__id"))
.annotate(module_id=F("issue_module__id"))
)
issues = IssueSerializer(issue_queryset, many=True).data
issue_queryset = (
issue_queryset
if show_sub_issues == "true"
else issue_queryset.filter(parent__isnull=True)
)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
@@ -197,7 +169,6 @@ class IssueViewSet(BaseViewSet):
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
@@ -235,8 +206,20 @@ class IssueViewSet(BaseViewSet):
{"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND
)
def retrieve(self, request, slug, project_id, pk=None):
try:
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
except Issue.DoesNotExist:
return Response(
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND
)
class UserWorkSpaceIssues(BaseAPIView):
@method_decorator(gzip_page)
def get(self, request, slug):
try:
issues = (
@@ -253,44 +236,9 @@ class UserWorkSpaceIssues(BaseAPIView):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocked_issues",
queryset=IssueBlocker.objects.select_related(
"blocked_by", "block"
),
)
)
.prefetch_related(
Prefetch(
"blocker_issues",
queryset=IssueBlocker.objects.select_related(
"block", "blocked_by"
),
)
)
.prefetch_related(
Prefetch(
"issue_cycle",
queryset=CycleIssue.objects.select_related("cycle", "issue"),
),
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.select_related("module", "issue"),
),
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related(
"issue"
).select_related("created_by"),
)
)
.order_by("-created_at")
)
serializer = IssueSerializer(issues, many=True)
serializer = IssueLiteSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
@@ -305,10 +253,13 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
WorkSpaceAdminPermission,
]
@method_decorator(gzip_page)
def get(self, request, slug):
try:
issues = Issue.objects.filter(workspace__slug=slug).filter(
project__project_projectmember__member=self.request.user
issues = (
Issue.objects.filter(workspace__slug=slug)
.filter(project__project_projectmember__member=self.request.user)
.order_by("-created_at")
)
serializer = IssueSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -325,6 +276,7 @@ class IssueActivityEndpoint(BaseAPIView):
ProjectEntityPermission,
]
@method_decorator(gzip_page)
def get(self, request, slug, project_id, issue_id):
try:
issue_activities = (
@@ -333,8 +285,8 @@ class IssueActivityEndpoint(BaseAPIView):
~Q(field="comment"),
project__project_projectmember__member=self.request.user,
)
.select_related("actor")
).order_by("created_by")
.select_related("actor", "workspace")
).order_by("created_at")
issue_comments = (
IssueComment.objects.filter(issue_id=issue_id)
.filter(project__project_projectmember__member=self.request.user)
@@ -561,6 +513,7 @@ class LabelViewSet(BaseViewSet):
.select_related("project")
.select_related("workspace")
.select_related("parent")
.order_by("name")
.distinct()
)
@@ -605,6 +558,7 @@ class SubIssuesEndpoint(BaseAPIView):
ProjectEntityPermission,
]
@method_decorator(gzip_page)
def get(self, request, slug, project_id, issue_id):
try:
sub_issues = (
@@ -617,37 +571,9 @@ class SubIssuesEndpoint(BaseAPIView):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocked_issues",
queryset=IssueBlocker.objects.select_related(
"blocked_by", "block"
),
)
)
.prefetch_related(
Prefetch(
"blocker_issues",
queryset=IssueBlocker.objects.select_related(
"block", "blocked_by"
),
)
)
.prefetch_related(
Prefetch(
"issue_cycle",
queryset=CycleIssue.objects.select_related("cycle", "issue"),
),
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.select_related("module", "issue"),
),
)
)
serializer = IssueSerializer(sub_issues, many=True)
serializer = IssueLiteSerializer(sub_issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
@@ -715,5 +641,45 @@ class IssueLinkViewSet(BaseViewSet):
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user)
.order_by("-created_at")
.distinct()
)
class BulkCreateIssueLabelsEndpoint(BaseAPIView):
def post(self, request, slug, project_id):
try:
label_data = request.data.get("label_data", [])
project = Project.objects.get(pk=project_id)
labels = Label.objects.bulk_create(
[
Label(
name=label.get("name", "Migrated"),
description=label.get("description", "Migrated Issue"),
color="#" + "%06x" % random.randint(0, 0xFFFFFF),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for label in label_data
],
batch_size=50,
ignore_conflicts=True,
)
return Response(
{"labels": LabelSerializer(labels, many=True).data},
status=status.HTTP_201_CREATED,
)
except Project.DoesNotExist:
return Response(
{"error": "Project Does not exist"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+75 -31
View File
@@ -3,8 +3,10 @@ import json
# Django Imports
from django.db import IntegrityError
from django.db.models import Prefetch, F, OuterRef, Func, Exists
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
from django.core import serializers
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
# Third party imports
from rest_framework.response import Response
@@ -19,6 +21,7 @@ from plane.api.serializers import (
ModuleIssueSerializer,
ModuleLinkSerializer,
ModuleFavoriteSerializer,
IssueStateSerializer,
)
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import (
@@ -31,6 +34,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
class ModuleViewSet(BaseViewSet):
@@ -47,29 +51,60 @@ class ModuleViewSet(BaseViewSet):
)
def get_queryset(self):
subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return (
super()
.get_queryset()
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.annotate(is_favorite=Exists(subquery))
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.select_related(
"module", "issue", "issue__state", "issue__project"
).prefetch_related("issue__assignees", "issue__labels"),
)
)
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related("module", "created_by"),
)
)
.annotate(total_issues=Count("issue_module"))
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="backlog"),
)
)
.order_by("-is_favorite", "name")
)
def create(self, request, slug, project_id):
@@ -101,23 +136,6 @@ class ModuleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
def list(self, request, slug, project_id):
try:
subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
modules = self.get_queryset().annotate(is_favorite=Exists(subquery))
return Response(ModuleSerializer(modules, many=True).data)
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
@@ -161,22 +179,43 @@ class ModuleIssueViewSet(BaseViewSet):
.distinct()
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id, module_id):
try:
order_by = request.GET.get("order_by", "issue__created_at")
queryset = self.get_queryset().order_by(f"issue__{order_by}")
order_by = request.GET.get("order_by", "created_at")
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)
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(bridge_id=F("issue_module__id"))
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by(order_by)
.filter(**filters)
)
module_issues = ModuleIssueSerializer(queryset, many=True).data
issues_data = IssueStateSerializer(issues, many=True).data
if group_by:
return Response(
group_results(module_issues, f"issue_detail.{group_by}"),
group_results(issues_data, group_by),
status=status.HTTP_200_OK,
)
return Response(
module_issues,
issues_data,
status=status.HTTP_200_OK,
)
except Exception as e:
@@ -302,11 +341,16 @@ class ModuleLinkViewSet(BaseViewSet):
.filter(project_id=self.kwargs.get("project_id"))
.filter(module_id=self.kwargs.get("module_id"))
.filter(project__project_projectmember__member=self.request.user)
.order_by("-created_at")
.distinct()
)
class ModuleFavoriteViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite
+41 -1
View File
@@ -5,6 +5,7 @@ import os
# Django imports
from django.utils import timezone
from django.conf import settings
# Third Party modules
from rest_framework.response import Response
@@ -204,7 +205,26 @@ class OauthEndpoint(BaseAPIView):
"last_login_at": timezone.now(),
},
)
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": f"oauth-{medium}",
},
"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",
},
)
return Response(data, status=status.HTTP_200_OK)
except User.DoesNotExist:
@@ -253,6 +273,26 @@ class OauthEndpoint(BaseAPIView):
"user": serialized_user,
"permissions": [],
}
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": f"oauth-{medium}",
},
"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",
},
)
SocialLoginConnection.objects.update_or_create(
medium=medium,
+487
View File
@@ -0,0 +1,487 @@
# Python imports
from datetime import timedelta, datetime, date
# Django imports
from django.db import IntegrityError
from django.db.models import Exists, OuterRef, Q, Prefetch
from django.utils import timezone
# 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, BaseAPIView
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import (
Page,
PageBlock,
PageFavorite,
Issue,
IssueAssignee,
IssueActivity,
)
from plane.api.serializers import (
PageSerializer,
PageBlockSerializer,
PageFavoriteSerializer,
IssueLiteSerializer,
)
class PageViewSet(BaseViewSet):
serializer_class = PageSerializer
model = Page
permission_classes = [
ProjectEntityPermission,
]
search_fields = [
"name",
]
def get_queryset(self):
subquery = PageFavorite.objects.filter(
user=self.request.user,
page_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
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)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-created_at"))
.prefetch_related("labels")
.order_by("name", "-is_favorite")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.distinct()
)
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"), owned_by=self.request.user
)
def create(self, request, slug, project_id):
try:
serializer = PageSerializer(
data=request.data,
context={"project_id": project_id, "owned_by_id": request.user.id},
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, 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 PageBlockViewSet(BaseViewSet):
serializer_class = PageBlockSerializer
model = PageBlock
permission_classes = [
ProjectEntityPermission,
]
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(page_id=self.kwargs.get("page_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.select_related("page")
.select_related("issue")
.order_by("sort_order")
.distinct()
)
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
page_id=self.kwargs.get("page_id"),
)
class PageFavoriteViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = PageFavoriteSerializer
model = PageFavorite
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(user=self.request.user)
.select_related("page", "page__owned_by")
)
def create(self, request, slug, project_id):
try:
serializer = PageFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user, project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"error": "The page is already added to favorites"},
status=status.HTTP_410_GONE,
)
else:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
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 destroy(self, request, slug, project_id, page_id):
try:
page_favorite = PageFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
page_id=page_id,
)
page_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except PageFavorite.DoesNotExist:
return Response(
{"error": "Page is not in favorites"},
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 CreateIssueFromPageBlockEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id, page_id, page_block_id):
try:
page_block = PageBlock.objects.get(
pk=page_block_id,
workspace__slug=slug,
project_id=project_id,
page_id=page_id,
)
issue = Issue.objects.create(
name=page_block.name,
project_id=project_id,
description=page_block.description,
description_html=page_block.description_html,
description_stripped=page_block.description_stripped,
)
_ = IssueAssignee.objects.create(
issue=issue, assignee=request.user, project_id=project_id
)
_ = IssueActivity.objects.create(
issue=issue,
actor=request.user,
project_id=project_id,
comment=f"{request.user.email} created the issue from {page_block.name} block",
verb="created",
)
page_block.issue = issue
page_block.save()
return Response(IssueLiteSerializer(issue).data, status=status.HTTP_200_OK)
except PageBlock.DoesNotExist:
return Response(
{"error": "Page Block does not exist"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class RecentPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
current_time = date.today()
day_before = current_time - timedelta(days=1)
todays_pages = (
Page.objects.filter(
updated_at__date=date.today(),
workspace__slug=slug,
project_id=project_id,
)
.filter(project__project_projectmember__member=request.user)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "-updated_at")
)
yesterdays_pages = (
Page.objects.filter(
updated_at__date=day_before,
workspace__slug=slug,
project_id=project_id,
)
.filter(project__project_projectmember__member=request.user)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "-updated_at")
)
earlier_this_week = (
Page.objects.filter(
updated_at__date__range=(
(timezone.now() - timedelta(days=7)),
(timezone.now() - timedelta(days=2)),
),
workspace__slug=slug,
project_id=project_id,
)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.filter(project__project_projectmember__member=request.user)
.annotate(is_favorite=Exists(subquery))
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "-updated_at")
)
todays_pages_serializer = PageSerializer(todays_pages, many=True)
yesterday_pages_serializer = PageSerializer(yesterdays_pages, many=True)
earlier_this_week_serializer = PageSerializer(earlier_this_week, many=True)
return Response(
{
"today": todays_pages_serializer.data,
"yesterday": yesterday_pages_serializer.data,
"earlier_this_week": earlier_this_week_serializer.data,
},
status=status.HTTP_200_OK,
)
except Exception as e:
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class FavoritePagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
pages = (
Page.objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.filter(project__project_projectmember__member=request.user)
.filter(is_favorite=True)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("name", "-is_favorite")
)
serializer = PageSerializer(pages, many=True)
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,
)
class MyPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
pages = (
Page.objects.filter(
workspace__slug=slug, project_id=project_id, owned_by=request.user
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.filter(project__project_projectmember__member=request.user)
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "name")
)
serializer = PageSerializer(pages, many=True)
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,
)
class CreatedbyOtherPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
pages = (
Page.objects.filter(
~Q(owned_by=request.user),
workspace__slug=slug,
project_id=project_id,
access=0,
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.annotate(is_favorite=Exists(subquery))
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "name")
)
serializer = PageSerializer(pages, many=True)
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,
)
+72 -3
View File
@@ -7,10 +7,20 @@ from sentry_sdk import capture_exception
# Module imports
from plane.api.serializers import (
UserSerializer,
IssueActivitySerializer,
)
from plane.api.views.base import BaseViewSet, BaseAPIView
from plane.db.models import User, Workspace
from plane.db.models import (
User,
Workspace,
WorkspaceMemberInvite,
Issue,
IssueActivity,
WorkspaceMember,
)
from plane.utils.paginator import BasePaginator
class UserEndpoint(BaseViewSet):
serializer_class = UserSerializer
@@ -22,11 +32,34 @@ class UserEndpoint(BaseViewSet):
def retrieve(self, request):
try:
workspace = Workspace.objects.get(pk=request.user.last_workspace_id)
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email
).count()
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
return Response(
{"user": UserSerializer(request.user).data, "slug": workspace.slug}
{
"user": UserSerializer(request.user).data,
"slug": workspace.slug,
"workspace_invites": workspace_invites,
"assigned_issues": assigned_issues,
},
status=status.HTTP_200_OK,
)
except Workspace.DoesNotExist:
return Response({"user": UserSerializer(request.user).data, "slug": None})
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email
).count()
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
return Response(
{
"user": UserSerializer(request.user).data,
"slug": None,
"workspace_invites": workspace_invites,
"assigned_issues": assigned_issues,
},
status=status.HTTP_200_OK,
)
except Exception as e:
return Response(
{"error": "Something went wrong please try again later"},
@@ -40,6 +73,20 @@ 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
)
@@ -49,3 +96,25 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UserActivityEndpoint(BaseAPIView, BasePaginator):
def get(self, request):
try:
queryset = IssueActivity.objects.filter(actor=request.user).select_related(
"actor", "workspace"
)
return self.paginate(
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(
issue_activities, many=True
).data,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+17 -1
View File
@@ -64,6 +64,11 @@ class ProjectViewSet(BaseViewSet):
return ProjectDetailSerializer
def get_queryset(self):
subquery = ProjectFavorite.objects.filter(
user=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
@@ -72,6 +77,7 @@ class ProjectViewSet(BaseViewSet):
.select_related(
"workspace", "workspace__owner", "default_assignee", "project_lead"
)
.annotate(is_favorite=Exists(subquery))
.distinct()
)
@@ -82,7 +88,11 @@ class ProjectViewSet(BaseViewSet):
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
projects = self.get_queryset().annotate(is_favorite=Exists(subquery))
projects = (
self.get_queryset()
.annotate(is_favorite=Exists(subquery))
.order_by("-is_favorite", "name")
)
return Response(ProjectDetailSerializer(projects, many=True).data)
except Exception as e:
capture_exception(e)
@@ -167,6 +177,12 @@ class ProjectViewSet(BaseViewSet):
{"name": "The project name is already taken"},
status=status.HTTP_410_GONE,
)
else:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_410_GONE,
)
except Workspace.DoesNotExist as e:
return Response(
{"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
+175
View File
@@ -0,0 +1,175 @@
# Python imports
import re
# Django imports
from django.db.models import Q
# 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 BaseAPIView
from plane.db.models import Workspace, Project, Issue, Cycle, Module, Page, IssueView
class GlobalSearchEndpoint(BaseAPIView):
"""Endpoint to search across multiple fields in the workspace and
also show related workspace if found
"""
def filter_workspaces(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return Workspace.objects.filter(
q, workspace_member__member=self.request.user
).distinct().values("name", "id", "slug")
def filter_projects(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user) | Q(network=2),
workspace__slug=slug,
).distinct().values("name", "id", "identifier", "workspace__slug")
def filter_issues(self, query, slug, project_id):
fields = ["name", "sequence_id"]
q = Q()
for field in fields:
if field == "sequence_id":
sequences = re.findall(r"\d+\.\d+|\d+", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
return Issue.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
project_id=project_id,
).distinct().values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"workspace__slug",
)
def filter_cycles(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return Cycle.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
project_id=project_id,
).distinct().values(
"name",
"id",
"project_id",
"workspace__slug",
)
def filter_modules(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return Module.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
project_id=project_id,
).distinct().values(
"name",
"id",
"project_id",
"workspace__slug",
)
def filter_pages(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return Page.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
project_id=project_id,
).distinct().values(
"name",
"id",
"project_id",
"workspace__slug",
)
def filter_views(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return IssueView.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
project_id=project_id,
).distinct().values(
"name",
"id",
"project_id",
"workspace__slug",
)
def get(self, request, slug, project_id):
try:
query = request.query_params.get("search", False)
if not query:
return Response(
{
"results": {
"workspace": [],
"project": [],
"issue": [],
"cycle": [],
"module": [],
"issue_view": [],
"page": [],
}
},
status=status.HTTP_200_OK,
)
MODELS_MAPPER = {
"workspace": self.filter_workspaces,
"project": self.filter_projects,
"issue": self.filter_issues,
"cycle": self.filter_cycles,
"module": self.filter_modules,
"issue_view": self.filter_views,
"page": self.filter_pages,
}
results = {}
for model in MODELS_MAPPER.keys():
func = MODELS_MAPPER.get(model, None)
results[model] = func(query, slug, project_id)
return Response({"results": results}, status=status.HTTP_200_OK)
except Exception as e:
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+137 -7
View File
@@ -1,14 +1,35 @@
# Django imports
from django.db import IntegrityError
from django.db.models import Prefetch, OuterRef, Exists
# 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
from plane.api.serializers import ViewSerializer
from . import BaseViewSet, BaseAPIView
from plane.api.serializers import (
IssueViewSerializer,
IssueLiteSerializer,
IssueViewFavoriteSerializer,
)
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import View
from plane.db.models import (
IssueView,
Issue,
IssueBlocker,
IssueLink,
CycleIssue,
ModuleIssue,
IssueViewFavorite,
)
from plane.utils.issue_filters import issue_filters
class ViewViewSet(BaseViewSet):
serializer_class = ViewSerializer
model = View
class IssueViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
model = IssueView
permission_classes = [
ProjectEntityPermission,
]
@@ -17,6 +38,12 @@ class ViewViewSet(BaseViewSet):
serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self):
subquery = IssueViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
@@ -25,5 +52,108 @@ class ViewViewSet(BaseViewSet):
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.order_by("-is_favorite", "name")
.distinct()
)
class ViewIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, view_id):
try:
view = IssueView.objects.get(pk=view_id)
queries = view.query
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.objects.filter(
**queries, project_id=project_id, workspace__slug=slug
)
.filter(**filters)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
)
serializer = IssueLiteSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except IssueView.DoesNotExist:
return Response(
{"error": "Issue View does not exist"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueViewFavoriteViewSet(BaseViewSet):
serializer_class = IssueViewFavoriteSerializer
model = IssueViewFavorite
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(user=self.request.user)
.select_related("view")
)
def create(self, request, slug, project_id):
try:
serializer = IssueViewFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user, project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"error": "The view is already added to favorites"},
status=status.HTTP_410_GONE,
)
else:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
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 destroy(self, request, slug, project_id, view_id):
try:
view_favourite = IssueViewFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
view_id=view_id,
)
view_favourite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueViewFavorite.DoesNotExist:
return Response(
{"error": "View is not in favorites"},
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,
)
+178 -4
View File
@@ -1,6 +1,7 @@
# Python imports
import jwt
from datetime import datetime
from datetime import date, datetime
from dateutil.relativedelta import relativedelta
# Django imports
from django.db import IntegrityError
@@ -10,8 +11,16 @@ from django.utils import timezone
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.contrib.sites.shortcuts import get_current_site
from django.db.models import CharField, Count, OuterRef, Func, F
from django.db.models.functions import Cast
from django.db.models import (
CharField,
Count,
OuterRef,
Func,
F,
Q,
)
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
from django.db.models.fields import DateField
# Third party modules
from rest_framework import status
@@ -37,6 +46,8 @@ from plane.db.models import (
WorkspaceMemberInvite,
Team,
ProjectMember,
IssueActivity,
Issue,
)
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
from plane.bgtasks.workspace_invitation_task import workspace_invitation
@@ -59,7 +70,9 @@ class WorkSpaceViewSet(BaseViewSet):
lookup_field = "slug"
def get_queryset(self):
return self.filter_queryset(super().get_queryset().select_related("owner"))
return self.filter_queryset(
super().get_queryset().select_related("owner")
).order_by("name")
def create(self, request):
try:
@@ -578,3 +591,164 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UserActivityGraphEndpoint(BaseAPIView):
def get(self, request, slug):
try:
issue_activities = (
IssueActivity.objects.filter(
actor=request.user,
workspace__slug=slug,
created_at__date__gte=date.today() + relativedelta(months=-6),
)
.annotate(created_date=Cast("created_at", DateField()))
.values("created_date")
.annotate(activity_count=Count("created_date"))
.order_by("created_date")
)
return Response(issue_activities, 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 UserIssueCompletedGraphEndpoint(BaseAPIView):
def get(self, request, slug):
try:
month = request.GET.get("month", 1)
issues = (
Issue.objects.filter(
assignees__in=[request.user],
workspace__slug=slug,
completed_at__month=month,
completed_at__isnull=False,
)
.annotate(completed_week=ExtractWeek("completed_at"))
.annotate(week=F("completed_week") % 4)
.values("week")
.annotate(completed_count=Count("completed_week"))
.order_by("week")
)
return Response(issues, 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 WeekInMonth(Func):
function = "FLOOR"
template = "(((%(expressions)s - 1) / 7) + 1)::INTEGER"
class UserWorkspaceDashboardEndpoint(BaseAPIView):
def get(self, request, slug):
try:
issue_activities = (
IssueActivity.objects.filter(
actor=request.user,
workspace__slug=slug,
created_at__date__gte=date.today() + relativedelta(months=-3),
)
.annotate(created_date=Cast("created_at", DateField()))
.values("created_date")
.annotate(activity_count=Count("created_date"))
.order_by("created_date")
)
month = request.GET.get("month", 1)
completed_issues = (
Issue.objects.filter(
assignees__in=[request.user],
workspace__slug=slug,
completed_at__month=month,
completed_at__isnull=False,
)
.annotate(day_of_month=ExtractDay("completed_at"))
.annotate(week_in_month=WeekInMonth(F("day_of_month")))
.values("week_in_month")
.annotate(completed_count=Count("id"))
.order_by("week_in_month")
)
assigned_issues = Issue.objects.filter(
workspace__slug=slug, assignees__in=[request.user]
).count()
pending_issues_count = Issue.objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
workspace__slug=slug,
assignees__in=[request.user],
).count()
completed_issues_count = Issue.objects.filter(
workspace__slug=slug,
assignees__in=[request.user],
state__group="completed",
).count()
issues_due_week = (
Issue.objects.filter(
workspace__slug=slug,
assignees__in=[request.user],
)
.annotate(target_week=ExtractWeek("target_date"))
.filter(target_week=timezone.now().date().isocalendar()[1])
.count()
)
state_distribution = (
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(
~Q(state__group__in=["completed", "cancelled"]),
workspace__slug=slug,
assignees__in=[request.user],
target_date__lt=timezone.now(),
completed_at__isnull=True,
).values("id", "name", "workspace__slug", "project_id", "target_date")
upcoming_issues = Issue.objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
target_date__gte=timezone.now(),
workspace__slug=slug,
assignees__in=[request.user],
completed_at__isnull=True,
).values("id", "name", "workspace__slug", "project_id", "target_date")
return Response(
{
"issue_activities": issue_activities,
"completed_issues": completed_issues,
"assigned_issues_count": assigned_issues,
"pending_issues_count": pending_issues_count,
"completed_issues_count": completed_issues_count,
"issues_due_week_count": issues_due_week,
"state_distribution": state_distribution,
"overdue_issues": overdue_issues,
"upcoming_issues": upcoming_issues,
},
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,
)
+163
View File
@@ -0,0 +1,163 @@
# Python imports
import json
import requests
import uuid
import jwt
from datetime import datetime
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.auth.hashers import make_password
# Third Party imports
from django_rq import job
from sentry_sdk import capture_exception
# Module imports
from plane.api.serializers import ImporterSerializer
from plane.db.models import (
Importer,
WorkspaceMember,
GithubRepositorySync,
GithubRepository,
ProjectMember,
WorkspaceIntegration,
Label,
User,
)
from .workspace_invitation_task import workspace_invitation
@job("default")
def service_importer(service, importer_id):
try:
importer = Importer.objects.get(pk=importer_id)
importer.status = "processing"
importer.save()
users = importer.data.get("users", [])
# For all invited users create the uers
new_users = User.objects.bulk_create(
[
User(
email=user.get("email").strip().lower(),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
for user in users
if user.get("import", False) == "invite"
],
batch_size=10,
ignore_conflicts=True,
)
workspace_users = User.objects.filter(
email__in=[
user.get("email").strip().lower()
for user in users
if user.get("import", False) == "invite"
or user.get("import", False) == "map"
]
)
# Add new users to Workspace and project automatically
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(member=user, workspace_id=importer.workspace_id)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
ProjectMember.objects.bulk_create(
[
ProjectMember(
project_id=importer.project_id,
workspace_id=importer.workspace_id,
member=user,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
# Check if sync config is on for github importers
if service == "github" and importer.config.get("sync", False):
name = importer.metadata.get("name", False)
url = importer.metadata.get("url", False)
config = importer.metadata.get("config", {})
owner = importer.metadata.get("owner", False)
repository_id = importer.metadata.get("repository_id", False)
workspace_integration = WorkspaceIntegration.objects.get(
workspace_id=importer.workspace_id, integration__provider="github"
)
# Delete the old repository object
GithubRepositorySync.objects.filter(project_id=importer.project_id).delete()
GithubRepository.objects.filter(project_id=importer.project_id).delete()
# Create a Label for github
label = Label.objects.filter(
name="GitHub", project_id=importer.project_id
).first()
if label is None:
label = Label.objects.create(
name="GitHub",
project_id=importer.project_id,
description="Label to sync Plane issues with GitHub issues",
color="#003773",
)
# Create repository
repo = GithubRepository.objects.create(
name=name,
url=url,
config=config,
repository_id=repository_id,
owner=owner,
project_id=importer.project_id,
)
# Create repo sync
repo_sync = GithubRepositorySync.objects.create(
repository=repo,
workspace_integration=workspace_integration,
actor=workspace_integration.actor,
credentials=importer.data.get("credentials", {}),
project_id=importer.project_id,
label=label,
)
# Add bot as a member in the project
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor,
role=20,
project_id=importer.project_id,
)
if settings.PROXY_BASE_URL:
headers = {"Content-Type": "application/json"}
import_data_json = json.dumps(
ImporterSerializer(importer).data,
cls=DjangoJSONEncoder,
)
res = requests.post(
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/",
json=import_data_json,
headers=headers,
)
return
except Exception as e:
importer = Importer.objects.get(pk=importer_id)
importer.status = "failed"
importer.save()
capture_exception(e)
return
@@ -676,8 +676,8 @@ def create_comment_activity(
verb="created",
actor=actor,
field="comment",
new_value=requested_data.get("comment_html"),
new_identifier=requested_data.get("id"),
new_value=requested_data.get("comment_html", ""),
new_identifier=requested_data.get("id", None),
issue_comment_id=requested_data.get("id", None),
)
)
@@ -696,11 +696,11 @@ def update_comment_activity(
verb="updated",
actor=actor,
field="comment",
old_value=current_instance.get("comment_html"),
old_value=current_instance.get("comment_html", ""),
old_identifier=current_instance.get("id"),
new_value=requested_data.get("comment_html"),
new_identifier=current_instance.get("id"),
issue_comment_id=current_instance.get("id"),
new_value=requested_data.get("comment_html", ""),
new_identifier=current_instance.get("id", None),
issue_comment_id=current_instance.get("id", None),
)
)
@@ -742,7 +742,11 @@ def issue_activity(event):
try:
issue_activities = []
type = event.get("type")
requested_data = json.loads(event.get("requested_data"))
requested_data = (
json.loads(event.get("requested_data"))
if event.get("current_instance") is not None
else None
)
current_instance = (
json.loads(event.get("current_instance"))
if event.get("current_instance") is not None
@@ -2,10 +2,13 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from django_rq import job
from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
# Module imports
from plane.db.models import Workspace, User, WorkspaceMemberInvite
@@ -13,9 +16,7 @@ from plane.db.models import Workspace, User, WorkspaceMemberInvite
@job("default")
def workspace_invitation(email, workspace_id, token, current_site, invitor):
try:
workspace = Workspace.objects.get(pk=workspace_id)
workspace_member_invite = WorkspaceMemberInvite.objects.get(
token=token, email=email
@@ -49,6 +50,18 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email])
msg.attach_alternative(html_content, "text/html")
msg.send()
# Send message on slack as well
if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN)
try:
_ = client.chat_postMessage(
channel="#trackers",
text=f"{workspace_member_invite.email} has been invited to {workspace.name} as a {workspace_member_invite.role}",
)
except SlackApiError as e:
print(f"Got an error: {e.response['error']}")
return
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
return
@@ -0,0 +1,92 @@
# Generated by Django 3.2.16 on 2023-03-15 19:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0022_auto_20230307_0304'),
]
operations = [
migrations.CreateModel(
name='Importer',
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)),
('service', models.CharField(choices=[('github', 'GitHub')], max_length=50)),
('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)),
('metadata', models.JSONField(default=dict)),
('config', models.JSONField(default=dict)),
('data', models.JSONField(default=dict)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importer_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='imports', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_importer', to='db.project')),
('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='importer', to='db.apitoken')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importer_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_importer', to='db.workspace')),
],
options={
'verbose_name': 'Importer',
'verbose_name_plural': 'Importers',
'db_table': 'importers',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='IssueView',
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, verbose_name='View Name')),
('description', models.TextField(blank=True, verbose_name='View Description')),
('query', models.JSONField(verbose_name='View Query')),
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
('query_data', models.JSONField(default=dict)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueview_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueview', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueview_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_issueview', to='db.workspace')),
],
options={
'verbose_name': 'Issue View',
'verbose_name_plural': 'Issue Views',
'db_table': 'issue_views',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='IssueViewFavorite',
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)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueviewfavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueviewfavorite', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueviewfavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_view_favorites', to=settings.AUTH_USER_MODEL)),
('view', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='view_favorites', to='db.issueview')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueviewfavorite', to='db.workspace')),
],
options={
'verbose_name': 'View Favorite',
'verbose_name_plural': 'View Favorites',
'db_table': 'view_favorites',
'ordering': ('-created_at',),
'unique_together': {('view', 'user')},
},
),
migrations.AlterUniqueTogether(
name='label',
unique_together={('name', 'project')},
),
migrations.DeleteModel(
name='View',
),
]
@@ -0,0 +1,113 @@
# Generated by Django 3.2.16 on 2023-03-21 20:08
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0023_auto_20230316_0040'),
]
operations = [
migrations.CreateModel(
name='Page',
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.JSONField(blank=True, default=dict)),
('description_html', models.TextField(blank=True, default='<p></p>')),
('description_stripped', models.TextField(blank=True, null=True)),
('access', models.PositiveSmallIntegerField(choices=[(0, 'Public'), (1, 'Private')], default=0)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pages', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Page',
'verbose_name_plural': 'Pages',
'db_table': 'pages',
'ordering': ('-created_at',),
},
),
migrations.AddField(
model_name='project',
name='issue_views_view',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='importer',
name='service',
field=models.CharField(choices=[('github', 'GitHub'), ('jira', 'Jira')], max_length=50),
),
migrations.AlterField(
model_name='project',
name='cover_image',
field=models.URLField(blank=True, max_length=800, null=True),
),
migrations.CreateModel(
name='PageBlock',
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.JSONField(blank=True, default=dict)),
('description_html', models.TextField(blank=True, default='<p></p>')),
('description_stripped', models.TextField(blank=True, null=True)),
('completed_at', models.DateTimeField(null=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pageblock_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blocks', to='db.issue')),
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks', to='db.page')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pageblock', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pageblock_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_pageblock', to='db.workspace')),
],
options={
'verbose_name': 'Page Block',
'verbose_name_plural': 'Page Blocks',
'db_table': 'page_blocks',
'ordering': ('-created_at',),
},
),
migrations.AddField(
model_name='page',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_page', to='db.project'),
),
migrations.AddField(
model_name='page',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AddField(
model_name='page',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_page', to='db.workspace'),
),
migrations.CreateModel(
name='PageFavorite',
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)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagefavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_favorites', to='db.page')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pagefavorite', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagefavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_favorites', to=settings.AUTH_USER_MODEL)),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_pagefavorite', to='db.workspace')),
],
options={
'verbose_name': 'Page Favorite',
'verbose_name_plural': 'Page Favorites',
'db_table': 'page_favorites',
'ordering': ('-created_at',),
'unique_together': {('page', 'user')},
},
),
]
@@ -0,0 +1,61 @@
# Generated by Django 3.2.18 on 2023-03-30 20:33
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0024_auto_20230322_0138'),
]
operations = [
migrations.AddField(
model_name='page',
name='color',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='pageblock',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='pageblock',
name='sync',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='project',
name='page_view',
field=models.BooleanField(default=True),
),
migrations.CreateModel(
name='PageLabel',
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)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagelabel_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_labels', to='db.label')),
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_labels', to='db.page')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pagelabel', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagelabel_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_pagelabel', to='db.workspace')),
],
options={
'verbose_name': 'Page Label',
'verbose_name_plural': 'Page Labels',
'db_table': 'page_labels',
'ordering': ('-created_at',),
},
),
migrations.AddField(
model_name='page',
name='labels',
field=models.ManyToManyField(blank=True, related_name='pages', through='db.PageLabel', to='db.Label'),
),
]
+6 -1
View File
@@ -31,6 +31,7 @@ from .issue import (
Label,
IssueBlocker,
IssueLink,
IssueSequence,
)
from .asset import FileAsset
@@ -43,7 +44,7 @@ from .cycle import Cycle, CycleIssue, CycleFavorite
from .shortcut import Shortcut
from .view import View
from .view import IssueView, IssueViewFavorite
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
@@ -57,3 +58,7 @@ from .integration import (
GithubIssueSync,
GithubCommentSync,
)
from .importer import Importer
from .page import Page, PageBlock, PageFavorite, PageLabel
+7 -1
View File
@@ -1,3 +1,6 @@
# Python imports
from uuid import uuid4
# Django import
from django.db import models
from django.core.exceptions import ValidationError
@@ -7,7 +10,9 @@ from . import BaseModel
def get_upload_path(instance, filename):
return f"{instance.workspace.id}/{filename}"
if instance.workspace_id is not None:
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
return f"user-{uuid4().hex}-{filename}"
def file_size(value):
@@ -15,6 +20,7 @@ def file_size(value):
if value.size > limit:
raise ValidationError("File too large. Size should not exceed 5 MB.")
class FileAsset(BaseModel):
"""
A file asset.
+45
View File
@@ -0,0 +1,45 @@
# Django imports
from django.db import models
from django.conf import settings
# Module imports
from . import ProjectBaseModel
class Importer(ProjectBaseModel):
service = models.CharField(
max_length=50,
choices=(
("github", "GitHub"),
("jira", "Jira"),
),
)
status = models.CharField(
max_length=50,
choices=(
("queued", "Queued"),
("processing", "Processing"),
("completed", "Completed"),
("failed", "Failed"),
),
default="queued",
)
initiated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="imports"
)
metadata = models.JSONField(default=dict)
config = models.JSONField(default=dict)
data = models.JSONField(default=dict)
token = models.ForeignKey(
"db.APIToken", on_delete=models.CASCADE, related_name="importer"
)
class Meta:
verbose_name = "Importer"
verbose_name_plural = "Importers"
db_table = "importers"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the service"""
return f"{self.service} <{self.project.name}>"
+10 -1
View File
@@ -85,7 +85,7 @@ class Issue(ProjectBaseModel):
pass
else:
try:
from plane.db.models import State
from plane.db.models import State, PageBlock
# Get the completed states of the project
completed_states = State.objects.filter(
@@ -94,7 +94,15 @@ class Issue(ProjectBaseModel):
# Check if the current issue state and completed state id are same
if self.state.id in completed_states:
self.completed_at = timezone.now()
# check if there are any page blocks
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=timezone.now()
)
else:
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=None
)
self.completed_at = None
except ImportError:
@@ -307,6 +315,7 @@ class Label(ProjectBaseModel):
color = models.CharField(max_length=255, blank=True)
class Meta:
unique_together = ["name", "project"]
verbose_name = "Label"
verbose_name_plural = "Labels"
db_table = "labels"
+126
View File
@@ -0,0 +1,126 @@
# Django imports
from django.db import models
from django.conf import settings
# Module imports
from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
class Page(ProjectBaseModel):
name = models.CharField(max_length=255)
description = models.JSONField(default=dict, blank=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages"
)
access = models.PositiveSmallIntegerField(
choices=((0, "Public"), (1, "Private")), default=0
)
color = models.CharField(max_length=255, blank=True)
labels = models.ManyToManyField(
"db.Label", blank=True, related_name="pages", through="db.PageLabel"
)
class Meta:
verbose_name = "Page"
verbose_name_plural = "Pages"
db_table = "pages"
ordering = ("-created_at",)
def __str__(self):
"""Return owner email and page name"""
return f"{self.owned_by.email} <{self.name}>"
class PageBlock(ProjectBaseModel):
page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="blocks")
name = models.CharField(max_length=255)
description = models.JSONField(default=dict, blank=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
issue = models.ForeignKey(
"db.Issue", on_delete=models.SET_NULL, related_name="blocks", null=True
)
completed_at = models.DateTimeField(null=True)
sort_order = models.FloatField(default=65535)
sync = models.BooleanField(default=True)
def save(self, *args, **kwargs):
if self._state.adding:
largest_sort_order = PageBlock.objects.filter(
project=self.project, page=self.page
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
if self.completed_at and self.issue:
try:
from plane.db.models import State, Issue
completed_state = State.objects.filter(
group="completed", project=self.project
).first()
if completed_state is not None:
Issue.objects.update(pk=self.issue_id, state=completed_state)
except ImportError:
pass
super(PageBlock, self).save(*args, **kwargs)
class Meta:
verbose_name = "Page Block"
verbose_name_plural = "Page Blocks"
db_table = "page_blocks"
ordering = ("-created_at",)
def __str__(self):
"""Return page and page block"""
return f"{self.page.name} <{self.name}>"
class PageFavorite(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="page_favorites",
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="page_favorites"
)
class Meta:
unique_together = ["page", "user"]
verbose_name = "Page Favorite"
verbose_name_plural = "Page Favorites"
db_table = "page_favorites"
ordering = ("-created_at",)
def __str__(self):
"""Return user and the page"""
return f"{self.user.email} <{self.page.name}>"
class PageLabel(ProjectBaseModel):
label = models.ForeignKey(
"db.Label", on_delete=models.CASCADE, related_name="page_labels"
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="page_labels"
)
class Meta:
verbose_name = "Page Label"
verbose_name_plural = "Page Labels"
db_table = "page_labels"
ordering = ("-created_at",)
def __str__(self):
return f"{self.page.name} {self.label.name}"
+8 -3
View File
@@ -21,10 +21,13 @@ ROLE_CHOICES = (
def get_default_props():
return {
"filters": {"type": None},
"orderBy": "-created_at",
"collapsed": True,
"issueView": "list",
"groupByProperty": None,
"orderBy": None,
"filterIssue": None,
"groupByProperty": True,
"showEmptyGroups": True,
}
@@ -63,7 +66,9 @@ class Project(BaseModel):
icon = models.CharField(max_length=255, null=True, blank=True)
module_view = models.BooleanField(default=True)
cycle_view = models.BooleanField(default=True)
cover_image = models.URLField(blank=True, null=True)
issue_views_view = models.BooleanField(default=True)
page_view = models.BooleanField(default=True)
cover_image = models.URLField(blank=True, null=True, max_length=800)
def __str__(self):
"""Return name of the project"""
+13
View File
@@ -11,9 +11,12 @@ from django.utils import timezone
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
class User(AbstractBaseUser, PermissionsMixin):
@@ -123,6 +126,16 @@ def send_welcome_email(sender, instance, created, **kwargs):
msg.attach_alternative(html_content, "text/html")
msg.send()
# Send message on slack as well
if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN)
try:
_ = client.chat_postMessage(
channel="#trackers",
text=f"New user {instance.email} has signed up and begun the onboarding journey.",
)
except SlackApiError as e:
print(f"Got an error: {e.response['error']}")
return
except Exception as e:
capture_exception(e)
+31 -5
View File
@@ -1,22 +1,48 @@
# Django imports
from django.db import models
from django.conf import settings
# Module import
from . import ProjectBaseModel
class View(ProjectBaseModel):
class IssueView(ProjectBaseModel):
name = models.CharField(max_length=255, verbose_name="View Name")
description = models.TextField(verbose_name="View Description", blank=True)
query = models.JSONField(verbose_name="View Query")
access = models.PositiveSmallIntegerField(
default=1, choices=((0, "Private"), (1, "Public"))
)
query_data = models.JSONField(default=dict)
class Meta:
verbose_name = "View"
verbose_name_plural = "Views"
db_table = "views"
verbose_name = "Issue View"
verbose_name_plural = "Issue Views"
db_table = "issue_views"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.project.name}>"
class IssueViewFavorite(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="user_view_favorites",
)
view = models.ForeignKey(
"db.IssueView", on_delete=models.CASCADE, related_name="view_favorites"
)
class Meta:
unique_together = ["view", "user"]
verbose_name = "View Favorite"
verbose_name_plural = "View Favorites"
db_table = "view_favorites"
ordering = ("-created_at",)
def __str__(self):
"""Return user and the view"""
return f"{self.user.email} <{self.view.name}>"
+1
View File
@@ -48,6 +48,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"crum.CurrentRequestUserMiddleware",
"django.middleware.gzip.GZipMiddleware",
]
REST_FRAMEWORK = {
+10
View File
@@ -78,3 +78,13 @@ if DOCKERIZED:
WEB_URL = os.environ.get("WEB_URL", "localhost:3000")
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
+34 -9
View File
@@ -22,13 +22,7 @@ DATABASES = {
}
}
# CORS WHITELIST ON PROD
CORS_ORIGIN_WHITELIST = [
# "https://example.com",
# "https://sub.example.com",
# "http://localhost:8080",
# "http://127.0.0.1:9000"
]
# Parse database configuration from $DATABASE_URL
DATABASES["default"] = dj_database_url.config()
SITE_ID = 1
@@ -43,12 +37,33 @@ DOCKERIZED = os.environ.get(
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Allow all host headers
ALLOWED_HOSTS = ["*"]
# TODO: Make it FALSE and LIST DOMAINS IN FULL PROD.
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_METHODS = [
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
]
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
]
CORS_ALLOW_CREDENTIALS = True
# Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
@@ -211,3 +226,13 @@ RQ_QUEUES = {
WEB_URL = os.environ.get("WEB_URL")
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
+10
View File
@@ -187,3 +187,13 @@ RQ_QUEUES = {
WEB_URL = os.environ.get("WEB_URL")
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
+9
View File
@@ -27,6 +27,15 @@ def group_results(results_data, group_by):
"""
response_dict = dict()
if group_by == "priority":
response_dict = {
"urgent": [],
"high": [],
"medium": [],
"low": [],
"None": [],
}
for value in results_data:
group_attribute = resolve_keys(group_by, value)
if isinstance(group_attribute, list):
+53
View File
@@ -0,0 +1,53 @@
import requests
from requests.auth import HTTPBasicAuth
from sentry_sdk import capture_exception
def jira_project_issue_summary(email, api_token, project_name, hostname):
try:
auth = HTTPBasicAuth(email, api_token)
headers = {"Accept": "application/json"}
issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_name} AND issuetype=Story"
issue_response = requests.request(
"GET", issue_url, headers=headers, auth=auth
).json()["total"]
module_url = f"https://{hostname}/rest/api/3/search?jql=project={project_name} AND issuetype=Epic"
module_response = requests.request(
"GET", module_url, headers=headers, auth=auth
).json()["total"]
status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_name}"
status_response = requests.request(
"GET", status_url, headers=headers, auth=auth
).json()
labels_url = f"https://{hostname}/rest/api/3/label/?jql=project={project_name}"
labels_response = requests.request(
"GET", labels_url, headers=headers, auth=auth
).json()["total"]
users_url = (
f"https://{hostname}/rest/api/3/users/search?jql=project={project_name}"
)
users_response = requests.request(
"GET", users_url, headers=headers, auth=auth
).json()
return {
"issues": issue_response,
"modules": module_response,
"labels": labels_response,
"states": len(status_response),
"users": (
[
user
for user in users_response
if user.get("accountType") == "atlassian"
]
),
}
except Exception as e:
capture_exception(e)
return {"error": "Something went wrong could not fetch information from jira"}
+61 -5
View File
@@ -1,6 +1,7 @@
import os
import jwt
import requests
from urllib.parse import urlparse, parse_qs
from datetime import datetime, timedelta
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.backends import default_backend
@@ -30,7 +31,7 @@ def get_github_metadata(installation_id):
url = f"https://api.github.com/app/installations/{installation_id}"
headers = {
"Authorization": "Bearer " + token,
"Authorization": "Bearer " + str(token),
"Accept": "application/vnd.github+json",
}
response = requests.get(url, headers=headers).json()
@@ -41,7 +42,7 @@ def get_github_repos(access_tokens_url, repositories_url):
token = get_jwt_token()
headers = {
"Authorization": "Bearer " + token,
"Authorization": "Bearer " + str(token),
"Accept": "application/vnd.github+json",
}
@@ -50,9 +51,9 @@ def get_github_repos(access_tokens_url, repositories_url):
headers=headers,
).json()
oauth_token = oauth_response.get("token")
oauth_token = oauth_response.get("token", "")
headers = {
"Authorization": "Bearer " + oauth_token,
"Authorization": "Bearer " + str(oauth_token),
"Accept": "application/vnd.github+json",
}
response = requests.get(
@@ -67,8 +68,63 @@ def delete_github_installation(installation_id):
url = f"https://api.github.com/app/installations/{installation_id}"
headers = {
"Authorization": "Bearer " + token,
"Authorization": "Bearer " + str(token),
"Accept": "application/vnd.github+json",
}
response = requests.delete(url, headers=headers)
return response
def get_github_repo_details(access_tokens_url, owner, repo):
token = get_jwt_token()
headers = {
"Authorization": "Bearer " + str(token),
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
oauth_response = requests.post(
access_tokens_url,
headers=headers,
).json()
oauth_token = oauth_response.get("token")
headers = {
"Authorization": "Bearer " + oauth_token,
"Accept": "application/vnd.github+json",
}
open_issues = requests.get(
f"https://api.github.com/repos/{owner}/{repo}",
headers=headers,
).json()["open_issues_count"]
total_labels = 0
labels_response = requests.get(
f"https://api.github.com/repos/{owner}/{repo}/labels?per_page=100&page=1",
headers=headers,
)
# Check if there are more pages
if len(labels_response.links.keys()):
# get the query parameter of last
last_url = labels_response.links.get("last").get("url")
parsed_url = urlparse(last_url)
last_page_value = parse_qs(parsed_url.query)["page"][0]
total_labels = total_labels + 100 * (last_page_value - 1)
# Get labels in last page
last_page_labels = requests.get(last_url, headers=headers).json()
total_labels = total_labels + len(last_page_labels)
else:
total_labels = len(labels_response.json())
# Currently only supporting upto 100 collaborators
# TODO: Update this function to fetch all collaborators
collaborators = requests.get(
f"https://api.github.com/repos/{owner}/{repo}/collaborators?per_page=100&page=1",
headers=headers,
).json()
return open_issues, total_labels, collaborators
+214
View File
@@ -0,0 +1,214 @@
from django.utils.timezone import make_aware
from django.utils.dateparse import parse_datetime
def filter_state(params, filter, method):
if method == "GET":
states = params.get("state").split(",")
if len(states) and "" not in states:
filter["state__in"] = states
else:
if params.get("state", None) and len(params.get("state")):
filter["state__in"] = params.get("state")
return filter
def filter_priority(params, filter, method):
if method == "GET":
priorties = params.get("priority").split(",")
if len(priorties) and "" not in priorties:
filter["priority__in"] = priorties
else:
if params.get("priority", None) and len(params.get("priority")):
filter["priority__in"] = params.get("priority")
return filter
def filter_parent(params, filter, method):
if method == "GET":
parents = params.get("parent").split(",")
if len(parents) and "" not in parents:
filter["parent__in"] = parents
else:
if params.get("parent", None) and len(params.get("parent")):
filter["parent__in"] = params.get("parent")
return filter
def filter_labels(params, filter, method):
if method == "GET":
labels = params.get("labels").split(",")
if len(labels) and "" not in labels:
filter["labels__in"] = labels
else:
if params.get("labels", None) and len(params.get("labels")):
filter["labels__in"] = params.get("labels")
return filter
def filter_assignees(params, filter, method):
if method == "GET":
assignees = params.get("assignees").split(",")
if len(assignees) and "" not in assignees:
filter["assignees__in"] = assignees
else:
if params.get("assignees", None) and len(params.get("assignees")):
filter["assignees__in"] = params.get("assignees")
return filter
def filter_created_by(params, filter, method):
if method == "GET":
created_bys = params.get("created_by").split(",")
if len(created_bys) and "" not in created_bys:
filter["created_by__in"] = created_bys
else:
if params.get("created_by", None) and len(params.get("created_by")):
filter["created_by__in"] = params.get("created_by")
return filter
def filter_name(params, filter, method):
if params.get("name", "") != "":
filter["name__icontains"] = params.get("name")
return filter
def filter_created_at(params, filter, method):
if method == "GET":
created_ats = params.get("created_at").split(",")
if len(created_ats) and "" not in created_ats:
for query in created_ats:
created_at_query = query.split(";")
if len(created_at_query) == 2 and "after" in created_at_query:
filter["created_at__date__gte"] = created_at_query[0]
else:
filter["created_at__date__lte"] = created_at_query[0]
else:
if params.get("created_at", None) and len(params.get("created_at")):
for query in params.get("created_at"):
if query.get("timeline", "after") == "after":
filter["created_at__date__gte"] = query.get("datetime")
else:
filter["created_at__date__lte"] = query.get("datetime")
return filter
def filter_updated_at(params, filter, method):
if method == "GET":
updated_ats = params.get("updated_at").split(",")
if len(updated_ats) and "" not in updated_ats:
for query in updated_ats:
updated_at_query = query.split(";")
if len(updated_at_query) == 2 and "after" in updated_at_query:
filter["updated_at__date__gte"] = updated_at_query[0]
else:
filter["updated_at__date__lte"] = updated_at_query[0]
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
for query in params.get("updated_at"):
if query.get("timeline", "after") == "after":
filter["updated_at__date__gte"] = query.get("datetime")
else:
filter["updated_at__date__lte"] = query.get("datetime")
return filter
def filter_start_date(params, filter, method):
if method == "GET":
start_dates = params.get("start_date").split(",")
if len(start_dates) and "" not in start_dates:
for query in start_dates:
start_date_query = query.split(";")
if len(start_date_query) == 2 and "after" in start_date_query:
filter["start_date__gte"] = start_date_query[0]
else:
filter["start_date__lte"] = start_date_query[0]
else:
if params.get("start_date", None) and len(params.get("start_date")):
for query in params.get("start_date"):
if query.get("timeline", "after") == "after":
filter["start_date__gte"] = query.get("datetime")
else:
filter["start_date__lte"] = query.get("datetime")
return filter
def filter_target_date(params, filter, method):
if method == "GET":
target_dates = params.get("target_date").split(",")
if len(target_dates) and "" not in target_dates:
for query in target_dates:
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gte"] = target_date_query[0]
else:
filter["target_date__lte"] = target_date_query[0]
else:
if params.get("target_date", None) and len(params.get("target_date")):
for query in params.get("target_date"):
if query.get("timeline", "after") == "after":
filter["target_date__gte"] = query.get("datetime")
else:
filter["target_date__lte"] = query.get("datetime")
return filter
def filter_completed_at(params, filter, method):
if method == "GET":
completed_ats = params.get("completed_at").split(",")
if len(completed_ats) and "" not in completed_ats:
for query in completed_ats:
completed_at_query = query.split(";")
if len(completed_at_query) == 2 and "after" in completed_at_query:
filter["completed_at__date__gte"] = completed_at_query[0]
else:
filter["completed_at__lte"] = completed_at_query[0]
else:
if params.get("completed_at", None) and len(params.get("completed_at")):
for query in params.get("completed_at"):
if query.get("timeline", "after") == "after":
filter["completed_at__date__gte"] = query.get("datetime")
else:
filter["completed_at__lte"] = query.get("datetime")
return filter
def filter_issue_state_type(params, filter, method):
type = params.get("type", "all")
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
if type == "backlog":
group = ["backlog"]
if type == "active":
group = ["unstarted", "started"]
filter["state__group__in"] = group
return filter
def issue_filters(query_params, method):
filter = dict()
ISSUE_FILTER = {
"state": filter_state,
"priority": filter_priority,
"parent": filter_parent,
"labels": filter_labels,
"assignees": filter_assignees,
"created_by": filter_created_by,
"name": filter_name,
"created_at": filter_created_at,
"updated_at": filter_updated_at,
"start_date": filter_start_date,
"target_date": filter_target_date,
"completed_at": filter_completed_at,
"type": filter_issue_state_type,
}
for key, value in ISSUE_FILTER.items():
if key in query_params:
func = value
func(query_params, filter, method)
return filter
+4 -2
View File
@@ -7,7 +7,7 @@ psycopg2==2.9.5
django-oauth-toolkit==2.2.0
mistune==2.0.4
djangorestframework==3.14.0
redis==4.4.2
redis==4.5.4
django-nested-admin==4.0.2
django-cors-headers==3.13.0
whitenoise==6.3.0
@@ -26,4 +26,6 @@ google-api-python-client==2.75.0
django-rq==2.6.0
django-redis==5.2.0
uvicorn==0.20.0
channels==4.0.0
channels==4.0.0
openai==0.27.2
slack-sdk==3.20.2
+6 -5
View File
@@ -1,7 +1,8 @@
NEXT_PUBLIC_API_BASE_URL = "http://localhost"
NEXT_PUBLIC_GOOGLE_CLIENTID="<-- google client id -->"
NEXT_PUBLIC_GITHUB_APP_NAME="<-- github app name -->"
NEXT_PUBLIC_GITHUB_ID="<-- github client id -->"
NEXT_PUBLIC_SENTRY_DSN="<-- sentry dns -->"
# Replace with your instance Public IP
# NEXT_PUBLIC_API_BASE_URL = "http://localhost"
NEXT_PUBLIC_GOOGLE_CLIENTID=""
NEXT_PUBLIC_GITHUB_APP_NAME=""
NEXT_PUBLIC_GITHUB_ID=""
NEXT_PUBLIC_SENTRY_DSN=""
NEXT_PUBLIC_ENABLE_OAUTH=0
NEXT_PUBLIC_ENABLE_SENTRY=0
+12 -10
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
// ui
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { Button, Input } from "components/ui";
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// services
import authenticationService from "services/authentication.service";
import useToast from "hooks/use-toast";
@@ -90,7 +90,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
return (
<>
<form className="mt-5 space-y-5">
<form className="space-y-5 py-5 px-5">
{(codeSent || codeResent) && (
<div className="rounded-md bg-green-50 p-4">
<div className="flex">
@@ -140,8 +140,8 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
/>
<button
type="button"
className={`text-xs mt-5 w-full flex justify-end outline-none ${
isResendDisabled ? "text-gray-400 cursor-default" : "cursor-pointer text-theme"
className={`mt-5 flex w-full justify-end text-xs outline-none ${
isResendDisabled ? "cursor-default text-gray-400" : "cursor-pointer text-theme"
} `}
onClick={() => {
setIsCodeResending(true);
@@ -169,27 +169,29 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
)}
<div>
{codeSent ? (
<Button
<PrimaryButton
type="submit"
className="w-full text-center"
size="md"
onClick={handleSubmit(handleSignin)}
disabled={isSubmitting || (!isValid && isDirty)}
loading={isSubmitting || (!isValid && isDirty)}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</Button>
</PrimaryButton>
) : (
<Button
<PrimaryButton
type="submit"
className="w-full text-center"
size="md"
onClick={() => {
handleSubmit(onSubmit)().then(() => {
setResendCodeTimer(30);
});
}}
disabled={isSubmitting || (!isValid && isDirty)}
loading={isSubmitting || (!isValid && isDirty)}
>
{isSubmitting ? "Sending code..." : "Send code"}
</Button>
</PrimaryButton>
)}
</div>
</form>
@@ -1,13 +1,15 @@
import React from "react";
// next
import Link from "next/link";
// react hook form
import { useForm } from "react-hook-form";
// ui
import { Button, Input } from "components/ui";
// services
import authenticationService from "services/authentication.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, SecondaryButton } from "components/ui";
// types
type EmailPasswordFormValues = {
email: string;
@@ -58,7 +60,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
};
return (
<>
<form className="mt-5" onSubmit={handleSubmit(onSubmit)}>
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}>
<div>
<Input
id="email"
@@ -97,13 +99,13 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
</div>
</div>
<div className="mt-5">
<Button
disabled={isSubmitting || (!isValid && isDirty)}
className="w-full text-center"
<SecondaryButton
type="submit"
className="w-full text-center"
loading={isSubmitting || (!isValid && isDirty)}
>
{isSubmitting ? "Signing in..." : "Sign In"}
</Button>
</SecondaryButton>
</div>
</form>
</>
@@ -19,28 +19,6 @@ export const EmailSignInForm: FC<EmailSignInFormProps> = (props) => {
) : (
<EmailPasswordForm onSuccess={handleSuccess} />
)}
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">or</span>
</div>
</div>
{/* <div className="mt-6 flex w-full flex-col items-stretch gap-y-2">
<button
type="button"
className="flex w-full items-center rounded border border-gray-300 px-3 py-2 text-sm duration-300 hover:bg-gray-100"
onClick={() => setUseCode((prev) => !prev)}
>
<KeyIcon className="h-[25px] w-[25px]" />
<span className="w-full text-center font-medium">
{useCode ? "Continue with Password" : "Continue with Code"}
</span>
</button>
</div> */}
</div>
</>
);
};
@@ -3,7 +3,7 @@ import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// images
import githubImage from "/public/logos/github.png";
import githubImage from "/public/logos/github-black.png";
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
@@ -33,19 +33,15 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
}, []);
return (
<Link
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
>
<button className="flex w-full items-center rounded bg-black px-3 py-2 text-sm text-white opacity-90 duration-300 hover:opacity-100">
<Image
src={githubImage}
height={25}
width={25}
className="flex-shrink-0"
alt="GitHub Logo"
/>
<span className="w-full text-center font-medium">Continue with GitHub</span>
</button>
</Link>
<div className="px-1 w-full">
<Link
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
>
<button className="flex w-full items-center justify-center gap-3 rounded-md border border-gray-200 p-2 text-sm font-medium text-gray-600 duration-300 hover:bg-gray-50">
<Image src={githubImage} height={22} width={22} color="#000" alt="GitHub Logo" />
<span>Sign In with Github</span>
</button>
</Link>
</div>
);
};
+2 -2
View File
@@ -27,7 +27,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
theme: "outline",
size: "large",
logo_alignment: "center",
width: document.getElementById("googleSignInButton")?.offsetWidth,
width: "410",
text: "continue_with",
} as GsiButtonConfiguration // customization attributes
);
@@ -47,7 +47,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
return (
<>
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
<div className="w-full" id="googleSignInButton" ref={googleSignInButton} />
<div className="h-12" id="googleSignInButton" ref={googleSignInButton} />
</>
);
};
+5 -4
View File
@@ -14,12 +14,13 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
return (
<>
<div className="flex items-center">
<div
className="grid h-8 w-8 cursor-pointer place-items-center flex-shrink-0 rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
<button
type="button"
className="grid h-8 w-8 flex-shrink-0 cursor-pointer place-items-center rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
onClick={() => router.back()}
>
<ArrowLeftIcon className="h-3 w-3" />
</div>
</button>
{children}
</div>
</>
@@ -44,7 +45,7 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
</a>
</Link>
) : (
<div className="px-3 text-sm max-w-64">
<div className="max-w-64 px-3 text-sm">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon}
<span className="break-all">{title}</span>
@@ -0,0 +1,108 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
// types
import { IIssue } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, PROJECT_MEMBERS } from "constants/fetch-keys";
// icons
import { CheckIcon } from "components/icons";
import projectService from "services/project.service";
import { Avatar } from "components/ui";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
};
export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: members } = useSWR(
projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const options =
members?.map(({ member }) => ({
value: member.id,
query:
(member.first_name && member.first_name !== "" ? member.first_name : member.email) +
" " +
member.last_name ?? "",
content: (
<>
<div className="flex items-center gap-2">
<Avatar user={member} />
{member.first_name && member.first_name !== "" ? member.first_name : member.email}
</div>
{issue.assignees.includes(member.id) && (
<div>
<CheckIcon className="h-3 w-3" />
</div>
)}
</>
),
})) ?? [];
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees_list: updatedAssignees });
setIsPaletteOpen(false);
};
return (
<>
{options.map((option) => (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:outline-none"
>
{option.content}
</Command.Item>
))}
</>
);
};
@@ -0,0 +1,74 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
// types
import { IIssue } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { PRIORITIES } from "constants/project";
// icons
import { CheckIcon, getPriorityIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
};
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueState = (priority: string | null) => {
submitChanges({ priority });
setIsPaletteOpen(false);
};
return (
<>
{PRIORITIES.map((priority) => (
<Command.Item
key={priority}
onSelect={() => handleIssueState(priority)}
className="focus:outline-none"
>
<div className="flex items-center space-x-3">
{getPriorityIcon(priority)}
<span className="capitalize">{priority ?? "None"}</span>
</div>
<div>{priority === issue.priority && <CheckIcon className="h-3 w-3" />}</div>
</Command.Item>
))}
</>
);
};
@@ -0,0 +1,95 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// ui
import { Spinner } from "components/ui";
// helpers
import { getStatesList } from "helpers/state.helper";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
// types
import { IIssue } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATE_LIST } from "constants/fetch-keys";
// icons
import { CheckIcon, getStateGroupIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
};
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: stateGroups, mutate: mutateIssueDetails } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutateIssueDetails();
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId, mutateIssueDetails]
);
const handleIssueState = (stateId: string) => {
submitChanges({ state: stateId });
setIsPaletteOpen(false);
};
return (
<>
{states ? (
states.length > 0 ? (
states.map((state) => (
<Command.Item
key={state.id}
onSelect={() => handleIssueState(state.id)}
className="focus:outline-none"
>
<div className="flex items-center space-x-3">
{getStateGroupIcon(state.group, "16", "16", state.color)}
<p>{state.name}</p>
</div>
<div>{state.id === issue.state && <CheckIcon className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,2 +1,5 @@
export * from "./command-pallette";
export * from "./shortcuts-modal";
export * from "./change-issue-state";
export * from "./change-issue-priority";
export * from "./change-issue-assignee";
@@ -3,6 +3,8 @@ import React, { useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// icons
import { XMarkIcon } from "@heroicons/react/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { MacCommandIcon } from "components/icons";
// ui
import { Input } from "components/ui";
@@ -15,7 +17,7 @@ const shortcuts = [
{
title: "Navigation",
shortcuts: [
{ keys: "Ctrl,/,Cmd,K", description: "To open navigator" },
{ keys: "Ctrl,K", description: "To open navigator" },
{ keys: "↑", description: "Move up" },
{ keys: "↓", description: "Move down" },
{ keys: "←", description: "Move left" },
@@ -31,11 +33,13 @@ const shortcuts = [
{ keys: "C", description: "To create issue" },
{ keys: "Q", description: "To create cycle" },
{ keys: "M", description: "To create module" },
{ keys: "V", description: "To create view" },
{ keys: "D", description: "To create page" },
{ keys: "Delete", description: "To bulk delete issues" },
{ keys: "H", description: "To open shortcuts guide" },
{
keys: "Ctrl,/,Cmd,Alt,C",
description: "To copy issue url when on issue detail page.",
keys: "Ctrl,Alt,C",
description: "To copy issue url when on issue detail page",
},
],
},
@@ -100,13 +104,17 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</span>
</Dialog.Title>
<div>
<Input
id="search"
name="search"
type="text"
placeholder="Search for shortcuts"
onChange={(e) => setQuery(e.target.value)}
/>
<div className="flex w-full items-center justify-start gap-1 rounded border-[0.6px] border-gray-200 bg-gray-100 px-3 py-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-gray-500" />
<Input
className="w-full border-none bg-transparent py-1 px-2 text-xs text-gray-500 focus:outline-none"
id="search"
name="search"
type="text"
placeholder="Search for shortcuts"
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<div className="flex w-full flex-col gap-y-3">
{query.trim().length > 0 ? (
@@ -114,14 +122,20 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
filteredShortcuts.map((shortcut) => (
<div key={shortcut.keys} className="flex w-full flex-col">
<div className="flex flex-col gap-y-3">
<div className="flex justify-between">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">{shortcut.description}</p>
<div className="flex items-center gap-x-1">
<div className="flex items-center gap-x-2.5">
{shortcut.keys.split(",").map((key, index) => (
<span key={index} className="flex items-center gap-1">
<kbd className="rounded bg-gray-200 px-1 text-sm">
{key}
</kbd>
{key === "Ctrl" ? (
<span className="flex h-full items-center rounded-sm border border-gray-200 bg-gray-100 p-2">
<MacCommandIcon />
</span>
) : (
<kbd className="rounded-sm border border-gray-200 bg-gray-100 px-2 py-1 text-sm font-medium text-gray-800">
{key === "Ctrl" ? <MacCommandIcon /> : key}
</kbd>
)}
</span>
))}
</div>
@@ -147,14 +161,20 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<p className="mb-4 font-medium">{title}</p>
<div className="flex flex-col gap-y-3">
{shortcuts.map(({ keys, description }, index) => (
<div key={index} className="flex justify-between">
<div key={index} className="flex items-center justify-between">
<p className="text-sm text-gray-500">{description}</p>
<div className="flex items-center gap-x-1">
<div className="flex items-center gap-x-2.5">
{keys.split(",").map((key, index) => (
<span key={index} className="flex items-center gap-1">
<kbd className="rounded bg-gray-200 px-1 text-sm">
{key}
</kbd>
{key === "Ctrl" ? (
<span className="flex h-full items-center rounded-sm border border-gray-200 bg-gray-100 p-2">
<MacCommandIcon />
</span>
) : (
<kbd className="rounded-sm border border-gray-200 bg-gray-100 px-2 py-1 text-sm font-medium text-gray-800">
{key === "Ctrl" ? <MacCommandIcon /> : key}
</kbd>
)}
</span>
))}
</div>
@@ -1,30 +1,30 @@
// hooks
import useIssueView from "hooks/use-issue-view";
import useProjectIssuesView from "hooks/use-issues-view";
// components
import { SingleBoard } from "components/core/board-view/single-board";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IProjectMember, IState, UserAuth } from "types";
import { IIssue, IState, UserAuth } from "types";
import { getStateGroupIcon } from "components/icons";
type Props = {
type: "issue" | "cycle" | "module";
issues: IIssue[];
states: IState[] | undefined;
members: IProjectMember[] | undefined;
addIssueToState: (groupTitle: string, stateId: string | null) => void;
addIssueToState: (groupTitle: string) => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
handleTrashBox: (isDragging: boolean) => void;
removeIssue: ((bridgeId: string) => void) | null;
isCompleted?: boolean;
userAuth: UserAuth;
};
export const AllBoards: React.FC<Props> = ({
type,
issues,
states,
members,
addIssueToState,
makeIssueCopy,
handleEditIssue,
@@ -32,58 +32,75 @@ export const AllBoards: React.FC<Props> = ({
handleDeleteIssue,
handleTrashBox,
removeIssue,
isCompleted = false,
userAuth,
}) => {
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssueView(issues);
const {
groupedByIssues,
groupByProperty: selectedGroup,
showEmptyGroups,
} = useProjectIssuesView();
return (
<>
{groupedByIssues ? (
<div className="h-[calc(100vh-157px)] w-full lg:h-[calc(100vh-115px)]">
<div className="horizontal-scroll-enable flex h-full gap-x-4 overflow-x-auto overflow-y-hidden">
{Object.keys(groupedByIssues).map((singleGroup, index) => {
const currentState =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)
: null;
<div className="horizontal-scroll-enable flex h-[calc(100vh-140px)] gap-x-4">
{Object.keys(groupedByIssues).map((singleGroup, index) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
const stateId =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null;
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null;
const bgColor =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color
: "#000000";
return (
<SingleBoard
key={index}
type={type}
currentState={currentState}
groupTitle={singleGroup}
handleEditIssue={handleEditIssue}
makeIssueCopy={makeIssueCopy}
addIssueToState={() => addIssueToState(singleGroup)}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={openIssuesListModal ?? null}
handleTrashBox={handleTrashBox}
removeIssue={removeIssue}
isCompleted={isCompleted}
userAuth={userAuth}
/>
);
})}
{!showEmptyGroups && (
<div className="h-full w-96 flex-shrink-0 space-y-3 p-1">
<h2 className="text-lg font-semibold">Hidden groups</h2>
<div className="space-y-3">
{Object.keys(groupedByIssues).map((singleGroup, index) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
return (
<SingleBoard
key={index}
type={type}
currentState={currentState}
bgColor={bgColor}
groupTitle={singleGroup}
groupedByIssues={groupedByIssues}
selectedGroup={selectedGroup}
members={members}
handleEditIssue={handleEditIssue}
makeIssueCopy={makeIssueCopy}
addIssueToState={() => addIssueToState(singleGroup, stateId)}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={openIssuesListModal ?? null}
orderBy={orderBy}
handleTrashBox={handleTrashBox}
removeIssue={removeIssue}
userAuth={userAuth}
/>
);
})}
</div>
if (groupedByIssues[singleGroup].length === 0)
return (
<div
key={index}
className="flex items-center justify-between gap-2 rounded bg-white p-2 shadow"
>
<div className="flex items-center gap-2">
{currentState &&
getStateGroupIcon(currentState.group, "16", "16", currentState.color)}
<h4 className="text-sm capitalize">
{selectedGroup === "state"
? addSpaceIfCamelCase(currentState?.name ?? "")
: addSpaceIfCamelCase(singleGroup)}
</h4>
</div>
<span className="text-xs text-gray-500">0</span>
</div>
);
})}
</div>
</div>
)}
</div>
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
) : null}
</>
);
};
@@ -1,80 +1,149 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import issuesService from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useIssuesView from "hooks/use-issues-view";
// component
import { Avatar } from "components/ui";
// icons
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IProjectMember, IState, NestedKeyOf } from "types";
import { getStateGroupIcon } from "components/icons";
import { IIssueLabels, IState } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
groupedByIssues: {
[key: string]: IIssue[];
};
currentState?: IState | null;
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
bgColor?: string;
addIssueToState: () => void;
members: IProjectMember[] | undefined;
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
isCompleted?: boolean;
};
export const BoardHeader: React.FC<Props> = ({
groupedByIssues,
currentState,
selectedGroup,
groupTitle,
bgColor,
addIssueToState,
isCollapsed,
setIsCollapsed,
members,
isCompleted = false,
}) => {
const createdBy =
selectedGroup === "created_by"
? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..."
: null;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
let assignees: any;
if (selectedGroup === "assignees") {
assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : [];
assignees =
assignees.length > 0
? assignees
.map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name)
.join(", ")
: "No assignee";
}
const { groupedByIssues, groupByProperty: selectedGroup } = useIssuesView();
const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
let bgColor = "#000000";
if (selectedGroup === "state") bgColor = currentState?.color ?? "#000000";
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle);
switch (selectedGroup) {
case "state":
title = addSpaceIfCamelCase(currentState?.name ?? "");
break;
case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
break;
}
return title;
};
const getGroupIcon = () => {
let icon;
switch (selectedGroup) {
case "state":
icon = currentState && getStateGroupIcon(currentState.group, "18", "18", bgColor);
break;
case "priority":
icon = getPriorityIcon(groupTitle, "h-[18px] w-[18px] flex items-center");
break;
case "labels":
const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
icon = (
<span
className="h-[18px] w-[18px] flex-shrink-0 rounded-full"
style={{ backgroundColor: labelColor }}
/>
);
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
icon = <Avatar user={member} height="24px" width="24px" fontSize="12px" />;
break;
}
return icon;
};
return (
<div
className={`flex justify-between px-1 ${
className={`flex justify-between items-center px-1 ${
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
}`}
>
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
<div
className={`flex cursor-pointer items-center gap-x-3.5 ${
className={`flex cursor-pointer items-center gap-x-3 ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
}`}
>
{currentState && getStateGroupIcon(currentState.group, "20", "20", bgColor)}
<span className="flex items-center">{getGroupIcon()}</span>
<h2
className={`text-xl font-semibold capitalize`}
className="text-lg font-semibold capitalize"
style={{
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
}}
>
{selectedGroup === "created_by"
? createdBy
: selectedGroup === "assignees"
? assignees
: addSpaceIfCamelCase(groupTitle)}
{getGroupTitle()}
</h2>
<span className="ml-0.5 text-sm bg-gray-100 py-1 px-3 rounded-full">
{groupedByIssues[groupTitle].length}
<span className="ml-0.5 rounded-full bg-gray-100 py-1 px-3 text-sm">
{groupedByIssues?.[groupTitle].length ?? 0}
</span>
</div>
</div>
@@ -93,13 +162,15 @@ export const BoardHeader: React.FC<Props> = ({
<ArrowsPointingOutIcon className="h-4 w-4" />
)}
</button>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 text-gray-700 outline-none duration-300 hover:bg-gray-100"
onClick={addIssueToState}
>
<PlusIcon className="h-4 w-4" />
</button>
{!isCompleted && (
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 text-gray-700 outline-none duration-300 hover:bg-gray-100"
onClick={addIssueToState}
>
<PlusIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
);
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/router";
@@ -6,6 +6,7 @@ import { useRouter } from "next/router";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useIssuesProperties from "hooks/use-issue-properties";
// components
import { BoardHeader, SingleBoardIssue } from "components/core";
@@ -16,177 +17,169 @@ import { PlusIcon } from "@heroicons/react/24/outline";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { IIssue, IProjectMember, IState, NestedKeyOf, UserAuth } from "types";
import { IIssue, IState, UserAuth } from "types";
type Props = {
type?: "issue" | "cycle" | "module";
currentState?: IState | null;
bgColor?: string;
groupTitle: string;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
members: IProjectMember[] | undefined;
handleEditIssue: (issue: IIssue) => void;
makeIssueCopy: (issue: IIssue) => void;
addIssueToState: () => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
orderBy: NestedKeyOf<IIssue> | null;
handleTrashBox: (isDragging: boolean) => void;
removeIssue: ((bridgeId: string) => void) | null;
isCompleted?: boolean;
userAuth: UserAuth;
};
export const SingleBoard: React.FC<Props> = ({
type,
currentState,
bgColor,
groupTitle,
groupedByIssues,
selectedGroup,
members,
handleEditIssue,
makeIssueCopy,
addIssueToState,
handleDeleteIssue,
openIssuesListModal,
orderBy,
handleTrashBox,
removeIssue,
isCompleted = false,
userAuth,
}) => {
// collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true);
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssuesView();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
useEffect(() => {
if (currentState?.group === "completed" || currentState?.group === "cancelled")
setIsCollapsed(false);
}, [currentState]);
return (
<div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-96 bg-gray-50"}`}>
<div className={`h-full flex-shrink-0 ${!isCollapsed ? "" : "w-96 bg-gray-50"}`}>
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}>
<BoardHeader
addIssueToState={addIssueToState}
currentState={currentState}
bgColor={bgColor}
selectedGroup={selectedGroup}
groupTitle={groupTitle}
groupedByIssues={groupedByIssues}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
members={members}
isCompleted={isCompleted}
/>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`relative h-full overflow-y-auto p-1 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!isCollapsed ? "hidden" : "block"}`}
ref={provided.innerRef}
{...provided.droppableProps}
>
{orderBy !== "sort_order" && (
<>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-gray-100 opacity-50`}
/>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-white p-2 text-xs`}
>
This board is ordered by {replaceUnderscoreIfSnakeCase(orderBy ?? "created_at")}
</div>
</>
)}
{groupedByIssues[groupTitle].map((issue, index: number) => (
<Draggable
key={issue.id}
draggableId={issue.id}
index={index}
isDragDisabled={
isNotAllowed || selectedGroup === "created_by" || selectedGroup === "assignees"
}
>
{(provided, snapshot) => (
<SingleBoardIssue
key={index}
provided={provided}
snapshot={snapshot}
type={type}
issue={issue}
selectedGroup={selectedGroup}
properties={properties}
editIssue={() => handleEditIssue(issue)}
makeIssueCopy={() => makeIssueCopy(issue)}
handleDeleteIssue={handleDeleteIssue}
orderBy={orderBy}
handleTrashBox={handleTrashBox}
removeIssue={() => {
removeIssue && removeIssue(issue.bridge);
}}
userAuth={userAuth}
/>
)}
</Draggable>
))}
<span
style={{
display: orderBy === "sort_order" ? "inline" : "none",
}}
{isCollapsed && (
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`relative h-full overflow-y-auto p-1 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!isCollapsed ? "hidden" : "block"}`}
ref={provided.innerRef}
{...provided.droppableProps}
>
{provided.placeholder}
</span>
{type === "issue" ? (
<button
type="button"
className="flex items-center gap-2 font-medium text-theme outline-none"
onClick={addIssueToState}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
) : (
<CustomMenu
customButton={
<button
type="button"
className="flex items-center gap-2 font-medium text-theme outline-none"
{orderBy !== "sort_order" && (
<>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-gray-100 opacity-50`}
/>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-white p-2 text-xs`}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
}
optionsPosition="left"
noBorder
This board is ordered by{" "}
{replaceUnderscoreIfSnakeCase(orderBy ?? "created_at")}
</div>
</>
)}
{groupedByIssues?.[groupTitle].map((issue, index) => (
<Draggable
key={issue.id}
draggableId={issue.id}
index={index}
isDragDisabled={isNotAllowed || selectedGroup === "created_by"}
>
{(provided, snapshot) => (
<SingleBoardIssue
key={index}
provided={provided}
snapshot={snapshot}
type={type}
index={index}
selectedGroup={selectedGroup}
issue={issue}
groupTitle={groupTitle}
properties={properties}
editIssue={() => handleEditIssue(issue)}
makeIssueCopy={() => makeIssueCopy(issue)}
handleDeleteIssue={handleDeleteIssue}
handleTrashBox={handleTrashBox}
removeIssue={() => {
if (removeIssue && issue.bridge_id) removeIssue(issue.bridge_id);
}}
isCompleted={isCompleted}
userAuth={userAuth}
/>
)}
</Draggable>
))}
<span
style={{
display: orderBy === "sort_order" ? "inline" : "none",
}}
>
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
)}
</StrictModeDroppable>
{provided.placeholder}
</span>
{type === "issue" ? (
<button
type="button"
className="flex items-center gap-2 font-medium text-theme outline-none"
onClick={addIssueToState}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
) : (
!isCompleted && (
<CustomMenu
customButton={
<button
type="button"
className="flex items-center gap-2 font-medium text-theme outline-none"
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
}
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)
)}
</div>
)}
</StrictModeDroppable>
)}
</div>
</div>
);
@@ -15,6 +15,7 @@ import {
// services
import issuesService from "services/issues.service";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useToast from "hooks/use-toast";
// components
import {
@@ -24,41 +25,46 @@ import {
ViewStateSelect,
} from "components/issues/view-select";
// ui
import { ContextMenu, CustomMenu, Tooltip } from "components/ui";
import { ContextMenu, CustomMenu } from "components/ui";
// icons
import {
ClipboardDocumentCheckIcon,
LinkIcon,
PencilIcon,
TrashIcon,
XMarkIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/react/24/outline";
// helpers
import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import {
CycleIssueResponse,
IIssue,
ModuleIssueResponse,
NestedKeyOf,
Properties,
UserAuth,
} from "types";
import { IIssue, Properties, TIssueGroupByOptions, UserAuth } from "types";
// fetch-keys
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
} from "constants/fetch-keys";
type Props = {
type?: string;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
issue: IIssue;
selectedGroup: NestedKeyOf<IIssue> | null;
properties: Properties;
groupTitle?: string;
index: number;
selectedGroup: TIssueGroupByOptions;
editIssue: () => void;
makeIssueCopy: () => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
orderBy: NestedKeyOf<IIssue> | null;
handleTrashBox: (isDragging: boolean) => void;
isCompleted?: boolean;
userAuth: UserAuth;
};
@@ -67,20 +73,24 @@ export const SingleBoardIssue: React.FC<Props> = ({
provided,
snapshot,
issue,
selectedGroup,
properties,
index,
selectedGroup,
editIssue,
makeIssueCopy,
removeIssue,
groupTitle,
handleDeleteIssue,
orderBy,
handleTrashBox,
isCompleted = false,
userAuth,
}) => {
// context menu
const [contextMenu, setContextMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
const { orderBy, params } = useIssuesView();
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@@ -91,75 +101,58 @@ export const SingleBoardIssue: React.FC<Props> = ({
if (!workspaceSlug || !projectId) return;
if (cycleId)
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId as string),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
},
};
}
return p;
});
return [...updatedIssues];
},
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
(prevData) =>
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
false
);
if (moduleId)
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId as string),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
},
};
}
return p;
});
return [...updatedIssues];
},
else if (moduleId)
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
MODULE_ISSUES_WITH_PARAMS(moduleId as string),
(prevData) =>
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
false
);
else
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
(prevData) =>
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
false
);
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === issue.id)
return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list };
return p;
}),
false
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.then((res) => {
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
.then(() => {
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
mutate(CYCLE_DETAILS(cycleId as string));
} 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 as string, params));
})
.catch((error) => {
console.log(error);
});
},
[workspaceSlug, projectId, cycleId, moduleId, issue]
[workspaceSlug, projectId, cycleId, moduleId, issue, groupTitle, index, selectedGroup, params]
);
const getStyle = (
@@ -168,9 +161,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
) => {
if (orderBy === "sort_order") return style;
if (!snapshot.isDragging) return {};
if (!snapshot.isDropAnimating) {
return style;
}
if (!snapshot.isDropAnimating) return style;
return {
...style,
@@ -196,7 +187,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
}, [snapshot, handleTrashBox]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
return (
<>
@@ -206,18 +197,31 @@ export const SingleBoardIssue: React.FC<Props> = ({
isOpen={contextMenu}
setIsOpen={setContextMenu}
>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
Edit issue
</ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
Make a copy...
</ContextMenu.Item>
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
Delete issue
</ContextMenu.Item>
{!isNotAllowed && (
<>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
Edit issue
</ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
Make a copy...
</ContextMenu.Item>
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
Delete issue
</ContextMenu.Item>
</>
)}
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</ContextMenu.Item>
<a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item>
</a>
</ContextMenu>
<div
className={`mb-3 rounded bg-white shadow ${
@@ -233,22 +237,36 @@ export const SingleBoardIssue: React.FC<Props> = ({
setContextMenuPosition({ x: e.pageX, y: e.pageY });
}}
>
<div className="group/card relative select-none p-4">
<div className="group/card relative select-none p-3.5">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
<div className="z-1 absolute top-1.5 right-1.5 opacity-0 group-hover/card:opacity-100">
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={editIssue}>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit issue</span>
</div>
</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
<div className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" />
<span>Remove from {type}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
Delete issue
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>
Copy issue link
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue Link</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
@@ -301,7 +319,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
{properties.labels && issue.label_details.length > 0 && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
<div
key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
>
@@ -312,7 +330,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
}}
/>
{label.name}
</span>
</div>
))}
</div>
)}
@@ -13,7 +13,7 @@ import issuesServices from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "components/ui";
import { DangerButton, SecondaryButton } from "components/ui";
// icons
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
@@ -100,12 +100,6 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
type: "success",
message: res.message,
});
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => (prevData ?? []).filter((p) => !data.delete_issue_ids.includes(p.id)),
false
);
handleClose();
})
.catch((e) => {
@@ -211,17 +205,10 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
Close
</Button>
<Button
onClick={handleSubmit(handleDelete)}
theme="danger"
size="sm"
disabled={isSubmitting}
>
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<DangerButton onClick={handleSubmit(handleDelete)} loading={isSubmitting}>
{isSubmitting ? "Deleting..." : "Delete selected issues"}
</Button>
</DangerButton>
</div>
)}
</form>
@@ -0,0 +1,470 @@
import React, { useState } from "react";
import useSWR, { mutate } from "swr";
import Link from "next/link";
import { useRouter } from "next/router";
// helper
import { renderDateFormat } from "helpers/date-time.helper";
import {
startOfWeek,
lastDayOfWeek,
eachDayOfInterval,
weekDayInterval,
formatDate,
getCurrentWeekStartDate,
getCurrentWeekEndDate,
subtractMonths,
addMonths,
updateDateWithYear,
updateDateWithMonth,
isSameMonth,
isSameYear,
subtract7DaysToDate,
addSevenDaysToDate,
} from "helpers/calendar.helper";
// ui
import { Popover, Transition } from "@headlessui/react";
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CustomMenu } from "components/ui";
// icon
import {
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "@heroicons/react/24/outline";
// services
import issuesService from "services/issues.service";
import cyclesService from "services/cycles.service";
// fetch key
import {
CYCLE_CALENDAR_ISSUES,
MODULE_CALENDAR_ISSUES,
PROJECT_CALENDAR_ISSUES,
} from "constants/fetch-keys";
// type
import { IIssue } from "types";
// constant
import { monthOptions, yearOptions } from "constants/calendar";
import modulesService from "services/modules.service";
interface ICalendarRange {
startDate: Date;
endDate: Date;
}
export const CalendarView = () => {
const [showWeekEnds, setShowWeekEnds] = useState<boolean>(false);
const [currentDate, setCurrentDate] = useState<Date>(new Date());
const [isMonthlyView, setIsMonthlyView] = useState<boolean>(true);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const [calendarDateRange, setCalendarDateRange] = useState<ICalendarRange>({
startDate: startOfWeek(currentDate),
endDate: lastDayOfWeek(currentDate),
});
const targetDateFilter = {
target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat(
calendarDateRange.endDate
)};before`,
};
const { data: projectCalendarIssues } = useSWR(
workspaceSlug && projectId ? PROJECT_CALENDAR_ISSUES(projectId as string) : null,
workspaceSlug && projectId
? () =>
issuesService.getIssuesWithParams(
workspaceSlug as string,
projectId as string,
targetDateFilter
)
: null
);
const { data: cycleCalendarIssues } = useSWR(
workspaceSlug && projectId && cycleId
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
: null,
workspaceSlug && projectId && cycleId
? () =>
cyclesService.getCycleIssuesWithParams(
workspaceSlug as string,
projectId as string,
cycleId as string,
targetDateFilter
)
: null
);
const { data: moduleCalendarIssues } = useSWR(
workspaceSlug && projectId && moduleId
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
: null,
workspaceSlug && projectId && moduleId
? () =>
modulesService.getModuleIssuesWithParams(
workspaceSlug as string,
projectId as string,
moduleId as string,
targetDateFilter
)
: null
);
const totalDate = eachDayOfInterval({
start: calendarDateRange.startDate,
end: calendarDateRange.endDate,
});
const onlyWeekDays = weekDayInterval({
start: calendarDateRange.startDate,
end: calendarDateRange.endDate,
});
const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays;
const calendarIssues = cycleCalendarIssues ?? moduleCalendarIssues ?? projectCalendarIssues;
const currentViewDaysData = currentViewDays.map((date: Date) => {
const filterIssue =
calendarIssues && calendarIssues.length > 0
? (calendarIssues as IIssue[]).filter(
(issue) =>
issue.target_date && renderDateFormat(issue.target_date) === renderDateFormat(date)
)
: [];
return {
date: renderDateFormat(date),
issues: filterIssue,
};
});
const weeks = ((date: Date[]) => {
const weeks = [];
if (showWeekEnds) {
for (let day = 0; day <= 6; day++) {
weeks.push(date[day]);
}
} else {
for (let day = 0; day <= 4; day++) {
weeks.push(date[day]);
}
}
return weeks;
})(currentViewDays);
const onDragEnd = (result: DropResult) => {
const { source, destination, draggableId } = result;
if (!destination || !workspaceSlug || !projectId) return;
if (source.droppableId === destination.droppableId) return;
const fetchKey = cycleId
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
: moduleId
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
: PROJECT_CALENDAR_ISSUES(projectId as string);
mutate<IIssue[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === draggableId)
return {
...p,
target_date: destination.droppableId,
};
return p;
}),
false
);
issuesService.patchIssue(workspaceSlug as string, projectId as string, draggableId, {
target_date: destination?.droppableId,
});
};
const updateDate = (date: Date) => {
setCurrentDate(date);
setCalendarDateRange({
startDate: startOfWeek(date),
endDate: lastDayOfWeek(date),
});
};
return (
<DragDropContext onDragEnd={onDragEnd}>
<div className="h-full overflow-y-auto rounded-lg text-gray-600 -m-2">
<div className="mb-4 flex items-center justify-between">
<div className="relative flex h-full w-full gap-2 items-center justify-start text-sm ">
<Popover className="flex h-full items-center justify-start rounded-lg">
{({ open }) => (
<>
<Popover.Button className={`group flex h-full items-start gap-1 text-gray-800`}>
<div className="flex items-center justify-center gap-2 text-2xl font-semibold">
<span className="text-black">{formatDate(currentDate, "Month")}</span>{" "}
<span>{formatDate(currentDate, "yyyy")}</span>
</div>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 left-0 z-20 w-full max-w-xs flex flex-col transform overflow-hidden bg-white shadow-lg rounded-[10px]">
<div className="flex justify-center items-center text-sm gap-5 px-2 py-2">
{yearOptions.map((year) => (
<button
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-gray-800"
: "text-xs text-gray-400 "
} hover:text-sm hover:text-gray-800 hover:font-medium `}
>
{year.label}
</button>
))}
</div>
<div className="grid grid-cols-4 px-2 border-t border-gray-200">
{monthOptions.map((month) => (
<button
onClick={() =>
updateDate(updateDateWithMonth(month.value, currentDate))
}
className={`text-gray-400 text-xs px-2 py-2 hover:font-medium hover:text-gray-800 ${
isSameMonth(month.value, currentDate)
? "font-medium text-gray-800"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<div className="flex items-center gap-2">
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(subtractMonths(currentDate, 1));
} else {
setCurrentDate(subtract7DaysToDate(currentDate));
setCalendarDateRange({
startDate: getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
endDate: getCurrentWeekEndDate(subtract7DaysToDate(currentDate)),
});
}
}}
>
<ChevronLeftIcon className="h-4 w-4" />
</button>
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(addMonths(currentDate, 1));
} else {
setCurrentDate(addSevenDaysToDate(currentDate));
setCalendarDateRange({
startDate: getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
endDate: getCurrentWeekEndDate(addSevenDaysToDate(currentDate)),
});
}
}}
>
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex w-full gap-2 items-center justify-end">
<button
className="group flex cursor-pointer items-center gap-2 rounded-md border bg-white px-4 py-1.5 text-sm hover:bg-gray-100 hover:text-gray-900 focus:outline-none"
onClick={() => {
if (isMonthlyView) {
updateDate(new Date());
} else {
setCurrentDate(new Date());
setCalendarDateRange({
startDate: getCurrentWeekStartDate(new Date()),
endDate: getCurrentWeekEndDate(new Date()),
});
}
}}
>
Today{" "}
</button>
<CustomMenu
customButton={
<div
className={`group flex cursor-pointer items-center gap-2 rounded-md border bg-white px-3 py-1.5 text-sm hover:bg-gray-100 hover:text-gray-900 focus:outline-none `}
>
{isMonthlyView ? "Monthly" : "Weekly"}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(true);
setCalendarDateRange({
startDate: startOfWeek(currentDate),
endDate: lastDayOfWeek(currentDate),
});
}}
className="w-52 text-sm text-gray-600"
>
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
<span className="flex items-center gap-2">Monthly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${
isMonthlyView ? "opacity-100" : "opacity-0"
}`}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(false);
setCalendarDateRange({
startDate: getCurrentWeekStartDate(currentDate),
endDate: getCurrentWeekEndDate(currentDate),
});
}}
className="w-52 text-sm text-gray-600"
>
<div className="flex w-full items-center justify-between gap-2">
<span className="flex items-center gap-2">Weekly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${
isMonthlyView ? "opacity-0" : "opacity-100"
}`}
/>
</div>
</CustomMenu.MenuItem>
<div className="mt-1 flex w-52 items-center justify-between border-t border-gray-200 py-2 px-1 text-sm text-gray-600">
<h4>Show weekends</h4>
<button
type="button"
className={`relative inline-flex h-3.5 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
showWeekEnds ? "bg-green-500" : "bg-gray-200"
}`}
role="switch"
aria-checked={showWeekEnds}
onClick={() => setShowWeekEnds(!showWeekEnds)}
>
<span className="sr-only">Show weekends</span>
<span
aria-hidden="true"
className={`inline-block h-2.5 w-2.5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
showWeekEnds ? "translate-x-2.5" : "translate-x-0"
}`}
/>
</button>
</div>
</CustomMenu>
</div>
</div>
<div
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
}`}
>
{weeks.map((date, index) => (
<div
key={index}
className={`flex items-center justify-start p-1.5 gap-2 border-gray-300 bg-gray-100 text-base font-medium text-gray-600 ${
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`}
>
<span>
{isMonthlyView ? formatDate(date, "eee").substring(0, 3) : formatDate(date, "eee")}
</span>
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
</div>
))}
</div>
<div
className={`grid h-full auto-rows-[minmax(150px,1fr)] ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `}
>
{currentViewDaysData.map((date, index) => (
<StrictModeDroppable droppableId={date.date}>
{(provided, snapshot) => (
<div
key={index}
ref={provided.innerRef}
{...provided.droppableProps}
className={`flex flex-col gap-1.5 border-t border-gray-300 p-2.5 text-left text-sm font-medium hover:bg-gray-100 ${
showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
}`}
>
{isMonthlyView && <span>{formatDate(new Date(date.date), "d")}</span>}
{date.issues.length > 0 &&
date.issues.map((issue: IIssue, index) => (
<Draggable draggableId={issue.id} index={index}>
{(provided, snapshot) => (
<div
key={index}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={`w-full cursor-pointer truncate rounded bg-white p-1.5 hover:scale-105 ${
snapshot.isDragging ? "shadow-lg" : ""
}`}
>
<Link
href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}
className="w-full"
>
{issue.name}
</Link>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</StrictModeDroppable>
))}
</div>
</div>
</DragDropContext>
);
};
@@ -0,0 +1 @@
export * from "./calendar"
@@ -1,17 +1,25 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// hooks
import { Combobox, Dialog, Transition } from "@headlessui/react";
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
import useToast from "hooks/use-toast";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
// ui
import { Button } from "components/ui";
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// types
import { IIssue } from "types";
// fetch-keys
import { CYCLE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
type FormInput = {
issues: string[];
@@ -32,8 +40,13 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
}) => {
const [query, setQuery] = useState("");
const router = useRouter();
const { cycleId, moduleId } = router.query;
const { setToastAlert } = useToast();
const { params } = useIssuesView();
const handleClose = () => {
onClose();
setQuery("");
@@ -63,6 +76,9 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
}
await handleOnSubmit(data);
if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
handleClose();
setToastAlert({
@@ -180,17 +196,10 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
/>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Adding..." : "Add selected issues"}
</Button>
</PrimaryButton>
</div>
)}
</form>
+282
View File
@@ -0,0 +1,282 @@
import React from "react";
import Image from "next/image";
// icons
import {
ArrowTopRightOnSquareIcon,
CalendarDaysIcon,
ChartBarIcon,
ChatBubbleBottomCenterTextIcon,
ChatBubbleLeftEllipsisIcon,
RectangleGroupIcon,
Squares2X2Icon,
TrashIcon,
UserIcon,
} from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons";
// helpers
import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import RemirrorRichTextEditor from "components/rich-text-editor";
import Link from "next/link";
const activityDetails: {
[key: string]: {
message?: string;
icon: JSX.Element;
};
} = {
assignee: {
message: "removed the assignee",
icon: <UserGroupIcon className="h-3 w-3" color="#6b7280" aria-hidden="true" />,
},
assignees: {
message: "added a new assignee",
icon: <UserGroupIcon className="h-3 w-3" color="#6b7280" aria-hidden="true" />,
},
blocks: {
message: "marked this issue being blocked by",
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
},
blocking: {
message: "marked this issue is blocking",
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
cycles: {
message: "set the cycle to",
icon: <CyclesIcon height="12" width="12" color="#6b7280" />,
},
labels: {
icon: <TagIcon height="12" width="12" color="#6b7280" />,
},
modules: {
message: "set the module to",
icon: <RectangleGroupIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
},
state: {
message: "set the state to",
icon: <Squares2X2Icon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
},
priority: {
message: "set the priority to",
icon: <ChartBarIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
},
name: {
message: "set the name to",
icon: <ChatBubbleBottomCenterTextIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
},
description: {
message: "updated the description.",
icon: <ChatBubbleBottomCenterTextIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
},
target_date: {
message: "set the due date to",
icon: <CalendarDaysIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
},
parent: {
message: "set the parent to",
icon: <UserIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
},
issue: {
message: "deleted the issue.",
icon: <TrashIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
},
};
export const Feeds: React.FC<any> = ({ activities }) => (
<div>
<ul role="list" className="-mb-4">
{activities.map((activity: any, activityIdx: number) => {
// determines what type of action is performed
let action = activityDetails[activity.field as keyof typeof activityDetails]?.message;
if (activity.field === "labels") {
action = activity.new_value !== "" ? "added a new label" : "removed the label";
} else if (activity.field === "blocking") {
action =
activity.new_value !== ""
? "marked this issue is blocking"
: "removed the issue from blocking";
} else if (activity.field === "blocks") {
action =
activity.new_value !== "" ? "marked this issue being blocked by" : "removed blocker";
} else if (activity.field === "target_date") {
action =
activity.new_value && activity.new_value !== ""
? "set the due date to"
: "removed the due date";
} else if (activity.field === "parent") {
action =
activity.new_value && activity.new_value !== ""
? "set the parent to"
: "removed the parent";
} else if (activity.field === "priority") {
action =
activity.new_value && activity.new_value !== ""
? "set the priority to"
: "removed the priority";
} else if (activity.field === "description") {
action = "updated the";
}
// for values that are after the action clause
let value: any = activity.new_value ? activity.new_value : activity.old_value;
if (
activity.verb === "created" &&
activity.field !== "cycles" &&
activity.field !== "modules"
) {
const { workspace_detail, project, issue } = activity;
value = (
<span className="text-gray-600">
created{" "}
<Link href={`/${workspace_detail.slug}/projects/${project}/issues/${issue}`}>
<a className="inline-flex items-center hover:underline">
this issue. <ArrowTopRightOnSquareIcon className="h-3.5 w-3.5 ml-1" />
</a>
</Link>
</span>
);
} else if (activity.field === "state") {
value = activity.new_value ? addSpaceIfCamelCase(activity.new_value) : "None";
} else if (activity.field === "labels") {
let name;
let id = "#000000";
if (activity.new_value !== "") {
name = activity.new_value;
id = activity.new_identifier ? activity.new_identifier : id;
} else {
name = activity.old_value;
id = activity.old_identifier ? activity.old_identifier : id;
}
value = name;
} else if (activity.field === "assignees") {
value = activity.new_value;
} else if (activity.field === "target_date") {
const date =
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value;
value = renderShortNumericDateFormat(date as string);
} else if (activity.field === "description") {
value = "description";
}
if (activity.field === "comment") {
return (
<div key={activity.id}>
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
<Image
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
>
{activity.actor_detail.first_name.charAt(0)}
</div>
)}
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-white px-0.5 py-px">
<ChatBubbleLeftEllipsisIcon
className="h-3.5 w-3.5 text-gray-400"
aria-hidden="true"
/>
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-xs">
{activity.actor_detail.first_name}
{activity.actor_detail.is_bot ? "Bot" : " " + activity.actor_detail.last_name}
</div>
<p className="mt-0.5 text-xs text-gray-500">
Commented {timeAgo(activity.created_at)}
</p>
</div>
<div className="issue-comments-section p-0">
<RemirrorRichTextEditor
value={
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value
}
editable={false}
onBlur={() => ({})}
noBorder
customClassName="text-xs bg-gray-100"
/>
</div>
</div>
</div>
</div>
);
}
if ("field" in activity && activity.field !== "updated_by") {
return (
<li key={activity.id}>
<div className="relative pb-1">
{activities.length > 1 && activityIdx !== activities.length - 1 ? (
<span
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-gray-200"
aria-hidden="true"
/>
) : null}
<div className="relative flex items-start space-x-2">
<>
<div>
<div className="relative px-1.5">
<div className="mt-1.5">
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-gray-100 ring-white">
{activity.field ? (
activityDetails[activity.field as keyof typeof activityDetails]?.icon
) : activity.actor_detail.avatar &&
activity.actor_detail.avatar !== "" ? (
<Image
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
height={24}
width={24}
className="rounded-full"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
>
{activity.actor_detail.first_name.charAt(0)}
</div>
)}
</div>
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-3">
<div className="text-xs text-gray-500">
<span className="text-gray font-medium">
{activity.actor_detail.first_name}
{activity.actor_detail.is_bot
? " Bot"
: " " + activity.actor_detail.last_name}
</span>
<span> {action} </span>
<span className="text-xs font-medium text-gray-900"> {value} </span>
<span className="whitespace-nowrap">{timeAgo(activity.created_at)}</span>
</div>
</div>
</>
</div>
</div>
</li>
);
}
})}
</ul>
</div>
);
+337
View File
@@ -0,0 +1,337 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// ui
import { Avatar } from "components/ui";
// helpers
import { getStatesList } from "helpers/state.helper";
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// services
import issuesService from "services/issues.service";
import projectService from "services/project.service";
import stateService from "services/state.service";
// types
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATE_LIST } from "constants/fetch-keys";
import { IIssueFilterOptions } from "types";
export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const { data: members } = useSWR(
projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const { data: issueLabels } = useSWR(
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId.toString())
: null
);
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
if (!filters) return <></>;
const nullFilters = Object.keys(filters).filter(
(key) => filters[key as keyof IIssueFilterOptions] === null
);
return (
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
{Object.keys(filters).map((key) => {
if (filters[key as keyof typeof filters] !== null)
return (
<div
key={key}
className="flex items-center gap-x-2 rounded-full border bg-white px-2 py-1"
>
<span className="font-medium capitalize text-gray-500">
{replaceUnderscoreIfSnakeCase(key)}:
</span>
{filters[key as keyof IIssueFilterOptions] === null ||
(filters[key as keyof IIssueFilterOptions]?.length ?? 0) <= 0 ? (
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
) : Array.isArray(filters[key as keyof IIssueFilterOptions]) ? (
<div className="space-x-2">
{key === "state" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.state?.map((stateId: any) => {
const state = states?.find((s) => s.id === stateId);
return (
<p
key={state?.id}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium text-white"
style={{
color: state?.color,
backgroundColor: `${state?.color}20`,
}}
>
<span>
{getStateGroupIcon(
state?.group ?? "backlog",
"12",
"12",
state?.color
)}
</span>
<span>{state?.name ?? ""}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters(
{
state: filters.state?.filter((s: any) => s !== stateId),
},
!Boolean(viewId)
)
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
);
})}
<button
type="button"
onClick={() =>
setFilters({
state: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : key === "priority" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.priority?.map((priority: any) => (
<p
key={priority}
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium capitalize text-white ${
priority === "urgent"
? "bg-red-100 text-red-600 hover:bg-red-100"
: priority === "high"
? "bg-orange-100 text-orange-500 hover:bg-orange-100"
: priority === "medium"
? "bg-yellow-100 text-yellow-500 hover:bg-yellow-100"
: priority === "low"
? "bg-green-100 text-green-500 hover:bg-green-100"
: "bg-gray-100 text-gray-700 hover:bg-gray-100"
}`}
>
<span>{getPriorityIcon(priority)}</span>
<span>{priority ? priority : "None"}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters(
{
priority: filters.priority?.filter((p: any) => p !== priority),
},
!Boolean(viewId)
)
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
))}
<button
type="button"
onClick={() =>
setFilters({
priority: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : key === "assignees" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.member.id === memberId)?.member;
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full px-1 font-medium capitalize"
>
<Avatar user={member} />
<span>{member?.first_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters(
{
assignees: filters.assignees?.filter(
(p: any) => p !== memberId
),
},
!Boolean(viewId)
)
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})}
<button
type="button"
onClick={() =>
setFilters({
assignees: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : (key as keyof IIssueFilterOptions) === "created_by" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.created_by?.map((memberId: string) => {
const member = members?.find((m) => m.member.id === memberId)?.member;
return (
<div
key={`${memberId}-${key}`}
className="inline-flex items-center gap-x-1 rounded-full px-1 font-medium capitalize"
>
<Avatar user={member} />
<span>{member?.first_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters(
{
created_by: filters.created_by?.filter(
(p: any) => p !== memberId
),
},
!Boolean(viewId)
)
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})}
<button
type="button"
onClick={() =>
setFilters({
created_by: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : key === "labels" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.labels?.map((labelId: string) => {
const label = issueLabels?.find((l) => l.id === labelId);
if (!label) return null;
const color = label.color !== "" ? label.color : "#0f172a";
return (
<div
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium"
style={{
background: `${color}33`, // add 20% opacity
}}
key={labelId}
>
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor: color,
}}
/>
<span
style={{
color: color,
}}
>
{label.name}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters(
{
labels: filters.labels?.filter((l: any) => l !== labelId),
},
!Boolean(viewId)
)
}
>
<XMarkIcon
className="h-3 w-3"
style={{
color: color,
}}
/>
</span>
</div>
);
})}
<button
type="button"
onClick={() =>
setFilters({
labels: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : (
(filters[key as keyof IIssueFilterOptions] as any)?.join(", ")
)}
</div>
) : (
<span className="capitalize">{filters[key as keyof typeof filters]}</span>
)}
</div>
);
})}
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
<button
type="button"
onClick={() =>
setFilters({
state: null,
priority: null,
assignees: null,
labels: null,
created_by: null,
})
}
className="flex items-center gap-x-1 rounded-full border bg-white px-3 py-1.5 text-xs"
>
<span className="font-medium">Clear all filters</span>
<XMarkIcon className="h-4 w-4" />
</button>
)}
</div>
);
};
@@ -0,0 +1,204 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// react-hook-form
import { useForm } from "react-hook-form";
// services
import aiService from "services/ai.service";
import trackEventServices from "services/track-event.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { IIssue, IPageBlock } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
inset?: string;
content: string;
htmlContent?: string;
onResponse: (response: string) => void;
projectId: string;
block?: IPageBlock;
issue?: IIssue;
};
type FormData = {
prompt: string;
task: string;
};
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
});
export const GptAssistantModal: React.FC<Props> = ({
isOpen,
handleClose,
inset = "top-0 left-0",
content,
htmlContent,
onResponse,
projectId,
block,
issue,
}) => {
const [response, setResponse] = useState("");
const [invalidResponse, setInvalidResponse] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
handleSubmit,
register,
reset,
setFocus,
formState: { isSubmitting },
} = useForm({
defaultValues: {
prompt: content,
task: "",
},
});
const onClose = () => {
handleClose();
setResponse("");
setInvalidResponse(false);
reset();
};
const handleResponse = async (formData: FormData) => {
if (!workspaceSlug || !projectId) return;
if (formData.task === "") {
setToastAlert({
type: "error",
title: "Error!",
message: "Please enter some task to get AI assistance.",
});
return;
}
await aiService
.createGptTask(workspaceSlug as string, projectId as string, {
prompt: content && content !== "" ? content : htmlContent ?? "",
task: formData.task,
})
.then((res) => {
setResponse(res.response_html);
setFocus("task");
if (res.response === "") setInvalidResponse(true);
else setInvalidResponse(false);
})
.catch((err) => {
if (err.status === 429)
setToastAlert({
type: "error",
title: "Error!",
message:
"You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Some error occurred. Please try again.",
});
});
};
useEffect(() => {
if (isOpen) setFocus("task");
}, [isOpen, setFocus]);
return (
<div
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border bg-white p-4 shadow ${
isOpen ? "block" : "hidden"
}`}
>
{((content && content !== "") || htmlContent !== "<p></p>") && (
<div className="remirror-section text-sm">
Content:
<RemirrorRichTextEditor
value={htmlContent ?? <p>{content}</p>}
customClassName="-m-3"
noBorder
borderOnFocus={false}
editable={false}
/>
</div>
)}
{response !== "" && (
<div className="text-sm page-block-section">
Response:
<RemirrorRichTextEditor
value={`<p>${response}</p>`}
customClassName="-mx-3 -my-3"
noBorder
borderOnFocus={false}
editable={false}
/>
</div>
)}
{invalidResponse && (
<div className="text-sm text-red-500">
No response could be generated. This may be due to insufficient content or task
information. Please try again.
</div>
)}
<Input
type="text"
name="task"
register={register}
placeholder={`${
content && content !== ""
? "Tell AI what action to perform on this content..."
: "Ask AI anything..."
}`}
autoComplete="off"
/>
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
{response !== "" && (
<PrimaryButton
onClick={() => {
onResponse(response);
onClose();
if (block)
trackEventServices.trackUseGPTResponseEvent(
block,
"USE_GPT_RESPONSE_IN_PAGE_BLOCK"
);
else if (issue)
trackEventServices.trackUseGPTResponseEvent(issue, "USE_GPT_RESPONSE_IN_ISSUE");
}}
>
Use this response
</PrimaryButton>
)}
<div className="flex items-center gap-2">
<SecondaryButton onClick={onClose}>Close</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleSubmit(handleResponse)}
loading={isSubmitting}
>
{isSubmitting
? "Generating response..."
: response === ""
? "Generate response"
: "Generate again"}
</PrimaryButton>
</div>
</div>
</div>
);
};
@@ -13,8 +13,7 @@ import { Tab, Transition, Popover } from "@headlessui/react";
import fileService from "services/file.service";
// components
import { Input, Spinner } from "components/ui";
import { PrimaryButton } from "components/ui/button/primary-button";
import { Input, Spinner, PrimaryButton } from "components/ui";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
@@ -108,7 +107,7 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
placeholder="Search for images"
/>
<PrimaryButton className="bg-indigo-600" size="sm">
<PrimaryButton type="submit" className="bg-indigo-600" size="sm">
Search
</PrimaryButton>
</form>
+62 -35
View File
@@ -3,27 +3,32 @@ import React, { useCallback, useState } from "react";
import NextImage from "next/image";
import { useRouter } from "next/router";
// react-dropzone
import { useDropzone } from "react-dropzone";
// headless ui
import { Transition, Dialog } from "@headlessui/react";
// services
import fileServices from "services/file.service";
// icon
import { UserCircleIcon } from "components/icons";
// ui
import { Button } from "components/ui";
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { UserCircleIcon } from "components/icons";
type TImageUploadModalProps = {
type Props = {
value?: string | null;
onClose: () => void;
isOpen: boolean;
onSuccess: (url: string) => void;
userImage?: boolean;
};
export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
const { value, onSuccess, isOpen, onClose } = props;
export const ImageUploadModal: React.FC<Props> = ({
value,
onSuccess,
isOpen,
onClose,
userImage,
}) => {
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
@@ -34,37 +39,62 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
setImage(acceptedFiles[0]);
}, []);
const {
getRootProps,
getInputProps,
isDragActive,
open: openFileDialog,
} = useDropzone({
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
});
const handleSubmit = async () => {
setIsImageUploading(true);
if (image === null || !workspaceSlug) return;
if (!image || !workspaceSlug) return;
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
fileServices
.uploadFile(workspaceSlug as string, formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setIsImageUploading(false);
})
.catch((err) => {
console.error(err);
});
if (userImage) {
fileServices
.uploadUserFile(formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setIsImageUploading(false);
setImage(null);
if (value) {
const index = value.indexOf(".com");
const asset = value.substring(index + 5);
fileServices.deleteUserFile(asset);
}
})
.catch((err) => {
console.error(err);
});
} else
fileServices
.uploadFile(workspaceSlug as string, formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setIsImageUploading(false);
setImage(null);
if (value) {
const index = value.indexOf(".com");
const asset = value.substring(index + 5);
fileServices.deleteFile(asset);
}
})
.catch((err) => {
console.error(err);
});
};
const handleClose = () => {
setImage(null);
onClose();
};
@@ -109,11 +139,10 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
: ""
}`}
>
{image !== null || (value && value !== null && value !== "") ? (
{image !== null || (value && value !== "") ? (
<>
<button
type="button"
onClick={openFileDialog}
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
>
Edit
@@ -142,16 +171,14 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button
type="submit"
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton
onClick={handleSubmit}
disabled={isImageUploading || image === null}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</Button>
</PrimaryButton>
</div>
</Dialog.Panel>
</Transition.Child>
+3 -1
View File
@@ -3,10 +3,12 @@ export * from "./list-view";
export * from "./sidebar";
export * from "./bulk-delete-issues-modal";
export * from "./existing-issues-list-modal";
export * from "./gpt-assistant-modal";
export * from "./image-upload-modal";
export * from "./issues-view-filter";
export * from "./issues-view";
export * from "./link-modal";
export * from "./not-authorized-view";
export * from "./multi-level-select";
export * from "./image-picker-popover";
export * from "./filter-list";
export * from "./feeds";
+222 -180
View File
@@ -4,42 +4,41 @@ import { useRouter } from "next/router";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
import useIssueView from "hooks/use-issue-view";
import useIssuesView from "hooks/use-issues-view";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// components
import { SelectFilters } from "components/views";
// ui
import { CustomMenu } from "components/ui";
// icons
import { ChevronDownIcon, ListBulletIcon } from "@heroicons/react/24/outline";
import { ChevronDownIcon, ListBulletIcon, CalendarDaysIcon } from "@heroicons/react/24/outline";
import { Squares2X2Icon } from "@heroicons/react/20/solid";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { IIssue, Properties } from "types";
// common
import { Properties } from "types";
// constants
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
type Props = {
issues?: IIssue[];
};
export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
export const IssuesFilterView: React.FC = () => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, projectId, viewId } = router.query;
const {
issueView,
setIssueViewToList,
setIssueViewToKanban,
setIssueView,
groupByProperty,
setGroupByProperty,
setOrderBy,
setFilterIssue,
orderBy,
filterIssue,
setOrderBy,
showEmptyGroups,
setShowEmptyGroups,
filters,
setFilters,
resetFilterToDefault,
setNewFilterDefaultView,
} = useIssueView(issues ?? []);
} = useIssuesView();
const [properties, setProperties] = useIssuesProperties(
workspaceSlug as string,
@@ -47,172 +46,215 @@ export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
);
return (
<>
{issues && issues.length > 0 && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
issueView === "list" ? "bg-gray-200" : ""
}`}
onClick={() => setIssueViewToList()}
>
<ListBulletIcon className="h-4 w-4" />
</button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
issueView === "kanban" ? "bg-gray-200" : ""
}`}
onClick={() => setIssueViewToKanban()}
>
<Squares2X2Icon className="h-4 w-4" />
</button>
</div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border bg-transparent p-2 text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none ${
open ? "bg-gray-100 text-gray-900" : "text-gray-500"
}`}
>
<span>View</span>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</Popover.Button>
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
issueView === "list" ? "bg-gray-200" : ""
}`}
onClick={() => setIssueView("list")}
>
<ListBulletIcon className="h-4 w-4" />
</button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
issueView === "kanban" ? "bg-gray-200" : ""
}`}
onClick={() => setIssueView("kanban")}
>
<Squares2X2Icon className="h-4 w-4" />
</button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
issueView === "calendar" ? "bg-gray-200" : ""
}`}
onClick={() => setIssueView("calendar")}
>
<CalendarDaysIcon className="h-4 w-4" />
</button>
</div>
<SelectFilters
filters={filters}
onSelect={(option) => {
const key = option.key as keyof typeof filters;
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg bg-white p-3 shadow-lg">
<div className="relative divide-y-2">
{issues && (
<div className="space-y-4 pb-3">
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu
label={
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
width="lg"
>
{GROUP_BY_OPTIONS.map((option) =>
issueView === "kanban" && option.key === null ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
width="lg"
>
{ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" &&
option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filterIssue)
?.name ?? "Select"
}
width="lg"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setFilterIssue(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="relative flex justify-end gap-x-3">
<button
type="button"
className="text-xs"
onClick={() => resetFilterToDefault()}
>
Reset to default
</button>
<button
type="button"
className="text-xs font-medium text-theme"
onClick={() => setNewFilterDefaultView()}
>
Set as default
</button>
</div>
</div>
)}
<div className="space-y-2 py-3">
<h4 className="text-sm text-gray-600">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => {
if (
issueView === "kanban" &&
((groupByProperty === "state_detail.name" && key === "state") ||
(groupByProperty === "priority" && key === "priority"))
)
return;
const valueExists = filters[key]?.includes(option.value);
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: "border-gray-300"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
</div>
if (valueExists) {
setFilters(
{
...(filters ?? {}),
[option.key]: ((filters[key] ?? []) as any[])?.filter(
(val) => val !== option.value
),
},
!Boolean(viewId)
);
} else {
setFilters(
{
...(filters ?? {}),
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
},
!Boolean(viewId)
);
}
}}
direction="left"
height="rg"
/>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border bg-transparent px-3 py-1.5 text-xs hover:bg-gray-100 hover:text-gray-900 focus:outline-none ${
open ? "bg-gray-100 text-gray-900" : "text-gray-500"
}`}
>
View
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg bg-white p-3 shadow-lg">
<div className="relative divide-y-2">
<div className="space-y-4 pb-3 text-xs">
<div className="flex items-center justify-between">
<h4 className="text-gray-600">Group by</h4>
<CustomMenu
label={
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)?.name ??
"Select"
}
width="lg"
>
{GROUP_BY_OPTIONS.map((option) =>
issueView === "kanban" && option.key === null ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
)}
</>
<div className="flex items-center justify-between">
<h4 className="text-gray-600">Order by</h4>
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
width="lg"
>
{ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-gray-600">Issue type</h4>
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type)
?.name ?? "Select"
}
width="lg"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-gray-600">Show empty states</h4>
<button
type="button"
className={`relative inline-flex h-3.5 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
showEmptyGroups ? "bg-green-500" : "bg-gray-200"
}`}
role="switch"
aria-checked={showEmptyGroups}
onClick={() => setShowEmptyGroups(!showEmptyGroups)}
>
<span className="sr-only">Show empty groups</span>
<span
aria-hidden="true"
className={`inline-block h-2.5 w-2.5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
showEmptyGroups ? "translate-x-2.5" : "translate-x-0"
}`}
/>
</button>
</div>
<div className="relative flex justify-end gap-x-3">
<button type="button" onClick={() => resetFilterToDefault()}>
Reset to default
</button>
<button
type="button"
className="font-medium text-theme"
onClick={() => setNewFilterDefaultView()}
>
Set as default
</button>
</div>
</div>
<div className="space-y-2 py-3">
<h4 className="text-sm text-gray-600">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: "border-gray-300"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
};
+278 -170
View File
@@ -9,49 +9,62 @@ import { DragDropContext, DropResult } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
import projectService from "services/project.service";
import modulesService from "services/modules.service";
// hooks
import useIssueView from "hooks/use-issue-view";
import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
// components
import { AllLists, AllBoards } from "components/core";
import { AllLists, AllBoards, FilterList } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CreateUpdateViewModal } from "components/views";
import { TransferIssues, TransferIssuesModal } from "components/cycles";
// ui
import { EmptySpace, EmptySpaceItem, PrimaryButton, Spinner } from "components/ui";
import { CalendarView } from "./calendar-view";
// icons
import { TrashIcon } from "@heroicons/react/24/outline";
import {
ListBulletIcon,
PlusIcon,
RectangleStackIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { ExclamationIcon, TransferIcon } from "components/icons";
// helpers
import { getStatesList } from "helpers/state.helper";
// types
import { CycleIssueResponse, IIssue, ModuleIssueResponse, UserAuth } from "types";
import { IIssue, IIssueFilterOptions, UserAuth } from "types";
// fetch-keys
import {
CYCLE_ISSUES,
MODULE_ISSUES,
PROJECT_ISSUES_LIST,
PROJECT_MEMBERS,
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
STATE_LIST,
} from "constants/fetch-keys";
type Props = {
type?: "issue" | "cycle" | "module";
issues: IIssue[];
openIssuesListModal?: () => void;
isCompleted?: boolean;
userAuth: UserAuth;
};
export const IssuesView: React.FC<Props> = ({
type = "issue",
issues,
openIssuesListModal,
isCompleted = false,
userAuth,
}) => {
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [createViewModal, setCreateViewModal] = useState<any>(null);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
// updates issue modal
// update issue modal
const [editIssueModal, setEditIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<
(IIssue & { actionType: "edit" | "delete" }) | undefined
@@ -64,15 +77,24 @@ export const IssuesView: React.FC<Props> = ({
// trash box
const [trashBox, setTrashBox] = useState(false);
// transfer issue
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { setToastAlert } = useToast();
const {
issueView,
groupedByIssues,
issueView,
groupByProperty: selectedGroup,
orderBy,
} = useIssueView(issues);
filters,
isNotEmpty,
setFilters,
params,
} = useIssuesView();
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
@@ -82,13 +104,6 @@ export const IssuesView: React.FC<Props> = ({
);
const states = getStatesList(stateGroups ?? {});
const { data: members } = useSWR(
projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const handleDeleteIssue = useCallback(
(issue: IIssue) => {
setDeleteIssueModal(true);
@@ -101,7 +116,7 @@ export const IssuesView: React.FC<Props> = ({
(result: DropResult) => {
setTrashBox(false);
if (!result.destination || !workspaceSlug || !projectId) return;
if (!result.destination || !workspaceSlug || !projectId || !groupedByIssues) return;
const { source, destination } = result;
@@ -156,90 +171,105 @@ export const IssuesView: React.FC<Props> = ({
draggedItem.sort_order = newSortOrder;
}
if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) {
const sourceGroup = source.droppableId; // source group id
const destinationGroup = destination.droppableId; // destination group id
const destinationGroup = destination.droppableId; // destination group id
if (!sourceGroup || !destinationGroup) return;
if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) {
// different group/column;
// source.droppableId !== destination.droppableId -> even if order by is not sort_order,
// if the issue is moved to a different group, then we will change the group of the
// dragged item(or issue)
if (selectedGroup === "priority") draggedItem.priority = destinationGroup;
else if (selectedGroup === "state_detail.name") {
const destinationState = states?.find((s) => s.name === destinationGroup);
else if (selectedGroup === "state") draggedItem.state = destinationGroup;
}
if (!destinationState) return;
const sourceGroup = source.droppableId; // source group id
draggedItem.state = destinationState.id;
draggedItem.state_detail = destinationState;
}
if (cycleId)
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId as string),
(prevData) => {
if (!prevData) return prevData;
const updatedIssues = prevData.map((issue) => {
if (issue.issue_detail.id === draggedItem.id) {
return {
...issue,
issue_detail: draggedItem,
};
}
return issue;
});
return [...updatedIssues];
},
false
);
if (moduleId)
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId as string),
(prevData) => {
if (!prevData) return prevData;
const updatedIssues = prevData.map((issue) => {
if (issue.issue_detail.id === draggedItem.id) {
return {
...issue,
issue_detail: draggedItem,
};
}
return issue;
});
return [...updatedIssues];
},
false
);
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
// TODO: move this mutation logic to a separate function
if (cycleId)
mutate<{
[key: string]: IIssue[];
}>(
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
(prevData) => {
if (!prevData) return prevData;
const updatedIssues = prevData.map((i) => {
if (i.id === draggedItem.id) return draggedItem;
const sourceGroupArray = prevData[sourceGroup];
const destinationGroupArray = groupedByIssues[destinationGroup];
return i;
});
sourceGroupArray.splice(source.index, 1);
destinationGroupArray.splice(destination.index, 0, draggedItem);
return updatedIssues;
return {
...prevData,
[sourceGroup]: sourceGroupArray,
[destinationGroup]: destinationGroupArray,
};
},
false
);
else if (moduleId)
mutate<{
[key: string]: IIssue[];
}>(
MODULE_ISSUES_WITH_PARAMS(moduleId as string, params),
(prevData) => {
if (!prevData) return prevData;
const sourceGroupArray = prevData[sourceGroup];
const destinationGroupArray = groupedByIssues[destinationGroup];
sourceGroupArray.splice(source.index, 1);
destinationGroupArray.splice(destination.index, 0, draggedItem);
return {
...prevData,
[sourceGroup]: sourceGroupArray,
[destinationGroup]: destinationGroupArray,
};
},
false
);
else
mutate<{ [key: string]: IIssue[] }>(
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
(prevData) => {
if (!prevData) return prevData;
const sourceGroupArray = prevData[sourceGroup];
const destinationGroupArray = groupedByIssues[destinationGroup];
sourceGroupArray.splice(source.index, 1);
destinationGroupArray.splice(destination.index, 0, draggedItem);
return {
...prevData,
[sourceGroup]: sourceGroupArray,
[destinationGroup]: destinationGroupArray,
};
},
false
);
// patch request
issuesService
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
priority: draggedItem.priority,
state: draggedItem.state,
sort_order: draggedItem.sort_order,
})
.then((res) => {
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
});
}
// patch request
issuesService
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
priority: draggedItem.priority,
state: draggedItem.state,
sort_order: draggedItem.sort_order,
})
.then(() => {
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));
}
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
});
}
},
[
@@ -250,17 +280,16 @@ export const IssuesView: React.FC<Props> = ({
projectId,
selectedGroup,
orderBy,
states,
handleDeleteIssue,
params,
]
);
const addIssueToState = useCallback(
(groupTitle: string, stateId: string | null) => {
(groupTitle: string) => {
setCreateIssueModal(true);
if (selectedGroup)
setPreloadedData({
state: stateId ?? undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
@@ -293,13 +322,9 @@ export const IssuesView: React.FC<Props> = ({
const removeIssueFromCycle = useCallback(
(bridgeId: string) => {
if (!workspaceSlug || !projectId) return;
if (!workspaceSlug || !projectId || !cycleId) return;
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId as string),
(prevData) => prevData?.filter((p) => p.id !== bridgeId),
false
);
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
issuesService
.removeIssueFromCycle(
@@ -315,18 +340,14 @@ export const IssuesView: React.FC<Props> = ({
console.log(e);
});
},
[workspaceSlug, projectId, cycleId]
[workspaceSlug, projectId, cycleId, params]
);
const removeIssueFromModule = useCallback(
(bridgeId: string) => {
if (!workspaceSlug || !projectId) return;
if (!workspaceSlug || !projectId || !moduleId) return;
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId as string),
(prevData) => prevData?.filter((p) => p.id !== bridgeId),
false
);
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
modulesService
.removeIssueFromModule(
@@ -342,7 +363,7 @@ export const IssuesView: React.FC<Props> = ({
console.log(e);
});
},
[workspaceSlug, projectId, moduleId]
[workspaceSlug, projectId, moduleId, params]
);
const handleTrashBox = useCallback(
@@ -352,8 +373,17 @@ export const IssuesView: React.FC<Props> = ({
[trashBox, setTrashBox]
);
const nullFilters = Object.keys(filters).filter(
(key) => filters[key as keyof IIssueFilterOptions] === null
);
return (
<>
<CreateUpdateViewModal
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
preLoadedData={createViewModal}
/>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
@@ -372,69 +402,147 @@ export const IssuesView: React.FC<Props> = ({
isOpen={deleteIssueModal}
data={issueToDelete}
/>
<div className="relative">
<DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox">
{(provided, snapshot) => (
<div
className={`${
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
} fixed top-9 right-9 z-20 flex h-28 w-96 items-center justify-center gap-2 rounded border-2 border-red-500 bg-red-100 p-3 text-xs font-medium italic text-red-500 ${
snapshot.isDraggingOver ? "bg-red-500 text-white" : ""
} duration-200`}
ref={provided.innerRef}
{...provided.droppableProps}
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
isOpen={transferIssuesModal}
/>
<div className="mb-5 -mt-4">
<div className="flex items-center justify-between gap-2">
<FilterList filters={filters} setFilters={setFilters} />
{Object.keys(filters).length > 0 &&
nullFilters.length !== Object.keys(filters).length && (
<PrimaryButton
onClick={() => {
if (viewId) {
setFilters({}, true);
setToastAlert({
title: "View updated",
message: "Your view has been updated",
type: "success",
});
} else
setCreateViewModal({
query: filters,
});
}}
className="flex items-center gap-2 text-sm"
>
<TrashIcon className="h-4 w-4" />
Drop issue here to delete
</div>
{!viewId && <PlusIcon className="h-4 w-4" />}
{viewId ? "Update" : "Save"} view
</PrimaryButton>
)}
</StrictModeDroppable>
{issueView === "list" ? (
<AllLists
type={type}
issues={issues}
states={states}
members={members}
addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
userAuth={userAuth}
/>
) : (
<AllBoards
type={type}
issues={issues}
states={states}
members={members}
addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
handleDeleteIssue={handleDeleteIssue}
handleTrashBox={handleTrashBox}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
userAuth={userAuth}
/>
)}
</DragDropContext>
</div>
</div>
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
<div className="mb-5 border-t" />
)}
<DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox">
{(provided, snapshot) => (
<div
className={`${
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
} fixed top-9 right-9 z-20 flex h-28 w-96 flex-col items-center justify-center gap-2 rounded border-2 border-red-500 bg-red-100 p-3 text-xs font-medium italic text-red-500 ${
snapshot.isDraggingOver ? "bg-red-500 text-white" : ""
} duration-200`}
ref={provided.innerRef}
{...provided.droppableProps}
>
<TrashIcon className="h-4 w-4" />
Drop issue here to delete
{provided.placeholder}
</div>
)}
</StrictModeDroppable>
{groupedByIssues ? (
isNotEmpty ? (
<>
{isCompleted && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
{issueView === "list" ? (
<AllLists
type={type}
states={states}
addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
isCompleted={isCompleted}
userAuth={userAuth}
/>
) : issueView === "kanban" ? (
<AllBoards
type={type}
states={states}
addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
handleDeleteIssue={handleDeleteIssue}
handleTrashBox={handleTrashBox}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
isCompleted={isCompleted}
userAuth={userAuth}
/>
) : (
<CalendarView />
)}
</>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<EmptySpace
title="You don't have any issue yet."
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
Icon={RectangleStackIcon}
>
<EmptySpaceItem
title="Create a new issue"
description={
<span>
Use <pre className="inline rounded bg-gray-200 px-2 py-1">C</pre> shortcut to
create a new issue
</span>
}
Icon={PlusIcon}
action={() => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
}}
/>
{openIssuesListModal && (
<EmptySpaceItem
title="Add an existing issue"
description="Open list"
Icon={ListBulletIcon}
action={openIssuesListModal}
/>
)}
</EmptySpace>
</div>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</DragDropContext>
</>
);
};
+4 -10
View File
@@ -1,15 +1,11 @@
import React from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button, Input } from "components/ui";
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// types
import type { IIssueLink, ModuleLink } from "types";
@@ -116,12 +112,10 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button theme="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Adding Link..." : "Add Link"}
</Button>
</PrimaryButton>
</div>
</form>
</Dialog.Panel>
@@ -1,66 +1,69 @@
// hooks
import useIssueView from "hooks/use-issue-view";
import useIssuesView from "hooks/use-issues-view";
// components
import { SingleList } from "components/core/list-view/single-list";
// types
import { IIssue, IProjectMember, IState, UserAuth } from "types";
import { IIssue, IState, UserAuth } from "types";
// types
type Props = {
type: "issue" | "cycle" | "module";
issues: IIssue[];
states: IState[] | undefined;
members: IProjectMember[] | undefined;
addIssueToState: (groupTitle: string, stateId: string | null) => void;
addIssueToState: (groupTitle: string) => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string) => void) | null;
isCompleted?: boolean;
userAuth: UserAuth;
};
export const AllLists: React.FC<Props> = ({
type,
issues,
states,
members,
addIssueToState,
makeIssueCopy,
openIssuesListModal,
handleEditIssue,
handleDeleteIssue,
removeIssue,
isCompleted = false,
userAuth,
}) => {
const { groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
const { groupedByIssues, groupByProperty: selectedGroup, showEmptyGroups } = useIssuesView();
return (
<div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => {
const stateId =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null;
<>
{groupedByIssues && (
<div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
return (
<SingleList
key={singleGroup}
type={type}
groupTitle={singleGroup}
groupedByIssues={groupedByIssues}
selectedGroup={selectedGroup}
members={members}
addIssueToState={() => addIssueToState(singleGroup, stateId)}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={removeIssue}
userAuth={userAuth}
/>
);
})}
</div>
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null;
return (
<SingleList
key={singleGroup}
type={type}
groupTitle={singleGroup}
groupedByIssues={groupedByIssues}
selectedGroup={selectedGroup}
currentState={currentState}
addIssueToState={() => addIssueToState(singleGroup)}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={removeIssue}
isCompleted={isCompleted}
userAuth={userAuth}
/>
);
})}
</div>
)}
</>
);
};
@@ -16,7 +16,8 @@ import {
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues/view-select";
// hooks
import useIssueView from "hooks/use-issues-view";
// ui
import { Tooltip, CustomMenu, ContextMenu } from "components/ui";
// icons
@@ -25,22 +26,35 @@ import {
LinkIcon,
PencilIcon,
TrashIcon,
XMarkIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/react/24/outline";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { handleIssuesMutation } from "constants/issue";
// types
import { CycleIssueResponse, IIssue, ModuleIssueResponse, Properties, UserAuth } from "types";
import { IIssue, Properties, UserAuth } from "types";
// fetch-keys
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
} from "constants/fetch-keys";
import { DIVIDER } from "@blueprintjs/core/lib/esm/common/classes";
type Props = {
type?: string;
issue: IIssue;
properties: Properties;
groupTitle?: string;
editIssue: () => void;
index: number;
makeIssueCopy: () => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
isCompleted?: boolean;
userAuth: UserAuth;
};
@@ -49,9 +63,12 @@ export const SingleListIssue: React.FC<Props> = ({
issue,
properties,
editIssue,
index,
makeIssueCopy,
removeIssue,
groupTitle,
handleDeleteIssue,
isCompleted = false,
userAuth,
}) => {
// context menu
@@ -63,80 +80,63 @@ export const SingleListIssue: React.FC<Props> = ({
const { setToastAlert } = useToast();
const { groupByProperty: selectedGroup, params } = useIssueView();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
if (cycleId)
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId as string),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
},
};
}
return p;
});
return [...updatedIssues];
},
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
(prevData) =>
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
false
);
if (moduleId)
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId as string),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
},
};
}
return p;
});
return [...updatedIssues];
},
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
MODULE_ISSUES_WITH_PARAMS(moduleId as string, params),
(prevData) =>
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
false
);
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === issue.id)
return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list };
return p;
}),
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
false
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.then((res) => {
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
})
.catch((error) => {
console.log(error);
.then(() => {
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
mutate(CYCLE_DETAILS(cycleId as string));
} 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 as string, params));
});
},
[workspaceSlug, projectId, cycleId, moduleId, issue]
[workspaceSlug, projectId, cycleId, moduleId, issue, groupTitle, index, selectedGroup, params]
);
const handleCopyText = () => {
@@ -153,7 +153,7 @@ export const SingleListIssue: React.FC<Props> = ({
});
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
return (
<>
@@ -163,123 +163,147 @@ export const SingleListIssue: React.FC<Props> = ({
isOpen={contextMenu}
setIsOpen={setContextMenu}
>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
Edit issue
</ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
Make a copy...
</ContextMenu.Item>
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
Delete issue
</ContextMenu.Item>
{!isNotAllowed && (
<>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
Edit issue
</ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
Make a copy...
</ContextMenu.Item>
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
Delete issue
</ContextMenu.Item>
</>
)}
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</ContextMenu.Item>
<a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item>
</a>
</ContextMenu>
<div
className="flex items-center justify-between gap-2 px-4 py-3 text-sm"
onContextMenu={(e) => {
e.preventDefault();
setContextMenu(true);
setContextMenuPosition({ x: e.pageX, y: e.pageY });
}}
>
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<div className="border-b border-gray-300 last:border-b-0">
<div
className="flex items-center justify-between gap-2 px-4 py-3"
onContextMenu={(e) => {
e.preventDefault();
setContextMenu(true);
setContextMenuPosition({ x: e.pageX, y: e.pageY });
}}
>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<Tooltip
tooltipHeading="ID"
tooltipHeading="Issue ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-gray-500">
<span className="flex-shrink-0 text-xs text-gray-400">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
)}
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
<span className="w-auto max-w-lg overflow-hidden text-ellipsis whitespace-nowrap">
{issue.name}
</span>
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-sm text-gray-800">{truncateText(issue.name, 50)}</span>
</Tooltip>
</a>
</Link>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
isNotAllowed={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-xs shadow-sm">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.labels && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
>
<div className="flex flex-wrap items-center gap-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
isNotAllowed={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs shadow-sm">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.labels && issue.label_details.length > 0 ? (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
isNotAllowed={isNotAllowed}
/>
)}
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
) : (
""
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
isNotAllowed={isNotAllowed}
/>
)}
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit issue</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
Delete issue
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
</CustomMenu>
)}
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<div className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" />
<span>Remove from {type}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue link</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
</div>
</>
@@ -1,48 +1,61 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// services
import issuesService from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
// components
import { SingleListIssue } from "components/core";
// ui
import { Avatar, CustomMenu } from "components/ui";
// icons
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/24/outline";
import { PlusIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IProjectMember, NestedKeyOf, UserAuth } from "types";
import { CustomMenu } from "components/ui";
import { IIssue, IIssueLabels, IState, TIssueGroupByOptions, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
type?: "issue" | "cycle" | "module";
currentState?: IState | null;
bgColor?: string;
groupTitle: string;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
members: IProjectMember[] | undefined;
selectedGroup: TIssueGroupByOptions;
addIssueToState: () => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string) => void) | null;
isCompleted?: boolean;
userAuth: UserAuth;
};
export const SingleList: React.FC<Props> = ({
type,
currentState,
bgColor,
groupTitle,
groupedByIssues,
selectedGroup,
members,
addIssueToState,
makeIssueCopy,
handleEditIssue,
handleDeleteIssue,
openIssuesListModal,
removeIssue,
isCompleted = false,
userAuth,
}) => {
const router = useRouter();
@@ -50,107 +63,116 @@ export const SingleList: React.FC<Props> = ({
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const createdBy =
selectedGroup === "created_by"
? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "Loading..."
: null;
const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
let assignees: any;
if (selectedGroup === "assignees") {
assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : [];
assignees =
assignees.length > 0
? assignees
.map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name)
.join(", ")
: "No assignee";
}
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle);
switch (selectedGroup) {
case "state":
title = addSpaceIfCamelCase(currentState?.name ?? "");
break;
case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
break;
}
return title;
};
const getGroupIcon = () => {
let icon;
switch (selectedGroup) {
case "state":
icon = currentState && getStateGroupIcon(currentState.group, "18", "18", bgColor);
break;
case "priority":
icon = getPriorityIcon(groupTitle, "h-[18px] w-[18px] flex items-center");
break;
case "labels":
const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
icon = (
<span
className="h-[18px] w-[18px] flex-shrink-0 rounded-full"
style={{ backgroundColor: labelColor }}
/>
);
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
icon = <Avatar user={member} height="24px" width="24px" fontSize="12px" />;
break;
}
return icon;
};
return (
<Disclosure key={groupTitle} as="div" defaultOpen>
{({ open }) => (
<div className="rounded-lg bg-white">
<div className="rounded-t-lg bg-gray-100 px-4 py-3">
<div className="rounded-[10px] border border-gray-300 bg-white">
<div
className={`flex items-center justify-between bg-gray-100 px-5 py-3 ${
open ? "rounded-t-[10px]" : "rounded-[10px]"
}`}
>
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
/>
</span>
<div className="flex items-center gap-x-3">
{selectedGroup !== null && (
<span className="flex items-center">{getGroupIcon()}</span>
)}
{selectedGroup !== null ? (
<h2 className="font-medium capitalize leading-5">
{selectedGroup === "created_by"
? createdBy
: selectedGroup === "assignees"
? assignees
: addSpaceIfCamelCase(groupTitle)}
<h2 className="text-base font-semibold capitalize leading-6 text-gray-800">
{getGroupTitle()}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<p className="text-sm text-gray-500">
<span className="rounded-full bg-gray-200 py-0.5 px-3 text-sm text-black">
{groupedByIssues[groupTitle as keyof IIssue].length}
</p>
</span>
</div>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="divide-y-2">
{groupedByIssues[groupTitle] ? (
groupedByIssues[groupTitle].length > 0 ? (
groupedByIssues[groupTitle].map((issue: IIssue) => (
<SingleListIssue
key={issue.id}
type={type}
issue={issue}
properties={properties}
editIssue={() => handleEditIssue(issue)}
makeIssueCopy={() => makeIssueCopy(issue)}
handleDeleteIssue={handleDeleteIssue}
removeIssue={() => {
removeIssue && removeIssue(issue.bridge);
}}
userAuth={userAuth}
/>
))
) : (
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
</div>
</Disclosure.Panel>
</Transition>
<div className="p-3">
{type === "issue" ? (
<button
type="button"
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100"
className="p-1 text-gray-500 hover:bg-gray-100"
onClick={addIssueToState}
>
<PlusIcon className="h-3 w-3" />
Add issue
<PlusIcon className="h-4 w-4" />
</button>
) : isCompleted ? (
""
) : (
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
customButton={
<div className="flex items-center cursor-pointer">
<PlusIcon className="h-4 w-4" />
</div>
}
optionsPosition="left"
optionsPosition="right"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
@@ -162,6 +184,44 @@ export const SingleList: React.FC<Props> = ({
</CustomMenu>
)}
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
{groupedByIssues[groupTitle] ? (
groupedByIssues[groupTitle].length > 0 ? (
groupedByIssues[groupTitle].map((issue, index) => (
<SingleListIssue
key={issue.id}
type={type}
issue={issue}
properties={properties}
groupTitle={groupTitle}
index={index}
editIssue={() => handleEditIssue(issue)}
makeIssueCopy={() => makeIssueCopy(issue)}
handleDeleteIssue={handleDeleteIssue}
removeIssue={() => {
if (removeIssue !== null && issue.bridge_id) removeIssue(issue.bridge_id);
}}
isCompleted={isCompleted}
userAuth={userAuth}
/>
))
) : (
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
@@ -1,13 +1,14 @@
import React from "react";
// next
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// layouts
import DefaultLayout from "layouts/default-layout";
// hooks
import useUser from "hooks/use-user";
// icons
import { LockIcon } from "components/icons";
// img
import ProjectSettingImg from "public/project-setting.svg";
type TNotAuthorizedViewProps = {
actionButton?: React.ReactNode;
@@ -27,25 +28,27 @@ export const NotAuthorizedView: React.FC<TNotAuthorizedViewProps> = (props) => {
}}
>
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
<LockIcon className="h-16 w-16 text-gray-400" />
<div className="h-44 w-72">
<Image src={ProjectSettingImg} height="176" width="288" alt="ProjectSettingImg" />
</div>
<h1 className="text-xl font-medium text-gray-900">
Oops! You are not authorized to view this page
</h1>
<div className="w-full md:w-1/3">
<div className="w-full text-base text-gray-500 max-w-md ">
{user ? (
<p className="text-base font-light">
You have signed in as <span className="font-medium">{user.email}</span>.{" "}
<p className="">
You have signed in as {user.email}.{" "}
<Link href={`/signin?next=${currentPath}`}>
<a className="font-medium">Sign in</a>
<a className="text-gray-900 font-medium">Sign in</a>
</Link>{" "}
with different account that has access to this page.
</p>
) : (
<p className="text-base font-light">
<p className="">
You need to{" "}
<Link href={`/signin?next=${currentPath}`}>
<a className="font-medium">Sign in</a>
<a className="text-gray-900 font-medium">Sign in</a>
</Link>{" "}
with an account that has access to this page.
</p>
@@ -54,11 +54,14 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5 className="w-4/5">{link.title}</h5>
<h5 className="w-4/5 break-all">{link.title}</h5>
<p className="mt-0.5 text-gray-500">
Added {timeAgo(link.created_at)}
<br />
by {link.created_by_detail.email}
by{" "}
{link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.email}
</p>
</div>
</a>
@@ -39,7 +39,6 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
const CustomTooltip = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
if (active && payload && payload.length) {
console.log(payload[0].payload.currentDate);
return (
<div className="rounded-sm bg-gray-300 p-1 text-xs text-gray-800">
<p>{payload[0].payload.currentDate}</p>
@@ -81,6 +80,7 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
tick={{ fontSize: "12px", fill: "#1f2937" }}
tickSize={10}
minTickGap={10}
allowDecimals={false}
/>
<Tooltip content={<CustomTooltip />} />
<Area
@@ -12,13 +12,13 @@ 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";
// components
import { LinksList, SingleProgressStats } from "components/core";
import { SingleProgressStats } from "components/core";
// ui
import { Avatar } from "components/ui";
// icons
import User from "public/user.png";
import { PlusIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IIssueLabels, IModule, UserAuth } from "types";
// fetch-keys
@@ -28,8 +28,6 @@ type Props = {
groupedIssues: any;
issues: IIssue[];
module?: IModule;
setModuleLinkModal?: any;
handleDeleteLink?: any;
userAuth?: UserAuth;
};
@@ -47,13 +45,13 @@ export const SidebarProgressStats: React.FC<Props> = ({
groupedIssues,
issues,
module,
setModuleLinkModal,
handleDeleteLink,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { filters, setFilters } = useIssuesView();
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
const { data: issueLabels } = useSWR<IIssueLabels[]>(
@@ -72,14 +70,12 @@ export const SidebarProgressStats: React.FC<Props> = ({
const currentValue = (tab: string | null) => {
switch (tab) {
case "Links":
return 0;
case "Assignees":
return 1;
return 0;
case "Labels":
return 2;
return 1;
case "States":
return 3;
return 2;
default:
return 3;
@@ -91,12 +87,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
onChange={(i) => {
switch (i) {
case 0:
return setTab("Links");
case 1:
return setTab("Assignees");
case 2:
case 1:
return setTab("Labels");
case 3:
case 2:
return setTab("States");
default:
@@ -109,20 +103,6 @@ export const SidebarProgressStats: React.FC<Props> = ({
className={`flex w-full items-center justify-between rounded-md bg-gray-100 px-1 py-1.5
${module ? "text-xs" : "text-sm"} `}
>
{module ? (
<Tab
className={({ selected }) =>
`w-full rounded px-3 py-1 text-gray-900 ${
selected ? " bg-theme text-white" : " hover:bg-hover-gray"
}`
}
>
Links
</Tab>
) : (
""
)}
<Tab
className={({ selected }) =>
`w-full rounded px-3 py-1 text-gray-900 ${
@@ -134,7 +114,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab>
<Tab
className={({ selected }) =>
`w-full rounded px-3 py-1 text-gray-900 ${
`w-full rounded px-3 py-1 text-gray-900 ${
selected ? " bg-theme text-white" : " hover:bg-hover-gray"
}`
}
@@ -151,34 +131,12 @@ export const SidebarProgressStats: React.FC<Props> = ({
States
</Tab>
</Tab.List>
<Tab.Panels className="flex w-full items-center justify-between p-1">
{module ? (
<Tab.Panel as="div" className="flex w-full flex-col text-xs ">
<button
type="button"
className="flex w-full items-center justify-start gap-2 rounded px-4 py-2 hover:bg-theme/5"
onClick={() => setModuleLinkModal(true)}
>
<PlusIcon className="h-4 w-4" /> <span>Add Link</span>
</button>
<div className="mt-2 space-y-2 hover:bg-theme/5">
{userAuth && module.link_module && module.link_module.length > 0 ? (
<LinksList
links={module.link_module}
handleDeleteLink={handleDeleteLink}
userAuth={userAuth}
/>
) : null}
</div>
</Tab.Panel>
) : (
""
)}
<Tab.Panel as="div" className="flex w-full flex-col text-xs ">
<Tab.Panels className="flex w-full items-center justify-between pt-1">
<Tab.Panel as="div" className="flex w-full flex-col text-xs">
{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 (
<SingleProgressStats
@@ -191,6 +149,15 @@ export const SidebarProgressStats: React.FC<Props> = ({
}
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)}
/>
);
}
@@ -222,10 +189,11 @@ export const SidebarProgressStats: React.FC<Props> = ({
""
)}
</Tab.Panel>
<Tab.Panel as="div" className="flex w-full flex-col ">
{issueLabels?.map((issue, index) => {
const totalArray = issues?.filter((i) => i.labels?.includes(issue.id));
<Tab.Panel as="div" className="w-full space-y-1">
{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 (
<SingleProgressStats
@@ -233,16 +201,25 @@ export const SidebarProgressStats: React.FC<Props> = ({
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: issue.color,
backgroundColor:
label.color && label.color !== "" ? label.color : "#000000",
}}
/>
<span className="text-xs capitalize">{issue.name}</span>
<span className="text-xs capitalize">{label.name}</span>
</div>
}
completed={completeArray.length}
total={totalArray.length}
onClick={() => {
if (filters.labels?.includes(label.id))
setFilters({
labels: filters.labels?.filter((l) => l !== label.id),
});
else setFilters({ labels: [...(filters?.labels ?? []), label.id] });
}}
selected={filters.labels?.includes(label.id)}
/>
);
}
@@ -263,7 +240,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group].length}
completed={groupedIssues[group]}
total={issues.length}
/>
))}
@@ -6,17 +6,26 @@ type TSingleProgressStatsProps = {
title: any;
completed: number;
total: number;
onClick?: () => void;
selected?: boolean;
};
export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
title,
completed,
total,
onClick,
selected = false,
}) => (
<div className="flex w-full items-center justify-between py-3 text-xs">
<div
className={`flex w-full items-center justify-between rounded p-2 text-xs ${
onClick ? "cursor-pointer hover:bg-gray-100" : ""
} ${selected ? "bg-gray-100" : ""}`}
onClick={onClick}
>
<div className="flex w-1/2 items-center justify-start gap-2">{title}</div>
<div className="flex w-1/2 items-center justify-end gap-1 px-2">
<div className="flex h-5 items-center justify-center gap-1 ">
<div className="flex h-5 items-center justify-center gap-1">
<span className="h-4 w-4 ">
<ProgressBar value={completed} maxValue={total} />
</span>
@@ -9,12 +9,14 @@ import cyclesService from "services/cycles.service";
// components
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
// icons
import { CompletedCycleIcon } from "components/icons";
import { CompletedCycleIcon, ExclamationIcon } from "components/icons";
// types
import { ICycle, SelectCycleType } from "types";
// fetch-keys
import { CYCLE_COMPLETE_LIST } from "constants/fetch-keys";
import { Loader } from "components/ui";
import { EmptyState, Loader } from "components/ui";
// image
import emptyCycle from "public/empty-state/empty-cycle.svg";
export interface CompletedCyclesListProps {
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
@@ -61,24 +63,31 @@ export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
/>
{completedCycles ? (
completedCycles.completed_cycles.length > 0 ? (
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
{completedCycles.completed_cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
/>
))}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<ExclamationIcon height={14} width={14} />
<span>Completed cycles are not editable.</span>
</div>
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
{completedCycles.completed_cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
isCompleted
/>
))}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 text-center">
<CompletedCycleIcon height="56" width="56" />
<h3 className="text-gray-500">
No completed cycles yet. Create with{" "}
<pre className="inline rounded bg-gray-200 px-2 py-1">Q</pre>.
</h3>
</div>
<EmptyState
type="cycle"
title="Create New Cycle"
description="Sprint more effectively with Cycles by confining your project
to a fixed amount of time. Create new cycle now."
imgURL={emptyCycle}
/>
)
) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">

Some files were not shown because too many files have changed in this diff Show More