Compare commits

...

108 Commits

Author SHA1 Message Date
Prateek Shourya
5f2350e0fc [WEB-496] improvement: disable submit on enter when there is no text in comment box. 2024-02-25 10:49:24 +05:30
Aaryan Khandelwal
812df59d1d fix: scroll container height (#3783) 2024-02-24 15:45:17 +05:30
sriram veeraghanta
7c85ee7e55 Merge branch 'develop' of github.com:makeplane/plane into develop 2024-02-23 21:48:37 +05:30
dependabot[bot]
9c1d496165 chore(deps): bump cryptography in /apiserver/requirements (#3784)
Bumps [cryptography](https://github.com/pyca/cryptography) from 42.0.0 to 42.0.4.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/42.0.0...42.0.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-23 21:47:57 +05:30
Nikhil
d6a32ef75d dev: fix sentry profiling rate (#3785) 2024-02-23 21:47:25 +05:30
sriram veeraghanta
6240b17063 Merge branch 'develop' of github.com:makeplane/plane into preview 2024-02-23 19:22:17 +05:30
Aaryan Khandelwal
33c99ded77 fix: due date highlight logic (#3763) 2024-02-23 19:15:59 +05:30
Aaryan Khandelwal
ba6479674c [WEB-306] fix: Gantt chart bugs, refactor Gantt context (#3775)
* chore: initialize gantt layout store

* fix: modules being refetched on creation

* fix: scrollLeft calculation logic

* chore: modules list item dropdown position

* refactor: active block logic

* refactor: main content block component

* chore: remove unnecessary conditions for duration
2024-02-23 19:10:45 +05:30
Anmol Singh Bhatia
46e1d46005 fix: project draft issue header (#3773) 2024-02-23 19:09:28 +05:30
Anmol Singh Bhatia
8c1f169f61 chore: workspace view header scroll to view improvement (#3771) 2024-02-23 19:08:50 +05:30
Aaryan Khandelwal
0aaca709da style: add right padding to sidebar projects list (#3764) 2024-02-23 19:07:32 +05:30
rahulramesha
5f6c9a4166 fix calendar layout distortion because of scrollbar (#3770) 2024-02-23 19:06:47 +05:30
Prateek Shourya
9f055840ef [WEB-539] style: add background to user email in dashboard dropdown for better UX on workspace list scroll. (#3769) 2024-02-23 19:06:03 +05:30
Anmol Singh Bhatia
50cbb2f002 chore: max length validation added in user name inputs (#3774) 2024-02-23 19:04:17 +05:30
Prateek Shourya
849d3a66c1 [WEB-540] fix: hide sub_issue, link, attachment property from list/ kanban view if their count is 0. (#3768)
* [WEB-540] fix: hide `sub_issue`, `link`, `attachment` property from list/ kanban view if their count is 0.

* chore: use `cn` helper function instead of string interpolation.
2024-02-23 19:03:45 +05:30
Bavisetti Narayan
1f7565ce52 fix: email notification assignees (#3762) 2024-02-23 19:03:09 +05:30
Aaryan Khandelwal
34f89ba45b [WEB-512] fix: date inputs keyboard navigation (#3753)
* fix: tab indices logic

* fix: due date highlight logic

* Revert "fix: due date highlight logic"

This reverts commit f523078689.
2024-02-23 18:57:48 +05:30
Aaryan Khandelwal
27fcfcf620 [WEB-507] fix: cycle lead details not visible (#3750)
* fix: cycle lead details

* revert: sidebar padding changes
2024-02-23 18:52:47 +05:30
Prateek Shourya
5571d42e10 [WEB-536] fix: analytics highlight while switching between scope_and_demand and custom tab. (#3767) 2024-02-23 18:52:12 +05:30
M. Palanikannan
e0a4d7a12a [WEB-459] fix: tables row color retention, images in tables and css fixes (#3748)
* fix: tables row color retention, images in tables and css fixes

* fix: border colors darker

* updated tables to new design

* removing comments
2024-02-23 18:51:38 +05:30
Prateek Shourya
5c64933927 [WEB-538] style: fix invite member icons in dropdown shrink when name is too large. (#3776) 2024-02-23 18:47:15 +05:30
Anmol Singh Bhatia
9c50ee39c3 fix: project and workspace view list flicker on hover fix (#3777) 2024-02-23 18:46:33 +05:30
rahulramesha
18b5115546 re enable sub issues toggle in spreadsheet layout (#3779) 2024-02-23 18:45:48 +05:30
Prateek Shourya
3372e21759 [WEB-537] fix: issue in all-issue create view modal, filter needs to disappear when filter is de selected. (#3781) 2024-02-23 18:44:05 +05:30
guru_sainath
03f8bfae10 fix: added default priority state in quick-add (#3761) 2024-02-22 20:59:06 +05:30
Nikhil
03e5f4a5bd [WEB-468] fix: issue detail endpoints (#3722)
* dev: add is_subscriber to issue details endpoint

* dev: remove is_subscribed annotation from detail serializers

* dev: update issue details endpoint

* dev: inbox issue create

* dev: issue detail serializer

* dev: optimize and add extra fields for issue details

* dev: remove data from issue updates

* dev: add fields for issue link and attachment

* remove expecting a issue response while updating and deleting an issue

* change link, attachment and reaction types and modify store to recieve their data from within the issue detail API call

* make changes for subscription store to recieve data from issue detail API call

* dev: add issue reaction id

* add query prarms for archived issue

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2024-02-22 20:58:34 +05:30
rahulramesha
62607ade6f disable peek overview for temporary issues until it is confirmed (#3749) 2024-02-22 20:24:15 +05:30
Nikhil
7927b7678d [WEB - 508] chore: bot filter (#3751)
* dev: update bot filters

* dev: remove bot filters from workspace

* filter out bot members in workspace

* dev: remove filtering from project member listing

* dev: update filtering for bot workspace members

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2024-02-22 20:22:03 +05:30
Anmol Singh Bhatia
ebad7f0cdf [WEB-515] fix: spreadsheet layout overflow (#3758)
* fix: spreadsheet column sort by dropdown overlapping fix

* fix: spreadsheet issue title column sticky fix

* fix: issues header z index fix
2024-02-22 20:20:30 +05:30
Aaryan Khandelwal
b1bf125916 [WEB-521] fix: kanban column collapse toggle not working (#3755)
* fix: kanban collapse toggle not working

* style: update dropdown position
2024-02-22 20:16:06 +05:30
Anmol Singh Bhatia
9b54fe80a8 chore: validation added to cycle issue transfer button (#3760) 2024-02-22 20:09:28 +05:30
Prateek Shourya
02182e05c4 [WEB-494] fix: selected label indicator are not showing up on the issue create modal. (#3752) 2024-02-22 20:03:00 +05:30
Prateek Shourya
e92417037c [WEB-496] improvement: disable submit button when there is no text in comment box. (#3754) 2024-02-22 20:01:42 +05:30
Anmol Singh Bhatia
d73cd2ec9d chore: inbox loader improvement (#3739) 2024-02-22 14:47:12 +05:30
Nikhil
acf81f30f5 fix: transfer cycle issues (#3746) 2024-02-22 14:46:02 +05:30
Nikhil
1cc2a4e679 dev: update python runtime version (#3740) 2024-02-22 14:44:02 +05:30
Bavisetti Narayan
c61e8fdb24 chore: project filter updated (#3745) 2024-02-22 14:42:57 +05:30
Bavisetti Narayan
b1a5e4872b [WEB - 490] chore: issue serializer changes (#3741)
* chore: issue serializer changes

* add assignee id check on the frontend

* add z-index for spreadsheet issue layout

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2024-02-21 21:19:00 +05:30
Aaryan Khandelwal
b1592adc66 [WEB-371]: Implemented react-day-picker for date selections (#3679)
* dev: initialize new date picker

* style: selected date focus state

* chore: replace custom date filter modal components

* chore: replaced inbox snooze popover datepicker

* chore: replaced the custom date picker

* style: date range picker designed

* chore: date range picker implemented throughout the platform

* chore: updated tab indices

* chore: range-picker in the issue layouts

* chore: passed due date color

* chore: removed range picker from issue dates
2024-02-21 19:55:18 +05:30
Nikhil
e86d2ba743 [WEB - 485] chore: external apis validation (#3737)
* dev: add integrity validation for states

* dev: add validation for issue comment with same external id and external source
2024-02-21 19:51:25 +05:30
Anmol Singh Bhatia
b10e89fdd7 [WEB-327] chore: scrollbar improvement (#3736)
* chore: scrollbar improvement

* chore: scrollbar improvement
2024-02-21 19:50:45 +05:30
Prateek Shourya
efadc728af [WEB-486] fix: In archived issues, Ctrl + click is redirecting to /issues/[issue-id] route instead of /archived-issues/[issue-id] resulting in infinite loading. (#3738) 2024-02-21 19:50:20 +05:30
Nikhil
370decc8aa [WEB - 467] perf: issues listing endpoints (#3710)
* dev: update issue listing apis

* dev: optimize issue listing apis

* fix: sub issues endpoint

* add loading state for subscription in issueDetail

* fix: import error

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2024-02-21 19:23:54 +05:30
guru_sainath
ac6e710623 [WEB-478]: implemented cycle filters in display properties for list, kanban, and spreadsheet layouts (#3702)
* chore: implemented the modules and cycle filter in the display properties

* typo: added placeholders for module and cycle select in spreadsheet view

* feat: created workspace modules and cycles endpoints in appi server and implemented in application

* ui: UI changes in the spreadsheet module and cycle dropdown and added cursor navigation for cycle via arrow keys

* format: formatted api sever

* chore: module select logic updated

* chore: updated module updated handler in all-properties and spreadsheet column

* chore: updated url names for workspace modules and cycles

* fix: validated members availability in the modules list member tooltip

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-02-21 19:20:46 +05:30
M. Palanikannan
56f4df4cb5 feat: added typography support to the editor for emDash, implies and arrows (#3648)
* feat: added typography support to the editor for emDash, implies and arrows

* chore: reverting back font
2024-02-21 18:35:44 +05:30
sriram veeraghanta
fcbe690665 fix: merge conflicts resolved 2024-02-21 18:28:11 +05:30
Aaryan Khandelwal
487e961df1 [WEB-394]: Added a description to project network options (#3717)
* chore: project access specifier description added

* chore: project access specifier description added to the project settings form

* chore: remove fullstop

* fix: dropdown width
2024-02-21 18:26:41 +05:30
Henit Chobisa
71901efcdf chore: removed cache from build test pull-request workflow (#3735) 2024-02-21 18:21:10 +05:30
Anmol Singh Bhatia
022a286eba [WEB-482] chore: issue sidebar improvement (#3733)
* chore: module-select dropdown improvement

* chore: issue sidebar subscription loader added

* chore: issue sidebar improvement

* fix: peek overview exception error
2024-02-21 18:19:49 +05:30
Prateek Shourya
c851ec7034 [WEB-465] improvement: redirect to issue detail on click on the sub-issue count property. (#3731) 2024-02-21 18:18:14 +05:30
Anmol Singh Bhatia
fb442d6dfe chore: issue embed suggestion dropdown improvement (#3730) 2024-02-21 18:16:55 +05:30
Prateek Shourya
e9fdb0ff5d improvement: open sub-issues accordion by default in issue details page. (#3724) 2024-02-21 18:05:27 +05:30
Nikhil
614096fd2f fix: email notification duplicates (#3719) 2024-02-21 18:00:47 +05:30
Anmol Singh Bhatia
133c9b3ddb chore: issue transfer validation added for completed cycle (#3715) 2024-02-21 17:53:20 +05:30
Anmol Singh Bhatia
48b55ef261 [WEB-327] chore: scrollbar implementation (#3703)
* style: custom scrollbar added

* chore: scrollbar added in issue layouts

* chore: scrollbar added in sidebar and workspace pages

* chore: calendar layout fix

* chore: project level scrollbar added

* chore: scrollbar added in command k modal

* fix: calendar layout alignment fix
2024-02-21 17:52:35 +05:30
Anmol Singh Bhatia
7464c1090a [WEB-456] chore: display sub issues option added in global view issues (#3712)
* chore: ensure global view issues displays sub-issues by default

* chore: sub issue toggle option added in global view issues
2024-02-21 17:51:36 +05:30
Nikhil
ab3c3a6cf9 [WEB - 466] perf: improve performance for cycle and module endpoints (#3711)
* dev: improve performance for cycle apis

* dev: reduce module endpoints and create a new endpoint for getting issues by list

* dev: remove unwanted fields from module

* dev: update module endpoints

* dev: optimize cycle endpoints

* change module and cycle types

* dev: module optimizations

* dev: fix the issues check

* dev: fix issues endpoint

* dev: update module detail serializer

* modify adding issues to modules and cycles

* dev: update cycle issues

* fix module links

* dev: optimize issue list endpoint

* fix: removing issues from the module when removing module_id from issue peekoverview

* fix: updated the tooltip and ui for cycle select (#3718)

* fix: updated the tooltip and ui for module select (#3716)

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-02-21 16:56:02 +05:30
Prateek Shourya
92becbc617 improvement: remove add sub-issues button from the bottom if sub-issues are available. (#3725) 2024-02-21 16:47:39 +05:30
guru_sainath
ee318ce91b [WEB-396] fix: removed view icons from workspace and project views list (#3727)
* ui: removed view icons from workspace and project views list

* cleanup: removed unnecessary imports
2024-02-21 16:43:24 +05:30
guru_sainath
fb4f4260fa [WEB-479]: rendering issue title without overflow problem in issue peekoverview (#3728)
* chore: rendering issue title without overflow problem

* chore: optimised calssName

* fix: className update
2024-02-21 16:42:04 +05:30
Aaryan Khandelwal
3efb7fc070 fix: layout change not working in cycles and modules (#3729) 2024-02-21 16:40:52 +05:30
Lakhan Baheti
95871b0049 [WEB-472] fix: Page title for global view (#3723)
* fix: Page title for global view

* fix: global-view-id type
2024-02-20 20:16:45 +05:30
Anmol Singh Bhatia
952eb871df [WEB-462] fix: inbox mutation (#3720)
* fix: inbox issue status mutation

* fix: sidebar inbox issue count fix

* fix: inbox issue mutation fix
2024-02-20 20:11:59 +05:30
Anmol Singh Bhatia
6bf9d84bea chore : calendar layout improvement (#3705)
* chore: current date indicator added in calendar layout

* style: calendar layout ui improvement
2024-02-20 15:03:58 +05:30
sriram veeraghanta
a6a28d46c7 Merge branch 'develop' of github.com:makeplane/plane into develop 2024-02-20 13:44:11 +05:30
sriram veeraghanta
4a44bb2a6c Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-20 13:43:53 +05:30
Anmol Singh Bhatia
d16a0b61d0 chore: dashboard widget heading updated (#3709) 2024-02-20 13:42:04 +05:30
Anmol Singh Bhatia
c7db290718 fix: kanban layout empty group toggle fix (#3708) 2024-02-20 13:41:38 +05:30
Anmol Singh Bhatia
e433a835fd chore: sidebar new issue button validation added (#3706) 2024-02-20 13:40:48 +05:30
sriram veeraghanta
cf3b888465 chore: adding page titles using project title. (#3692)
* chore: adding page titles

* chore: added title to remaining pages

* fix: added observer at required places

---------

Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com>
2024-02-20 13:36:38 +05:30
AbId KhAn
a64e1e04db fix: previous year closed issues in analytics (#3672)
* fix bug where previous year closed issues are showing #3668

* grouping import from same package since it was failed in GA #3668
2024-02-20 13:36:20 +05:30
Anmol Singh Bhatia
07a4cb1f7d fix: project description (#3678)
* fix: project description reset fix

* fix: project description reset fix

* chore: project settings layout loader added

* chore: loader improvement

* fix: project description reset fix
2024-02-19 21:09:51 +05:30
Anmol Singh Bhatia
e1bf318317 chore : project inbox improvement (#3695)
* chore: project inbox layout loader added

* chore: project inbox layout loader added

* chore: project inbox added in sidebar project items

* chore: inbox loader improvement

* chore: project inbox improvement
2024-02-19 21:07:43 +05:30
guru_sainath
bbbd7047d3 fix: issue description empty state initial load in inbox and issue detail page (#3696)
* fix: updated description init loading and added loading confirmation alert in inbox issues, issue peek overview, and issue detail

* fix: updated the space issue in the editor and removed unwanted props in the description-input for issues
2024-02-19 15:43:57 +05:30
sriram veeraghanta
17e5663e81 fix: description autosave fixes 2024-02-19 12:54:45 +05:30
sriram veeraghanta
170f30c7dd fix: editor fixes 2024-02-19 00:35:21 +05:30
sriram veeraghanta
7381a818a9 fix: description editor fixes 2024-02-19 00:34:54 +05:30
sriram veeraghanta
261013b794 fix: inbox issue initial data load (#3693)
* fix: inbox issue initial data load

* chore: removed unnecessary comments
2024-02-19 00:17:31 +05:30
sriram veeraghanta
ce9ed6b25e Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-18 15:29:19 +05:30
guru_sainath
10057377dc fix: improved issue description editor focus and state management (#3690)
* chore: issue input and editor reload alert issue resolved

* chore: issue description mutation issue in inbox

* fix: reload confirmation alert and stay focused after saving

* chore: updated the renderOnPropChange prop in the description-input

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-02-18 15:28:37 +05:30
sriram veeraghanta
eba5ed24ad fix: color pick background color on change (#3691) 2024-02-18 15:26:50 +05:30
Anmol Singh Bhatia
41e812a811 fix: quick action copy link action (#3686) 2024-02-16 20:07:38 +05:30
guru_sainath
a94c607031 fix: updated issue title and description components (#3687)
* fix: issue description fixes

* chore: description html in the archive issue

* chore: changed retrieve viewset

* chore: implemented new issue title description components in inbox, issue-detail and fixed issue in archived store

* chore: removed consoles and empty description update in issue detail

* fix: draft issue empty state image

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-02-16 20:07:04 +05:30
Anmol Singh Bhatia
665a07f15a chore: dropdown and peek overview improvement (#3682)
* chore: dropdown focus state improvement

* fix: peek overview dropdown placement fix
2024-02-16 20:01:58 +05:30
rahulramesha
85a8af5125 fix: spreadsheet views sorting (#3683)
* fix sorting in spreadsheet all issues

* removing focus border since it is being handled globally
2024-02-16 18:20:44 +05:30
Anmol Singh Bhatia
7628419a26 fix: inbox description and mutation (#3677)
* fix: inbox status mutation fix

* fix: inbox description fix

* chore: added description in inbox issue

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-02-15 19:31:38 +05:30
Manish Gupta
2cd16c61ba arm build for release tag (#3676) 2024-02-15 14:52:18 +05:30
sriram veeraghanta
8f7b05b73f Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-15 13:48:13 +05:30
Manish Gupta
98d545cfb9 github action modified (#3673) 2024-02-15 13:47:26 +05:30
Anmol Singh Bhatia
2548a9f062 fix: peek overview issue description initial load bug (#3670)
* fix: peek overview issue description initial load bug

* fix: peekoverview issue description infinite loading for certain data sets

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-02-15 03:17:16 +05:30
Bavisetti Narayan
d901277a20 chore: parent can be added to issue (#3665) 2024-02-14 18:16:27 +05:30
rahulramesha
489555f788 fix: horizontal scroll in gantt chart while dragging (#3664)
* fix horizontal scroll in gantt chart while dragging

* add aline indicator for quick add

* add border color for line above quick add in gantt to make it look better in dark mode
2024-02-14 18:15:13 +05:30
Anmol Singh Bhatia
b827a1af27 fix: issue details page parent issue redirection bug (#3662) 2024-02-14 18:13:29 +05:30
Ramesh Kumar Chandra
1d2e331cec fix: mobile responsive sidebar close on item tap (#3661) 2024-02-14 14:55:33 +05:30
Anmol Singh Bhatia
e51e4761b9 fix: cycle favorite mutation (#3659)
* fix: cycle favorite mutation

* fix: issue details sidebar overflow fix
2024-02-14 14:17:46 +05:30
Anmol Singh Bhatia
9299478539 chore: module & cycle empty state improvement (#3658) 2024-02-14 13:03:37 +05:30
guru_sainath
fb4cffdd1c fix: infinity loader in the issue description in issue peek overview and issue detail page (#3656) 2024-02-14 12:46:52 +05:30
Anmol Singh Bhatia
83bf28bb83 chore: loading state improvement (#3657) 2024-02-14 12:40:03 +05:30
guru_sainath
25a2816a76 chore: empty state loaders and filter cancel state update for modules and cycles (#3653) 2024-02-13 20:58:44 +05:30
rahulramesha
571b89632c fix: issue spillover on switching workspace (#3652)
* fix global issues spillover to other workspace

* fix the same for profile issues as well
2024-02-13 20:50:42 +05:30
sriram veeraghanta
5b5698ad97 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-13 19:13:07 +05:30
Anmol Singh Bhatia
b1989bae1b feat: loading states update (#3639)
* dev: implement layout skeleton loader and helper function

* chore: implemented layout loader

* chore: settings loader added

* chore: cycle, module, view, pages, notification and projects loader added

* chore: kanban loader improvement

* chore: loader utils updated
2024-02-13 19:12:10 +05:30
rahulramesha
83139989c2 fix: sort order in the same list for Kanban (#3651)
* fixing kanban dnd by stooping the modification of the original array by spreading to change the array reference

* fix sort order in the same list

* minor change in condition
2024-02-13 19:11:20 +05:30
Anmol Singh Bhatia
1bf06821bb fix: add existing issue modal fix (#3649) 2024-02-13 18:57:56 +05:30
Anmol Singh Bhatia
eea3b4fa54 chore: new empty state (#3640)
* chore: empty state asset updated

* chore: empty state config updated

* chore: cycle and module issues layout empty state added

* chore: workspace and project settings empty state added
2024-02-13 16:35:20 +05:30
rahulramesha
f64284f6a0 fixing kanban dnd by stooping the modification of the original array by spreading to change the array reference (#3646) 2024-02-13 16:33:19 +05:30
Anmol Singh Bhatia
06496ff0f0 chore: app sidebar state (#3644)
* chore: app sidebar state fix

* chore: app sidebar state fix
2024-02-13 16:33:01 +05:30
AbId KhAn
e0a18578f5 fix local development setup issue due to docker compose local file makeplane/plane#3641 (#3642) 2024-02-13 12:39:58 +05:30
577 changed files with 10699 additions and 6336 deletions

View File

@@ -28,7 +28,7 @@ jobs:
- id: set_env_variables
name: Set Environment Variables
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
@@ -54,7 +54,7 @@ jobs:
steps:
- name: Set Frontend Docker Tag
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
@@ -105,7 +105,7 @@ jobs:
steps:
- name: Set Space Docker Tag
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
@@ -156,7 +156,7 @@ jobs:
steps:
- name: Set Backend Docker Tag
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
@@ -208,7 +208,7 @@ jobs:
steps:
- name: Set Proxy Docker Tag
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable

View File

@@ -21,7 +21,6 @@ jobs:
uses: actions/setup-node@v2
with:
node-version: 18.x
cache: "yarn"
- name: Get changed files
id: changed-files

View File

@@ -647,6 +647,33 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
)
def post(self, request, slug, project_id, issue_id):
# Validation check if the issue already exists
if (
request.data.get("external_id")
and request.data.get("external_source")
and IssueComment.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
issue_comment = IssueComment.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "Issue Comment with the same external id and external source already exists",
"id": str(issue_comment.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
@@ -680,6 +707,29 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
IssueCommentSerializer(issue_comment).data,
cls=DjangoJSONEncoder,
)
# Validation check if the issue already exists
if (
str(request.data.get("external_id"))
and (issue_comment.external_id != str(request.data.get("external_id")))
and Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", issue_comment.external_source
),
external_id=request.data.get("external_id"),
).exists()
):
return Response(
{
"error": "Issue Comment with the same external id and external source already exists",
"id": str(issue_comment.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer = IssueCommentSerializer(
issue_comment, data=request.data, partial=True
)

View File

@@ -1,7 +1,5 @@
# Python imports
from itertools import groupby
# Django imports
from django.db import IntegrityError
from django.db.models import Q
# Third party imports
@@ -34,37 +32,51 @@ class StateAPIEndpoint(BaseAPIView):
)
def post(self, request, slug, project_id):
serializer = StateSerializer(
data=request.data, context={"project_id": project_id}
)
if serializer.is_valid():
if (
request.data.get("external_id")
and request.data.get("external_source")
and State.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
state = State.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "State with the same external id and external source already exists",
"id": str(state.id),
},
status=status.HTTP_409_CONFLICT,
)
try:
serializer = StateSerializer(
data=request.data, context={"project_id": project_id}
)
if serializer.is_valid():
if (
request.data.get("external_id")
and request.data.get("external_source")
and State.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
state = State.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "State with the same external id and external source already exists",
"id": str(state.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
state = State.objects.filter(
workspace__slug=slug,
project_id=project_id,
name=request.data.get("name"),
).first()
return Response(
{
"error": "State with the same name already exists in the project",
"id": str(state.id),
},
status=status.HTTP_409_CONFLICT,
)
def get(self, request, slug, project_id, state_id=None):
if state_id:

View File

@@ -69,9 +69,13 @@ from .issue import (
RelatedIssueSerializer,
IssuePublicSerializer,
IssueDetailSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
)
from .module import (
ModuleDetailSerializer,
ModuleWriteSerializer,
ModuleSerializer,
ModuleIssueSerializer,

View File

@@ -58,9 +58,12 @@ class DynamicBaseSerializer(BaseSerializer):
IssueSerializer,
LabelSerializer,
CycleIssueSerializer,
IssueFlatSerializer,
IssueLiteSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
InboxIssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
)
# Expansion mapper
@@ -79,12 +82,34 @@ class DynamicBaseSerializer(BaseSerializer):
"assignees": UserLiteSerializer,
"labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"parent": IssueLiteSerializer,
"issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer,
"issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
}
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False)
self.fields[field] = expansion[field](
many=(
True
if field
in [
"members",
"assignees",
"labels",
"issue_cycle",
"issue_relation",
"issue_inbox",
"issue_reactions",
"issue_attachment",
"issue_link",
"sub_issues",
]
else False
)
)
return self.fields
@@ -105,7 +130,11 @@ class DynamicBaseSerializer(BaseSerializer):
LabelSerializer,
CycleIssueSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
InboxIssueLiteSerializer,
IssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
)
# Expansion mapper
@@ -124,9 +153,13 @@ class DynamicBaseSerializer(BaseSerializer):
"assignees": UserLiteSerializer,
"labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"parent": IssueLiteSerializer,
"issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer,
"issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
}
# Check if field in expansion then expand the field
if expand in expansion:

View File

@@ -3,10 +3,7 @@ from rest_framework import serializers
# Module imports
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,
@@ -14,7 +11,6 @@ from plane.db.models import (
CycleUserProperties,
)
class CycleWriteSerializer(BaseSerializer):
def validate(self, data):
if (
@@ -30,60 +26,6 @@ class CycleWriteSerializer(BaseSerializer):
class Meta:
model = Cycle
fields = "__all__"
class CycleSerializer(BaseSerializer):
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)
assignees = serializers.SerializerMethodField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
status = serializers.CharField(read_only=True)
def validate(self, data):
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError(
"Start date cannot exceed end date"
)
return data
def get_assignees(self, obj):
members = [
{
"avatar": assignee.avatar,
"display_name": assignee.display_name,
"id": assignee.id,
}
for issue_cycle in obj.issue_cycle.prefetch_related(
"issue__assignees"
).all()
for assignee in issue_cycle.issue.assignees.all()
]
# Use a set comprehension to return only the unique objects
unique_objects = {frozenset(item.items()) for item in members}
# Convert the set back to a list of dictionaries
unique_list = [dict(item) for item in unique_objects]
return unique_list
class Meta:
model = Cycle
fields = "__all__"
read_only_fields = [
"workspace",
"project",
@@ -91,6 +33,52 @@ class CycleSerializer(BaseSerializer):
]
class CycleSerializer(BaseSerializer):
# favorite
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
# state group wise distribution
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)
# active | draft | upcoming | completed
status = serializers.CharField(read_only=True)
class Meta:
model = Cycle
fields = [
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"status",
]
read_only_fields = fields
class CycleIssueSerializer(BaseSerializer):
issue_detail = IssueStateSerializer(read_only=True, source="issue")
sub_issues_count = serializers.IntegerField(read_only=True)

View File

@@ -444,6 +444,22 @@ class IssueLinkSerializer(BaseSerializer):
return IssueLink.objects.create(**validated_data)
class IssueLinkLiteSerializer(BaseSerializer):
class Meta:
model = IssueLink
fields = [
"id",
"issue_id",
"title",
"url",
"metadata",
"created_by_id",
"created_at",
]
read_only_fields = fields
class IssueAttachmentSerializer(BaseSerializer):
class Meta:
model = IssueAttachment
@@ -459,6 +475,21 @@ class IssueAttachmentSerializer(BaseSerializer):
]
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
class Meta:
model = IssueAttachment
fields = [
"id",
"asset",
"attributes",
"issue_id",
"updated_at",
"updated_by_id",
]
read_only_fields = fields
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
@@ -473,6 +504,18 @@ class IssueReactionSerializer(BaseSerializer):
]
class IssueReactionLiteSerializer(DynamicBaseSerializer):
class Meta:
model = IssueReaction
fields = [
"id",
"actor_id",
"issue_id",
"reaction",
]
class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
@@ -503,9 +546,7 @@ class IssueCommentSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
comment_reactions = CommentReactionSerializer(
read_only=True, many=True
)
comment_reactions = CommentReactionSerializer(read_only=True, many=True)
is_member = serializers.BooleanField(read_only=True)
class Meta:
@@ -558,18 +599,17 @@ class IssueStateSerializer(DynamicBaseSerializer):
class IssueSerializer(DynamicBaseSerializer):
# ids
project_id = serializers.PrimaryKeyRelatedField(read_only=True)
state_id = serializers.PrimaryKeyRelatedField(read_only=True)
parent_id = serializers.PrimaryKeyRelatedField(read_only=True)
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
module_ids = serializers.SerializerMethodField()
module_ids = serializers.ListField(
child=serializers.UUIDField(), required=False,
)
# Many to many
label_ids = serializers.PrimaryKeyRelatedField(
read_only=True, many=True, source="labels"
label_ids = serializers.ListField(
child=serializers.UUIDField(), required=False,
)
assignee_ids = serializers.PrimaryKeyRelatedField(
read_only=True, many=True, source="assignees"
assignee_ids = serializers.ListField(
child=serializers.UUIDField(), required=False,
)
# Count items
@@ -577,9 +617,6 @@ class IssueSerializer(DynamicBaseSerializer):
attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True)
# is_subscribed
is_subscribed = serializers.BooleanField(read_only=True)
class Meta:
model = Issue
fields = [
@@ -606,57 +643,45 @@ class IssueSerializer(DynamicBaseSerializer):
"updated_by",
"attachment_count",
"link_count",
"is_subscribed",
"is_draft",
"archived_at",
]
read_only_fields = fields
def get_module_ids(self, obj):
# Access the prefetched modules and extract module IDs
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
description_html = serializers.CharField()
is_subscribed = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + ['description_html']
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
]
class IssueLiteSerializer(DynamicBaseSerializer):
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)
attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True)
issue_reactions = IssueReactionSerializer(read_only=True, many=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",
fields = [
"id",
"sequence_id",
"project_id",
]
read_only_fields = fields
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
is_subscribed = serializers.BooleanField()
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
]
read_only_fields = fields
class IssuePublicSerializer(BaseSerializer):

View File

@@ -5,7 +5,6 @@ from rest_framework import serializers
from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from plane.db.models import (
User,
@@ -19,17 +18,18 @@ from plane.db.models import (
class ModuleWriteSerializer(BaseSerializer):
members = serializers.ListField(
lead_id = serializers.PrimaryKeyRelatedField(
source="lead",
queryset=User.objects.all(),
required=False,
allow_null=True,
)
member_ids = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = Module
fields = "__all__"
@@ -44,7 +44,9 @@ class ModuleWriteSerializer(BaseSerializer):
def to_representation(self, instance):
data = super().to_representation(instance)
data["members"] = [str(member.id) for member in instance.members.all()]
data["member_ids"] = [
str(member.id) for member in instance.members.all()
]
return data
def validate(self, data):
@@ -59,12 +61,10 @@ class ModuleWriteSerializer(BaseSerializer):
return data
def create(self, validated_data):
members = validated_data.pop("members", None)
members = validated_data.pop("member_ids", None)
project = self.context["project"]
module = Module.objects.create(**validated_data, project=project)
if members is not None:
ModuleMember.objects.bulk_create(
[
@@ -85,7 +85,7 @@ class ModuleWriteSerializer(BaseSerializer):
return module
def update(self, instance, validated_data):
members = validated_data.pop("members", None)
members = validated_data.pop("member_ids", None)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
@@ -142,7 +142,6 @@ class ModuleIssueSerializer(BaseSerializer):
class ModuleLinkSerializer(BaseSerializer):
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
class Meta:
model = ModuleLink
@@ -170,12 +169,9 @@ class ModuleLinkSerializer(BaseSerializer):
class ModuleSerializer(DynamicBaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
lead_detail = UserLiteSerializer(read_only=True, source="lead")
members_detail = UserLiteSerializer(
read_only=True, many=True, source="members"
member_ids = serializers.ListField(
child=serializers.UUIDField(), required=False, allow_null=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)
@@ -186,15 +182,46 @@ class ModuleSerializer(DynamicBaseSerializer):
class Meta:
model = Module
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
fields = [
# Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
]
read_only_fields = fields
class ModuleDetailSerializer(ModuleSerializer):
link_module = ModuleLinkSerializer(read_only=True, many=True)
class Meta(ModuleSerializer.Meta):
fields = ModuleSerializer.Meta.fields + ['link_module']
class ModuleFavoriteSerializer(BaseSerializer):

View File

@@ -2,6 +2,7 @@ from django.urls import path
from plane.app.views import (
IssueListEndpoint,
IssueViewSet,
LabelViewSet,
BulkCreateIssueLabelsEndpoint,
@@ -25,6 +26,11 @@ from plane.app.views import (
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/list/",
IssueListEndpoint.as_view(),
name="project-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueViewSet.as_view(
@@ -84,11 +90,13 @@ urlpatterns = [
BulkImportIssuesEndpoint.as_view(),
name="project-issues-bulk",
),
# deprecated endpoint TODO: remove once confirmed
path(
"workspaces/<str:slug>/my-issues/",
UserWorkSpaceIssues.as_view(),
name="workspace-issues",
),
##
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
SubIssuesEndpoint.as_view(),

View File

@@ -22,6 +22,8 @@ from plane.app.views import (
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
)
@@ -219,4 +221,14 @@ urlpatterns = [
WorkspaceEstimatesEndpoint.as_view(),
name="workspace-estimate",
),
path(
"workspaces/<str:slug>/modules/",
WorkspaceModulesEndpoint.as_view(),
name="workspace-modules",
),
path(
"workspaces/<str:slug>/cycles/",
WorkspaceCyclesEndpoint.as_view(),
name="workspace-cycles",
),
]

View File

@@ -49,6 +49,8 @@ from .workspace import (
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
)
from .state import StateViewSet
from .view import (
@@ -67,6 +69,7 @@ from .cycle import (
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue import (
IssueListEndpoint,
IssueViewSet,
WorkSpaceIssuesEndpoint,
IssueActivityEndpoint,

View File

@@ -1,6 +1,7 @@
# Django imports
from django.db.models import Count, Sum, F, Q
from django.db.models.functions import ExtractMonth
from django.utils import timezone
# Third party imports
from rest_framework import status
@@ -331,8 +332,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
.order_by("state_group")
)
current_year = timezone.now().year
issue_completed_month_wise = (
base_issues.filter(completed_at__isnull=False)
base_issues.filter(completed_at__year=current_year)
.annotate(month=ExtractMonth("completed_at"))
.values("month")
.annotate(count=Count("*"))

View File

@@ -20,7 +20,10 @@ 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
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework.response import Response
@@ -33,7 +36,6 @@ from plane.app.serializers import (
CycleIssueSerializer,
CycleFavoriteSerializer,
IssueSerializer,
IssueStateSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
)
@@ -51,7 +53,6 @@ from plane.db.models import (
IssueAttachment,
Label,
CycleUserProperties,
IssueSubscriber,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
@@ -73,7 +74,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
def get_queryset(self):
subquery = CycleFavorite.objects.filter(
favorite_subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
@@ -85,10 +86,24 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.select_related("project", "workspace", "owned_by")
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
)
)
.annotate(is_favorite=Exists(favorite_subquery))
.annotate(
total_issues=Count(
"issue_cycle",
@@ -148,29 +163,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
),
)
)
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
status=Case(
When(
@@ -190,20 +182,16 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
output_field=CharField(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
.annotate(
assignee_ids=Coalesce(
ArrayAgg(
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(
issue_cycle__issue__assignees__id__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "name")
@@ -213,12 +201,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
def list(self, request, slug, project_id):
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
# Update the order by
queryset = queryset.order_by("-is_favorite", "-created_at")
# Current Cycle
@@ -228,9 +212,35 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
end_date__gte=timezone.now(),
)
data = CycleSerializer(queryset, many=True).data
data = queryset.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
)
if len(data):
if data:
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=data[0]["id"],
@@ -315,19 +325,45 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
}
if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"][
"completion_chart"
] = burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
cycle_id=data[0]["id"],
data[0]["distribution"]["completion_chart"] = (
burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
cycle_id=data[0]["id"],
)
)
return Response(data, status=status.HTTP_200_OK)
cycles = CycleSerializer(queryset, many=True).data
return Response(cycles, status=status.HTTP_200_OK)
data = queryset.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
)
return Response(data, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
if (
@@ -337,7 +373,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
):
serializer = CycleSerializer(data=request.data)
serializer = CycleWriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
@@ -346,12 +382,36 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
cycle = (
self.get_queryset()
.filter(pk=serializer.data["id"])
.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
)
.first()
)
serializer = CycleSerializer(cycle)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(cycle, status=status.HTTP_201_CREATED)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
@@ -364,10 +424,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
def partial_update(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
queryset = (
self.get_queryset()
.filter(workspace__slug=slug, project_id=project_id, pk=pk)
)
cycle = queryset.first()
request_data = request.data
if (
@@ -375,7 +436,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
and cycle.end_date < timezone.now().date()
):
if "sort_order" in request_data:
# Can only change sort order
# Can only change sort order for a completed cycle``
request_data = {
"sort_order": request_data.get(
"sort_order", cycle.sort_order
@@ -394,12 +455,71 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
cycle = queryset.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
).first()
return Response(cycle, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk):
queryset = self.get_queryset().get(pk=pk)
queryset = self.get_queryset().filter(pk=pk)
data = (
self.get_queryset()
.filter(pk=pk)
.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
)
.first()
)
queryset = queryset.first()
# Assignee Distribution
assignee_distribution = (
Issue.objects.filter(
@@ -488,7 +608,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.order_by("label_name")
)
data = CycleSerializer(queryset).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
@@ -589,20 +708,18 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
]
order_by = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
issues = (
queryset = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
"assignees",
"labels",
"issue_module__module",
"issue_cycle__cycle",
)
.order_by(order_by)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
@@ -621,22 +738,79 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
subscriber=self.request.user, issue_id=OuterRef("id")
)
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by(order_by)
)
serializer = IssueSerializer(
issues, many=True, fields=fields if fields else None
)
return Response(serializer.data, status=status.HTTP_200_OK)
if self.fields:
issues = IssueSerializer(
queryset, many=True, fields=fields if fields else None
).data
else:
issues = queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", [])
if not len(issues):
if not issues:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
@@ -658,52 +832,52 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
)
# Get all CycleIssues already created
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
update_cycle_issue_activity = []
record_to_create = []
records_to_update = []
cycle_issues = list(
CycleIssue.objects.filter(
~Q(cycle_id=cycle_id), issue_id__in=issues
)
)
existing_issues = [
str(cycle_issue.issue_id) for cycle_issue in cycle_issues
]
new_issues = list(set(issues) - set(existing_issues))
for issue in issues:
cycle_issue = [
cycle_issue
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
# Update only when cycle changes
if len(cycle_issue):
if cycle_issue[0].cycle_id != cycle_id:
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue[0].cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue[0].issue_id),
}
)
cycle_issue[0].cycle_id = cycle_id
records_to_update.append(cycle_issue[0])
else:
record_to_create.append(
CycleIssue(
project_id=project_id,
workspace=cycle.workspace,
created_by=request.user,
updated_by=request.user,
cycle=cycle,
issue_id=issue,
)
# New issues to create
created_records = CycleIssue.objects.bulk_create(
[
CycleIssue(
project_id=project_id,
workspace_id=cycle.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
cycle_id=cycle_id,
issue_id=issue,
)
CycleIssue.objects.bulk_create(
record_to_create,
batch_size=10,
ignore_conflicts=True,
)
CycleIssue.objects.bulk_update(
records_to_update,
["cycle"],
for issue in new_issues
],
batch_size=10,
)
# Updated Issues
updated_records = []
update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues:
# Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list
updated_records.append(cycle_issue)
# Record the update activity
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue.cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
# Update the cycle issues
CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
@@ -715,7 +889,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", record_to_create
"json", created_records
),
}
),
@@ -723,16 +897,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Return all Cycle Issues
issues = self.get_queryset().values_list("issue_id", flat=True)
return Response(
IssueSerializer(
Issue.objects.filter(pk__in=issues), many=True
).data,
status=status.HTTP_200_OK,
)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.get(
@@ -776,6 +941,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter(
Q(workspace__slug=slug)
& Q(project_id=project_id)
@@ -785,7 +951,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
| Q(start_date__gte=start_date, end_date__lte=end_date)
)
).exclude(pk=cycle_id)
if cycles.exists():
return Response(
{
@@ -909,29 +1074,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
),
)
)
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
)
# Pass the new_cycle queryset to burndown_plot
@@ -942,6 +1084,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
cycle_id=cycle_id,
)
# Get the assignee distribution
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
@@ -980,7 +1123,22 @@ class TransferCycleIssueEndpoint(BaseAPIView):
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
@@ -1023,7 +1181,9 @@ class TransferCycleIssueEndpoint(BaseAPIView):
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None,
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
@@ -1032,11 +1192,14 @@ class TransferCycleIssueEndpoint(BaseAPIView):
for item in assignee_distribution
]
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": str(item["label_id"]) if item["label_id"] else None,
"label_id": (
str(item["label_id"]) if item["label_id"] else None
),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
@@ -1055,10 +1218,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
"started_issues": old_cycle.first().started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues,
"total_estimates": old_cycle.first().total_estimates,
"completed_estimates": old_cycle.first().completed_estimates,
"started_estimates": old_cycle.first().started_estimates,
"distribution":{
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,

View File

@@ -15,6 +15,10 @@ from django.db.models import (
Func,
Prefetch,
)
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
from django.utils import timezone
# Third Party imports
@@ -130,7 +134,32 @@ def dashboard_assigned_issues(self, request, slug):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.order_by("created_at")
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
)
# Priority Ordering
@@ -259,6 +288,32 @@ def dashboard_created_issues(self, request, slug):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by("created_at")
)

View File

@@ -3,8 +3,12 @@ import json
# Django import
from django.utils import timezone
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework import status
@@ -21,13 +25,14 @@ from plane.db.models import (
IssueLink,
IssueAttachment,
ProjectMember,
IssueReaction,
IssueSubscriber,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueSerializer,
InboxSerializer,
InboxIssueSerializer,
IssueCreateSerializer,
IssueStateInboxSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
@@ -92,7 +97,7 @@ class InboxIssueViewSet(BaseViewSet):
Issue.objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_inbox__inbox_id=self.kwargs.get("inbox_id")
issue_inbox__inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
@@ -127,14 +132,75 @@ class InboxIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status")
issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data
issue_queryset = (
self.get_queryset()
.filter(**filters)
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
)
if self.expand:
issues = IssueSerializer(
issue_queryset, expand=self.expand, many=True
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(
issues_data,
issues,
status=status.HTTP_200_OK,
)
@@ -199,8 +265,8 @@ class InboxIssueViewSet(BaseViewSet):
source=request.data.get("source", "in-app"),
)
issue = (self.get_queryset().filter(pk=issue.id).first())
serializer = IssueSerializer(issue ,expand=self.expand)
issue = self.get_queryset().filter(pk=issue.id).first()
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
@@ -230,11 +296,7 @@ class InboxIssueViewSet(BaseViewSet):
issue_data = request.data.pop("issue", False)
if bool(issue_data):
issue = Issue.objects.get(
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first()
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
# viewers and guests since only viewers and guests
@@ -320,20 +382,55 @@ class InboxIssueViewSet(BaseViewSet):
if state is not None:
issue.state = state
issue.save()
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue ,expand=self.expand)
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue, expand=self.expand,)
issue = (
self.get_queryset()
.filter(pk=issue_id)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
)
).first()
if issue is None:
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, issue_id):

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,12 @@ import json
# Django Imports
from django.utils import timezone
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
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework.response import Response
@@ -24,6 +25,7 @@ from plane.app.serializers import (
ModuleFavoriteSerializer,
IssueSerializer,
ModuleUserPropertiesSerializer,
ModuleDetailSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
@@ -38,11 +40,9 @@ from plane.db.models import (
ModuleFavorite,
IssueLink,
IssueAttachment,
IssueSubscriber,
ModuleUserProperties,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.utils.analytics_plot import burndown_plot
@@ -62,7 +62,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
def get_queryset(self):
subquery = ModuleFavorite.objects.filter(
favorite_subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
@@ -73,7 +73,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
.get_queryset()
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.annotate(is_favorite=Exists(subquery))
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("project")
.select_related("workspace")
.select_related("lead")
@@ -145,6 +145,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
),
)
)
.annotate(
member_ids=Coalesce(
ArrayAgg(
"members__id",
distinct=True,
filter=~Q(members__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "-created_at")
)
@@ -157,25 +167,84 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
if serializer.is_valid():
serializer.save()
module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
module = (
self.get_queryset()
.filter(pk=serializer.data["id"])
.values( # Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
)
).first()
return Response(module, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
modules = ModuleSerializer(
queryset, many=True, fields=fields if fields else None
).data
if self.fields:
modules = ModuleSerializer(
queryset,
many=True,
fields=self.fields,
).data
else:
modules = queryset.values( # Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
)
return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk):
queryset = self.get_queryset().get(pk=pk)
queryset = self.get_queryset().filter(pk=pk)
assignee_distribution = (
Issue.objects.filter(
@@ -269,16 +338,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
.order_by("label_name")
)
data = ModuleSerializer(queryset).data
data = ModuleDetailSerializer(queryset.first()).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if queryset.start_date and queryset.target_date:
if queryset.first().start_date and queryset.first().target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset,
queryset=queryset.first(),
slug=slug,
project_id=project_id,
module_id=pk,
@@ -289,6 +358,47 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
status=status.HTTP_200_OK,
)
def partial_update(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(pk=pk)
serializer = ModuleWriteSerializer(
queryset.first(), data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
module = queryset.values(
# Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
).first()
return Response(module, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, pk):
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
@@ -331,17 +441,15 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
ProjectEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id")
issue_module__module_id=self.kwargs.get("module_id"),
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("labels", "assignees")
.prefetch_related('issue_module__module')
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@@ -365,6 +473,32 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
@method_decorator(gzip_page)
@@ -376,15 +510,44 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
]
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters)
serializer = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
)
return Response(serializer.data, status=status.HTTP_200_OK)
if self.fields or self.expand:
issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK)
# create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id):
issues = request.data.get("issues", [])
if not len(issues):
if not issues:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
@@ -420,15 +583,12 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
)
for issue in issues
]
issues = (self.get_queryset().filter(pk__in=issues))
serializer = IssueSerializer(issues , many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
# create multiple module inside an issue
def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", [])
if not len(modules):
if not modules:
return Response(
{"error": "Modules are required"},
status=status.HTTP_400_BAD_REQUEST,
@@ -466,10 +626,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
for module in modules
]
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
@@ -484,7 +641,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps({"module_name": module_issue.module.name}),
current_instance=json.dumps(
{"module_name": module_issue.module.name}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),

View File

@@ -247,12 +247,7 @@ class IssueSearchEndpoint(BaseAPIView):
if parent == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True
).exclude(
pk__in=Issue.issue_objects.filter(
parent__isnull=False
).values_list("parent_id", flat=True)
)
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id))
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(

View File

@@ -1,6 +1,6 @@
# Django imports
from django.db.models import (
Prefetch,
Q,
OuterRef,
Func,
F,
@@ -13,16 +13,21 @@ from django.db.models import (
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db.models import Prefetch, OuterRef, Exists
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
# Third party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseViewSet, BaseAPIView
from . import BaseViewSet
from plane.app.serializers import (
GlobalViewSerializer,
IssueViewSerializer,
IssueSerializer,
IssueViewFavoriteSerializer,
@@ -30,22 +35,16 @@ from plane.app.serializers import (
from plane.app.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
WorkspaceViewerPermission,
ProjectLitePermission,
)
from plane.db.models import (
Workspace,
GlobalView,
IssueView,
Issue,
IssueViewFavorite,
IssueReaction,
IssueLink,
IssueAttachment,
IssueSubscriber,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class GlobalViewViewSet(BaseViewSet):
@@ -89,11 +88,54 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
)
@@ -123,28 +165,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
@@ -207,10 +227,39 @@ class GlobalViewIssuesViewSet(BaseViewSet):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
serializer = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
)
return Response(serializer.data, status=status.HTTP_200_OK)
if self.fields:
issues = IssueSerializer(
issue_queryset, many=True, fields=self.fields
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK)
class IssueViewViewSet(BaseViewSet):

View File

@@ -22,9 +22,14 @@ from django.db.models import (
When,
Max,
IntegerField,
Sum,
)
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
from django.db.models.fields import DateField
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party modules
from rest_framework import status
@@ -73,6 +78,9 @@ from plane.db.models import (
WorkspaceUserProperties,
Estimate,
EstimatePoint,
Module,
ModuleLink,
Cycle,
)
from plane.app.permissions import (
WorkSpaceBasePermission,
@@ -85,6 +93,12 @@ from plane.app.permissions import (
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.event_tracking_task import workspace_invite_event
from plane.app.serializers.module import (
ModuleSerializer,
)
from plane.app.serializers.cycle import (
CycleSerializer,
)
class WorkSpaceViewSet(BaseViewSet):
@@ -546,7 +560,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
member__is_bot=False,
is_active=True,
)
.select_related("workspace", "workspace__owner")
@@ -754,7 +767,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_ids = (
ProjectMember.objects.filter(
member=request.user,
member__is_bot=False,
is_active=True,
)
.values_list("project_id", flat=True)
@@ -764,7 +776,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
# Get all the project members in which the user is involved
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
project_id__in=project_ids,
is_active=True,
).select_related("project", "member", "workspace")
@@ -1234,6 +1245,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
Project.objects.filter(
workspace__slug=slug,
project_projectmember__member=request.user,
project_projectmember__is_active=True,
)
.annotate(
created_issues=Count(
@@ -1370,6 +1382,32 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by("created_at")
).distinct()
@@ -1490,6 +1528,192 @@ class WorkspaceEstimatesEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
class WorkspaceModulesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug):
modules = (
Module.objects.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related(
"module", "created_by"
),
)
)
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
)
serializer = ModuleSerializer(modules, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
class WorkspaceCyclesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug):
cycles = (
Cycle.objects.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
serializer = CycleSerializer(cycles, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
class WorkspaceUserPropertiesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,

View File

@@ -1,21 +1,33 @@
from datetime import datetime
from bs4 import BeautifulSoup
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Django imports
from django.utils import timezone
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Module imports
from plane.db.models import EmailNotificationLog, User, Issue
from plane.license.utils.instance_value import get_email_configuration
from plane.settings.redis import redis_instance
# acquire and delete redis lock
def acquire_lock(lock_id, expire_time=300):
redis_client = redis_instance()
"""Attempt to acquire a lock with a specified expiration time."""
return redis_client.set(lock_id, 'true', nx=True, ex=expire_time)
def release_lock(lock_id):
"""Release a lock."""
redis_client = redis_instance()
redis_client.delete(lock_id)
@shared_task
def stack_email_notification():
# get all email notifications
@@ -142,135 +154,155 @@ def process_html_content(content):
processed_content_list.append(processed_content)
return processed_content_list
@shared_task
def send_email_notification(
issue_id, notification_data, receiver_id, email_notification_ids
):
# Convert UUIDs to a sorted, concatenated string
sorted_ids = sorted(email_notification_ids)
ids_str = "_".join(str(id) for id in sorted_ids)
lock_id = f"send_email_notif_{issue_id}_{receiver_id}_{ids_str}"
# acquire the lock for sending emails
try:
ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data)
if acquire_lock(lock_id=lock_id):
# get the redis instance
ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data)
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
receiver = User.objects.get(pk=receiver_id)
issue = Issue.objects.get(pk=issue_id)
template_data = []
total_changes = 0
comments = []
actors_involved = []
for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes)
comment = changes.pop("comment", False)
mention = changes.pop("mention", False)
actors_involved.append(actor_id)
if comment:
comments.append(
{
"actor_comments": comment,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
receiver = User.objects.get(pk=receiver_id)
issue = Issue.objects.get(pk=issue_id)
template_data = []
total_changes = 0
comments = []
actors_involved = []
for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes)
comment = changes.pop("comment", False)
mention = changes.pop("mention", False)
actors_involved.append(actor_id)
if comment:
comments.append(
{
"actor_comments": comment,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
if mention:
mention["new_value"] = process_html_content(mention.get("new_value"))
mention["old_value"] = process_html_content(mention.get("old_value"))
comments.append(
{
"actor_comments": mention,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
# Parse the input string into a datetime object
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
if changes:
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(formatted_time),
}
)
if mention:
mention["new_value"] = process_html_content(mention.get("new_value"))
mention["old_value"] = process_html_content(mention.get("old_value"))
comments.append(
{
"actor_comments": mention,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
# Parse the input string into a datetime object
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
if changes:
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(formatted_time),
}
)
summary = "Updates were made to the issue by"
summary = "Updates were made to the issue by"
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
context = {
"data": template_data,
"summary": summary,
"actors_involved": len(set(actors_involved)),
"issue": {
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"name": issue.name,
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
context = {
"data": template_data,
"summary": summary,
"actors_involved": len(set(actors_involved)),
"issue": {
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"name": issue.name,
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
},
"receiver": {
"email": receiver.email,
},
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
},
"receiver": {
"email": receiver.email,
},
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
"workspace":str(issue.project.workspace.slug),
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
)
text_content = strip_tags(html_content)
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
"workspace":str(issue.project.workspace.slug),
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
)
text_content = strip_tags(html_content)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
EmailNotificationLog.objects.filter(
pk__in=email_notification_ids
).update(sent_at=timezone.now())
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
EmailNotificationLog.objects.filter(
pk__in=email_notification_ids
).update(sent_at=timezone.now())
# release the lock
release_lock(lock_id=lock_id)
return
except Exception as e:
capture_exception(e)
# release the lock
release_lock(lock_id=lock_id)
return
else:
print("Duplicate task recived. Skipping...")
return
except Exception as e:
except (Issue.DoesNotExist, User.DoesNotExist) as e:
if settings.DEBUG:
print(e)
return
except Issue.DoesNotExist:
release_lock(lock_id=lock_id)
return

View File

@@ -1,4 +1,5 @@
"""Global Settings"""
# Python imports
import os
import ssl
@@ -307,7 +308,9 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
traces_sample_rate=1,
send_default_pii=True,
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
profiles_sample_rate=1.0,
profiles_sample_rate=float(
os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0.5)
),
)

View File

@@ -30,7 +30,7 @@ openpyxl==3.1.2
beautifulsoup4==4.12.2
dj-database-url==2.1.0
posthog==3.0.2
cryptography==42.0.0
cryptography==42.0.4
lxml==4.9.3
boto3==1.28.40

View File

@@ -1 +1 @@
python-3.11.7
python-3.11.8

View File

@@ -137,7 +137,7 @@ services:
dockerfile: Dockerfile.dev
args:
DOCKER_BUILDKIT: 1
restart: no
restart: "no"
networks:
- dev_env
volumes:

View File

@@ -97,8 +97,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
}
}
}
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run();
else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run();
};
export const unsetLinkEditor = (editor: Editor) => {

View File

@@ -170,68 +170,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
}
}
#editor-container {
table {
border-collapse: collapse;
table-layout: fixed;
margin: 0.5em 0 0.5em 0;
border: 1px solid rgb(var(--color-border-200));
width: 100%;
td,
th {
min-width: 1em;
border: 1px solid rgb(var(--color-border-200));
padding: 10px 15px;
vertical-align: top;
box-sizing: border-box;
position: relative;
transition: background-color 0.3s ease;
> * {
margin-bottom: 0;
}
}
th {
font-weight: bold;
text-align: left;
background-color: rgb(var(--color-primary-100));
}
td:hover {
background-color: rgba(var(--color-primary-300), 0.1);
}
.selectedCell:after {
z-index: 2;
position: absolute;
content: "";
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(var(--color-primary-300), 0.1);
pointer-events: none;
}
.column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 2px;
background-color: rgb(var(--color-primary-400));
pointer-events: none;
}
}
}
.tableWrapper {
overflow-x: auto;
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;

View File

@@ -9,15 +9,15 @@
border-collapse: collapse;
table-layout: fixed;
margin: 0;
margin-bottom: 3rem;
border: 1px solid rgba(var(--color-border-200));
margin-bottom: 1rem;
border: 2px solid rgba(var(--color-border-300));
width: 100%;
}
.tableWrapper table td,
.tableWrapper table th {
min-width: 1em;
border: 1px solid rgba(var(--color-border-200));
border: 1px solid rgba(var(--color-border-300));
padding: 10px 15px;
vertical-align: top;
box-sizing: border-box;
@@ -43,7 +43,8 @@
.tableWrapper table th {
font-weight: bold;
text-align: left;
background-color: rgba(var(--color-primary-100));
background-color: #d9e4ff;
color: #171717;
}
.tableWrapper table th * {
@@ -62,6 +63,35 @@
pointer-events: none;
}
.colorPicker {
display: grid;
padding: 8px 8px;
grid-template-columns: repeat(6, 1fr);
gap: 5px;
}
.colorPickerLabel {
font-size: 0.85rem;
color: #6b7280;
padding: 8px 8px;
padding-bottom: 0px;
}
.colorPickerItem {
margin: 2px 0px;
width: 24px;
height: 24px;
border-radius: 4px;
border: none;
cursor: pointer;
}
.divider {
background-color: #e5e7eb;
height: 1px;
margin: 3px 0;
}
.tableWrapper table .column-resize-handle {
position: absolute;
right: -2px;
@@ -69,7 +99,7 @@
bottom: -2px;
width: 4px;
z-index: 99;
background-color: rgba(var(--color-primary-400));
background-color: #d9e4ff;
pointer-events: none;
}
@@ -112,7 +142,7 @@
}
.tableWrapper .tableControls .rowsControlDiv {
background-color: rgba(var(--color-primary-100));
background-color: #d9e4ff;
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
@@ -127,7 +157,7 @@
}
.tableWrapper .tableControls .columnsControlDiv {
background-color: rgba(var(--color-primary-100));
background-color: #d9e4ff;
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
@@ -144,10 +174,12 @@
.tableWrapper .tableControls .tableColorPickerToolbox {
border: 1px solid rgba(var(--color-border-300));
background-color: rgba(var(--color-background-100));
border-radius: 5px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
padding: 0.25rem;
display: flex;
flex-direction: column;
width: 200px;
width: max-content;
gap: 0.25rem;
}
@@ -158,7 +190,7 @@
align-items: center;
gap: 0.5rem;
border: none;
padding: 0.1rem;
padding: 0.3rem 0.5rem 0.1rem 0.1rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
@@ -173,9 +205,7 @@
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
border: 1px solid rgba(var(--color-border-300));
border-radius: 3px;
padding: 4px;
padding: 4px 0px;
display: flex;
align-items: center;
justify-content: center;
@@ -187,8 +217,8 @@
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
width: 2rem;
height: 2rem;
width: 1rem;
height: 1rem;
}
.tableToolbox {

View File

@@ -25,7 +25,8 @@ import { DeleteImage } from "src/types/delete-image";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { RestoreImage } from "src/types/restore-image";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomCodeInlineExtension } from "./code-inline";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
import { CustomTypographyExtension } from "src/ui/extensions/typography";
export const CoreEditorExtensions = (
mentionConfig: {
@@ -79,6 +80,7 @@ export const CoreEditorExtensions = (
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
CustomTypographyExtension,
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",

View File

@@ -13,7 +13,7 @@ export const TableCell = Node.create<TableCellOptions>({
};
},
content: "paragraph+",
content: "block+",
addAttributes() {
return {
@@ -33,7 +33,10 @@ export const TableCell = Node.create<TableCellOptions>({
},
},
background: {
default: "none",
default: null,
},
textColor: {
default: null,
},
};
},
@@ -50,7 +53,7 @@ export const TableCell = Node.create<TableCellOptions>({
return [
"td",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`,
style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`,
}),
0,
];

View File

@@ -33,7 +33,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
},
},
background: {
default: "rgb(var(--color-primary-100))",
default: "none",
},
};
},

View File

@@ -13,6 +13,17 @@ export const TableRow = Node.create<TableRowOptions>({
};
},
addAttributes() {
return {
background: {
default: null,
},
textColor: {
default: null,
},
};
},
content: "(tableCell | tableHeader)*",
tableRole: "row",
@@ -22,6 +33,12 @@ export const TableRow = Node.create<TableRowOptions>({
},
renderHTML({ HTMLAttributes }) {
return ["tr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
const style = HTMLAttributes.background
? `background-color: ${HTMLAttributes.background}; color: ${HTMLAttributes.textColor}`
: "";
const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style });
return ["tr", attributes, 0];
},
});

View File

@@ -1,7 +1,7 @@
export const icons = {
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`,
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
insertLeftTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
length={24}
@@ -35,6 +35,8 @@ export const icons = {
/>
</svg>
`,
toggleColumnHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
toggleRowHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
insertBottomTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
length={24}

View File

@@ -81,53 +81,75 @@ const defaultTippyOptions: Partial<Props> = {
placement: "right",
};
function setCellsBackgroundColor(editor: Editor, backgroundColor: string) {
function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
return editor
.chain()
.focus()
.updateAttributes("tableCell", {
background: backgroundColor,
})
.updateAttributes("tableHeader", {
background: backgroundColor,
background: color.backgroundColor,
textColor: color.textColor,
})
.run();
}
function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
const { state, dispatch } = editor.view;
const { selection } = state;
if (!(selection instanceof CellSelection)) {
return false;
}
// Get the position of the hovered cell in the selection to determine the row.
const hoveredCell = selection.$headCell || selection.$anchorCell;
// Find the depth of the table row node
let rowDepth = hoveredCell.depth;
while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") {
rowDepth--;
}
// If we couldn't find a tableRow node, we can't set the background color
if (hoveredCell.node(rowDepth).type.name !== "tableRow") {
return false;
}
// Get the position where the table row starts
const rowStartPos = hoveredCell.start(rowDepth);
// Create a transaction that sets the background color on the tableRow node.
const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, {
...hoveredCell.node(rowDepth).attrs,
background: color.backgroundColor,
textColor: color.textColor,
});
dispatch(tr);
return true;
}
const columnsToolboxItems: ToolboxItem[] = [
{
label: "Add Column Before",
label: "Toggle column header",
icon: icons.toggleColumnHeader,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(),
},
{
label: "Add column before",
icon: icons.insertLeftTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(),
},
{
label: "Add Column After",
label: "Add column after",
icon: icons.insertRightTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(),
},
{
label: "Pick Column Color",
icon: icons.colorPicker,
action: ({
editor,
triggerButton,
controlsContainer,
}: {
editor: Editor;
triggerButton: HTMLElement;
controlsContainer: Element;
}) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer,
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
});
},
label: "Pick color",
icon: "", // No icon needed for color picker
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
},
{
label: "Delete Column",
label: "Delete column",
icon: icons.deleteColumn,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(),
},
@@ -135,35 +157,24 @@ const columnsToolboxItems: ToolboxItem[] = [
const rowsToolboxItems: ToolboxItem[] = [
{
label: "Add Row Above",
label: "Toggle row header",
icon: icons.toggleRowHeader,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(),
},
{
label: "Add row above",
icon: icons.insertTopTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(),
},
{
label: "Add Row Below",
label: "Add row below",
icon: icons.insertBottomTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(),
},
{
label: "Pick Row Color",
icon: icons.colorPicker,
action: ({
editor,
triggerButton,
controlsContainer,
}: {
editor: Editor;
triggerButton: HTMLButtonElement;
controlsContainer: Element | "parent" | ((ref: Element) => Element) | undefined;
}) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer,
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
});
},
label: "Pick color",
icon: "",
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
},
{
label: "Delete Row",
@@ -176,37 +187,57 @@ function createToolbox({
triggerButton,
items,
tippyOptions,
onSelectColor,
onClickItem,
colors,
}: {
triggerButton: Element | null;
items: ToolboxItem[];
tippyOptions: any;
onClickItem: (item: ToolboxItem) => void;
onSelectColor: (color: { backgroundColor: string; textColor: string }) => void;
colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } };
}): Instance<Props> {
// @ts-expect-error
const toolbox = tippy(triggerButton, {
content: h(
"div",
{ className: "tableToolbox" },
items.map((item) =>
h(
"div",
{
className: "toolboxItem",
itemType: "button",
onClick() {
onClickItem(item);
items.map((item, index) => {
if (item.label === "Pick color") {
return h("div", { className: "flex flex-col" }, [
h("div", { className: "divider" }),
h("div", { className: "colorPickerLabel" }, item.label),
h(
"div",
{ className: "colorPicker grid" },
Object.entries(colors).map(([colorName, colorValue]) =>
h("div", {
className: "colorPickerItem",
style: `background-color: ${colorValue.backgroundColor};
color: ${colorValue.textColor || "inherit"};`,
innerHTML: colorValue?.icon || "",
onClick: () => onSelectColor(colorValue),
})
)
),
h("div", { className: "divider" }),
]);
} else {
return h(
"div",
{
className: "toolboxItem",
itemType: "div",
onClick: () => onClickItem(item),
},
},
[
h("div", {
className: "iconContainer",
innerHTML: item.icon,
}),
h("div", { className: "label" }, item.label),
]
)
)
[
h("div", { className: "iconContainer", innerHTML: item.icon }),
h("div", { className: "label" }, item.label),
]
);
}
})
),
...tippyOptions,
});
@@ -214,71 +245,6 @@ function createToolbox({
return Array.isArray(toolbox) ? toolbox[0] : toolbox;
}
function createColorPickerToolbox({
triggerButton,
tippyOptions,
onSelectColor = () => {},
}: {
triggerButton: HTMLElement;
tippyOptions: Partial<Props>;
onSelectColor?: (color: string) => void;
}) {
const items = {
Default: "rgb(var(--color-primary-100))",
Orange: "#FFE5D1",
Grey: "#F1F1F1",
Yellow: "#FEF3C7",
Green: "#DCFCE7",
Red: "#FFDDDD",
Blue: "#D9E4FF",
Pink: "#FFE8FA",
Purple: "#E8DAFB",
};
const colorPicker = tippy(triggerButton, {
...defaultTippyOptions,
content: h(
"div",
{ className: "tableColorPickerToolbox" },
Object.entries(items).map(([key, value]) =>
h(
"div",
{
className: "toolboxItem",
itemType: "button",
onClick: () => {
onSelectColor(value);
colorPicker.hide();
},
},
[
h("div", {
className: "colorContainer",
style: {
backgroundColor: value,
},
}),
h(
"div",
{
className: "label",
},
key
),
]
)
)
),
onHidden: (instance) => {
instance.destroy();
},
showOnCreate: true,
...tippyOptions,
});
return colorPicker;
}
export class TableView implements NodeView {
node: ProseMirrorNode;
cellMinWidth: number;
@@ -347,10 +313,27 @@ export class TableView implements NodeView {
this.rowsControl,
this.columnsControl
);
const columnColors = {
Blue: { backgroundColor: "#D9E4FF", textColor: "#171717" },
Orange: { backgroundColor: "#FFEDD5", textColor: "#171717" },
Grey: { backgroundColor: "#F1F1F1", textColor: "#171717" },
Yellow: { backgroundColor: "#FEF3C7", textColor: "#171717" },
Green: { backgroundColor: "#DCFCE7", textColor: "#171717" },
Red: { backgroundColor: "#FFDDDD", textColor: "#171717" },
Pink: { backgroundColor: "#FFE8FA", textColor: "#171717" },
Purple: { backgroundColor: "#E8DAFB", textColor: "#171717" },
None: {
backgroundColor: "none",
textColor: "none",
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>`,
},
};
this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
items: columnsToolboxItems,
colors: columnColors,
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls,
@@ -368,10 +351,12 @@ export class TableView implements NodeView {
this.rowsToolbox = createToolbox({
triggerButton: this.rowsControl.firstElementChild,
items: rowsToolboxItems,
colors: columnColors,
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls,
},
onSelectColor: (color) => setTableRowBackgroundColor(editor, color),
onClickItem: (item) => {
item.action({
editor: this.editor,
@@ -383,8 +368,6 @@ export class TableView implements NodeView {
});
}
// Table
this.colgroup = h(
"colgroup",
null,
@@ -437,16 +420,19 @@ export class TableView implements NodeView {
}
updateControls() {
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => {
if (curr.spec.hoveredCell !== undefined) {
acc["hoveredCell"] = curr.spec.hoveredCell;
}
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce(
(acc, curr) => {
if (curr.spec.hoveredCell !== undefined) {
acc["hoveredCell"] = curr.spec.hoveredCell;
}
if (curr.spec.hoveredTable !== undefined) {
acc["hoveredTable"] = curr.spec.hoveredTable;
}
return acc;
}, {} as Record<string, HTMLElement>) as any;
if (curr.spec.hoveredTable !== undefined) {
acc["hoveredTable"] = curr.spec.hoveredTable;
}
return acc;
},
{} as Record<string, HTMLElement>
) as any;
if (table === undefined || cell === undefined) {
return this.root.classList.add("controls--disabled");
@@ -457,12 +443,12 @@ export class TableView implements NodeView {
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
if (!this.table) {
if (!this.table || !cellDom) {
return;
}
const tableRect = this.table.getBoundingClientRect();
const cellRect = cellDom.getBoundingClientRect();
const tableRect = this.table?.getBoundingClientRect();
const cellRect = cellDom?.getBoundingClientRect();
if (this.columnsControl) {
this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`;

View File

@@ -107,10 +107,9 @@ export const Table = Node.create({
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
({ tr, dispatch, editor }) => {
const node = createTable(editor.schema, rows, cols, withHeaderRow);
if (dispatch) {
const offset = tr.selection.anchor + 1;

View File

@@ -0,0 +1,109 @@
import { Extension } from "@tiptap/core";
import {
TypographyOptions,
emDash,
ellipsis,
leftArrow,
rightArrow,
copyright,
trademark,
servicemark,
registeredTrademark,
oneHalf,
plusMinus,
notEqual,
laquo,
raquo,
multiplication,
superscriptTwo,
superscriptThree,
oneQuarter,
threeQuarters,
impliesArrowRight,
} from "src/ui/extensions/typography/inputRules";
export const CustomTypographyExtension = Extension.create<TypographyOptions>({
name: "typography",
addInputRules() {
const rules = [];
if (this.options.emDash !== false) {
rules.push(emDash(this.options.emDash));
}
if (this.options.impliesArrowRight !== false) {
rules.push(impliesArrowRight(this.options.impliesArrowRight));
}
if (this.options.ellipsis !== false) {
rules.push(ellipsis(this.options.ellipsis));
}
if (this.options.leftArrow !== false) {
rules.push(leftArrow(this.options.leftArrow));
}
if (this.options.rightArrow !== false) {
rules.push(rightArrow(this.options.rightArrow));
}
if (this.options.copyright !== false) {
rules.push(copyright(this.options.copyright));
}
if (this.options.trademark !== false) {
rules.push(trademark(this.options.trademark));
}
if (this.options.servicemark !== false) {
rules.push(servicemark(this.options.servicemark));
}
if (this.options.registeredTrademark !== false) {
rules.push(registeredTrademark(this.options.registeredTrademark));
}
if (this.options.oneHalf !== false) {
rules.push(oneHalf(this.options.oneHalf));
}
if (this.options.plusMinus !== false) {
rules.push(plusMinus(this.options.plusMinus));
}
if (this.options.notEqual !== false) {
rules.push(notEqual(this.options.notEqual));
}
if (this.options.laquo !== false) {
rules.push(laquo(this.options.laquo));
}
if (this.options.raquo !== false) {
rules.push(raquo(this.options.raquo));
}
if (this.options.multiplication !== false) {
rules.push(multiplication(this.options.multiplication));
}
if (this.options.superscriptTwo !== false) {
rules.push(superscriptTwo(this.options.superscriptTwo));
}
if (this.options.superscriptThree !== false) {
rules.push(superscriptThree(this.options.superscriptThree));
}
if (this.options.oneQuarter !== false) {
rules.push(oneQuarter(this.options.oneQuarter));
}
if (this.options.threeQuarters !== false) {
rules.push(threeQuarters(this.options.threeQuarters));
}
return rules;
},
});

View File

@@ -0,0 +1,137 @@
import { textInputRule } from "@tiptap/core";
export interface TypographyOptions {
emDash: false | string;
ellipsis: false | string;
leftArrow: false | string;
rightArrow: false | string;
copyright: false | string;
trademark: false | string;
servicemark: false | string;
registeredTrademark: false | string;
oneHalf: false | string;
plusMinus: false | string;
notEqual: false | string;
laquo: false | string;
raquo: false | string;
multiplication: false | string;
superscriptTwo: false | string;
superscriptThree: false | string;
oneQuarter: false | string;
threeQuarters: false | string;
impliesArrowRight: false | string;
}
export const emDash = (override?: string) =>
textInputRule({
find: /--$/,
replace: override ?? "—",
});
export const impliesArrowRight = (override?: string) =>
textInputRule({
find: /=>$/,
replace: override ?? "⇒",
});
export const leftArrow = (override?: string) =>
textInputRule({
find: /<-$/,
replace: override ?? "←",
});
export const rightArrow = (override?: string) =>
textInputRule({
find: /->$/,
replace: override ?? "→",
});
export const ellipsis = (override?: string) =>
textInputRule({
find: /\.\.\.$/,
replace: override ?? "…",
});
export const copyright = (override?: string) =>
textInputRule({
find: /\(c\)$/,
replace: override ?? "©",
});
export const trademark = (override?: string) =>
textInputRule({
find: /\(tm\)$/,
replace: override ?? "™",
});
export const servicemark = (override?: string) =>
textInputRule({
find: /\(sm\)$/,
replace: override ?? "℠",
});
export const registeredTrademark = (override?: string) =>
textInputRule({
find: /\(r\)$/,
replace: override ?? "®",
});
export const oneHalf = (override?: string) =>
textInputRule({
find: /(?:^|\s)(1\/2)\s$/,
replace: override ?? "½",
});
export const plusMinus = (override?: string) =>
textInputRule({
find: /\+\/-$/,
replace: override ?? "±",
});
export const notEqual = (override?: string) =>
textInputRule({
find: /!=$/,
replace: override ?? "≠",
});
export const laquo = (override?: string) =>
textInputRule({
find: /<<$/,
replace: override ?? "«",
});
export const raquo = (override?: string) =>
textInputRule({
find: />>$/,
replace: override ?? "»",
});
export const multiplication = (override?: string) =>
textInputRule({
find: /\d+\s?([*x])\s?\d+$/,
replace: override ?? "×",
});
export const superscriptTwo = (override?: string) =>
textInputRule({
find: /\^2$/,
replace: override ?? "²",
});
export const superscriptThree = (override?: string) =>
textInputRule({
find: /\^3$/,
replace: override ?? "³",
});
export const oneQuarter = (override?: string) =>
textInputRule({
find: /(?:^|\s)(1\/4)\s$/,
replace: override ?? "¼",
});
export const threeQuarters = (override?: string) =>
textInputRule({
find: /(?:^|\s)(3\/4)\s$/,
replace: override ?? "¾",
});

View File

@@ -42,15 +42,6 @@ export function CoreEditorProps(
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
event.preventDefault();
const file = event.dataTransfer.files[0];

View File

@@ -145,7 +145,7 @@ const IssueSuggestionList = ({
<div
id="issue-list-container"
ref={commandListContainer}
className=" fixed z-[10] max-h-80 w-60 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
className=" fixed z-[10] max-h-80 w-96 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
>
{sections.map((section) => {
const sectionItems = displayedItems[section];
@@ -175,8 +175,8 @@ const IssueSuggestionList = ({
>
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
<PriorityIcon priority={item.priority} />
<div>
<p className="flex-grow truncate text-xs">{item.title}</p>
<div className="w-full truncate">
<p className="flex-grow w-full truncate text-xs">{item.title}</p>
</div>
</button>
))}

View File

@@ -48,34 +48,12 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
function getComplexItems(): BubbleMenuItem[] {
const items: BubbleMenuItem[] = [TableItem(editor)];
if (shouldShowImageItem()) {
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
}
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
return items;
}
const complexItems: BubbleMenuItem[] = getComplexItems();
function shouldShowImageItem(): boolean {
if (typeof window !== "undefined") {
const selectionRange: any = window?.getSelection();
const { selection } = props.editor.state;
if (selectionRange.rangeCount !== 0) {
const range = selectionRange.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return false;
}
if (isCellSelection(selection)) {
return false;
}
}
return true;
}
return false;
}
return (
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
<div className="flex items-center gap-0.5 pr-2">

View File

@@ -35,7 +35,7 @@ export interface DragHandleOptions {
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
const data = node?.getBoundingClientRect();
return {
top: data.top,
@@ -65,7 +65,7 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) {
}
function nodePosAtDOM(node: Element, view: EditorView) {
const boundingRect = node.getBoundingClientRect();
const boundingRect = node?.getBoundingClientRect();
if (node.nodeName === "IMG") {
return view.posAtCoords({

View File

@@ -60,34 +60,13 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
function getComplexItems(): BubbleMenuItem[] {
const items: BubbleMenuItem[] = [TableItem(props.editor)];
if (shouldShowImageItem()) {
items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
}
items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
return items;
}
const complexItems: BubbleMenuItem[] = getComplexItems();
function shouldShowImageItem(): boolean {
if (typeof window !== "undefined") {
const selectionRange: any = window?.getSelection();
const { selection } = props.editor.state;
if (selectionRange.rangeCount !== 0) {
const range = selectionRange.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return false;
}
if (isCellSelection(selection)) {
return false;
}
}
return true;
}
return false;
}
const handleAccessChange = (accessKey: string) => {
props.commentAccessSpecifier?.onAccessChange(accessKey);
};

View File

@@ -15,6 +15,7 @@ import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
export type IRichTextEditor = {
value: string;
initialValue?: string;
dragDropEnabled?: boolean;
uploadFile: UploadImage;
restoreFile: RestoreImage;
@@ -54,6 +55,7 @@ const RichTextEditor = ({
setShouldShowAlert,
editorContentCustomClassNames,
value,
initialValue,
uploadFile,
deleteFile,
noBorder,
@@ -97,6 +99,10 @@ const RichTextEditor = ({
customClassName,
});
React.useEffect(() => {
if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
}, [editor, initialValue]);
if (!editor) return null;
return (

View File

@@ -30,10 +30,9 @@ export interface ICycle {
is_favorite: boolean;
issue: string;
name: string;
owned_by: string;
owned_by_id: string;
progress_snapshot: TProgressSnapshot;
project: string;
project_detail: IProjectLite;
project_id: string;
status: TCycleGroups;
sort_order: number;
start_date: string | null;
@@ -42,12 +41,11 @@ export interface ICycle {
unstarted_issues: number;
updated_at: Date;
updated_by: string;
assignees: IUserLite[];
assignee_ids: string[];
view_props: {
filters: IIssueFilterOptions;
};
workspace: string;
workspace_detail: IWorkspaceLite;
workspace_id: string;
}
export type TProgressSnapshot = {

View File

@@ -58,7 +58,6 @@ export interface IIssueLink {
export interface ILinkDetails {
created_at: Date;
created_by: string;
created_by_detail: IUserLite;
id: string;
metadata: any;
title: string;

View File

@@ -1,4 +1,7 @@
import { TIssuePriorities } from "../issues";
import { TIssueAttachment } from "./issue_attachment";
import { TIssueLink } from "./issue_link";
import { TIssueReaction } from "./issue_reaction";
// new issue structure types
export type TIssue = {
@@ -34,7 +37,12 @@ export type TIssue = {
updated_by: string;
is_draft: boolean;
is_subscribed: boolean;
is_subscribed?: boolean;
parent?: partial<TIssue>;
issue_reactions?: TIssueReaction[];
issue_attachment?: TIssueAttachment[];
issue_link?: TIssueLink[];
// tempId is used for optimistic updates. It is not a part of the API response.
tempId?: string;

View File

@@ -1,17 +1,15 @@
export type TIssueAttachment = {
id: string;
created_at: string;
updated_at: string;
attributes: {
name: string;
size: number;
};
asset: string;
created_by: string;
issue_id: string;
//need
updated_at: string;
updated_by: string;
project: string;
workspace: string;
issue: string;
};
export type TIssueAttachmentMap = {

View File

@@ -4,11 +4,13 @@ export type TIssueLinkEditableFields = {
};
export type TIssueLink = TIssueLinkEditableFields & {
created_at: Date;
created_by: string;
created_by_detail: IUserLite;
created_by_id: string;
id: string;
metadata: any;
issue_id: string;
//need
created_at: Date;
};
export type TIssueLinkMap = {

View File

@@ -1,15 +1,8 @@
export type TIssueReaction = {
actor: string;
actor_detail: IUserLite;
created_at: Date;
created_by: string;
actor_id: string;
id: string;
issue: string;
project: string;
issue_id: string;
reaction: string;
updated_at: Date;
updated_by: string;
workspace: string;
};
export type TIssueReactionMap = {

View File

@@ -27,16 +27,12 @@ export interface IModule {
labels: TLabelsDistribution[];
};
id: string;
lead: string | null;
lead_detail: IUserLite | null;
lead_id: string | null;
link_module: ILinkDetails[];
links_list: ModuleLink[];
members: string[];
members_detail: IUserLite[];
member_ids: string[];
is_favorite: boolean;
name: string;
project: string;
project_detail: IProjectLite;
project_id: string;
sort_order: number;
start_date: string | null;
started_issues: number;
@@ -49,8 +45,7 @@ export interface IModule {
view_props: {
filters: IIssueFilterOptions;
};
workspace: string;
workspace_detail: IWorkspaceLite;
workspace_id: string;
}
export interface ModuleIssueResponse {

View File

@@ -1,5 +1,11 @@
import { EUserProjectRoles } from "constants/project";
import type { IUser, IUserLite, IWorkspace, IWorkspaceLite, TStateGroups } from ".";
import type {
IUser,
IUserLite,
IWorkspace,
IWorkspaceLite,
TStateGroups,
} from ".";
export interface IProject {
archive_in: number;
@@ -117,7 +123,7 @@ export type TProjectIssuesSearchParams = {
parent?: boolean;
issue_relation?: boolean;
cycle?: boolean;
module?: string[];
module?: string;
sub_issue?: boolean;
issue_id?: string;
workspace_search: boolean;

View File

@@ -30,6 +30,10 @@ export type TIssueOrderByOptions =
| "-assignees__first_name"
| "labels__name"
| "-labels__name"
| "modules__name"
| "-modules__name"
| "cycle__name"
| "-cycle__name"
| "target_date"
| "-target_date"
| "estimate_point"
@@ -109,6 +113,8 @@ export interface IIssueDisplayProperties {
estimate?: boolean;
created_on?: boolean;
updated_on?: boolean;
modules?: boolean;
cycle?: boolean;
}
export type TIssueKanbanFilters = {

View File

@@ -5,10 +5,11 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
onClick: () => void;
children: React.ReactNode;
target?: string;
disabled?: boolean;
};
export const ControlLink: React.FC<TControlLink> = (props) => {
const { href, onClick, children, target = "_self", ...rest } = props;
const { href, onClick, children, target = "_self", disabled = false, ...rest } = props;
const LEFT_CLICK_EVENT_CODE = 0;
const _onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
@@ -19,6 +20,8 @@ export const ControlLink: React.FC<TControlLink> = (props) => {
}
};
if (disabled) return <>{children}</>;
return (
<a href={href} target={target} onClick={_onClick} {...rest}>
{children}

View File

@@ -122,7 +122,7 @@ const Option = (props: ICustomSelectItemProps) => {
value={value}
className={({ active }) =>
cn(
"cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200",
"cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200 flex items-center justify-between gap-2",
{
"bg-custom-background-80": active,
},
@@ -131,10 +131,10 @@ const Option = (props: ICustomSelectItemProps) => {
}
>
{({ selected }) => (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">{children}</div>
<>
{children}
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</div>
</>
)}
</Listbox.Option>
);

View File

@@ -20,7 +20,8 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined;
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;
return (
<>
@@ -57,7 +58,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
<div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{moduleDetails.lead_detail?.display_name}</span>
{moduleLeadDetails && <span>{moduleLeadDetails?.display_name}</span>}
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>

View File

@@ -5,7 +5,7 @@ import { mutate } from "swr";
// services
import { AnalyticsService } from "services/analytics.service";
// hooks
import { useCycle, useModule, useProject, useUser } from "hooks/store";
import { useCycle, useModule, useProject, useUser, useWorkspace } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics";
@@ -39,6 +39,8 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
// store hooks
const { currentUser } = useUser();
const { workspaceProjectIds, getProjectById } = useProject();
const { getWorkspaceById } = useWorkspace();
const { fetchCycleDetails, getCycleById } = useCycle();
const { fetchModuleDetails, getModuleById } = useModule();
@@ -70,11 +72,14 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
const currentProjectDetails = getProjectById(details?.project_id || "");
const currentWorkspaceDetails = getWorkspaceById(details?.workspace_id || "");
eventPayload.workspaceId = details?.workspace_id;
eventPayload.workspaceName = currentWorkspaceDetails?.name;
eventPayload.projectId = details?.project_id;
eventPayload.projectIdentifier = currentProjectDetails?.identifier;
eventPayload.projectName = currentProjectDetails?.name;
}
if (cycleDetails) {
@@ -138,14 +143,18 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
return (
<div className={cn("relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100", !isProjectLevel ? "flex-col" : "")}
<div
className={cn(
"relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100",
!isProjectLevel ? "flex-col" : ""
)}
>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
<LayersIcon height={14} width={14} />
{analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
{analytics ? analytics.total : "..."}
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
</div>
{isProjectLevel && (
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
@@ -154,8 +163,8 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
(cycleId
? cycleDetails?.created_at
: moduleId
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
)}
</div>
)}

View File

@@ -47,7 +47,7 @@ export const ScopeAndDemand: React.FC<Props> = (props) => {
<>
{!defaultAnalyticsError ? (
defaultAnalytics ? (
<div className="h-full overflow-y-auto p-5 text-sm">
<div className="h-full overflow-y-auto p-5 text-sm vertical-scrollbar scrollbar-lg">
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}>
<AnalyticsDemand defaultAnalytics={defaultAnalytics} />
<AnalyticsScope defaultAnalytics={defaultAnalytics} />

View File

@@ -1,11 +1,10 @@
import { useState } from "react";
import { add } from "date-fns";
import { Controller, useForm } from "react-hook-form";
import { DateDropdown } from "components/dropdowns";
import { Calendar } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// components
import { CustomDatePicker } from "components/ui";
// ui
import { Button, CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui";
// helpers
@@ -167,7 +166,7 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
<CustomSelect
customButton={
<div
className={`flex items-center gap-2 rounded border-[0.5px] border-custom-border-200 px-2 py-1 ${
className={`flex items-center gap-2 rounded border-[0.5px] border-custom-border-300 px-2 py-0.5 ${
neverExpires ? "text-custom-text-400" : ""
}`}
>
@@ -194,20 +193,13 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
}}
/>
{watch("expired_at") === "custom" && (
<CustomDatePicker
<DateDropdown
value={customDate}
onChange={(date) => setCustomDate(date ? new Date(date) : null)}
onChange={(date) => setCustomDate(date)}
minDate={tomorrow}
customInput={
<div
className={`flex cursor-pointer items-center gap-2 !rounded border-[0.5px] border-custom-border-200 px-2 py-1 text-xs !shadow-none !duration-0 ${
customDate ? "w-[7.5rem]" : ""
} ${neverExpires ? "!cursor-not-allowed text-custom-text-400" : "hover:bg-custom-background-80"}`}
>
<Calendar className="h-3 w-3" />
{customDate ? renderFormattedDate(customDate) : "Set date"}
</div>
}
icon={<Calendar className="h-3 w-3" />}
buttonVariant="border-with-text"
placeholder="Set date"
disabled={neverExpires}
/>
)}

View File

@@ -229,7 +229,7 @@ export const CommandModal: React.FC = observer(() => {
/>
</div>
<Command.List className="max-h-96 overflow-scroll p-2">
<Command.List className="max-h-96 overflow-scroll p-2 vertical-scrollbar scrollbar-sm">
{searchTerm !== "" && (
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
Search results for{" "}

View File

@@ -1,13 +1,12 @@
import { Fragment } from "react";
import { Controller, useForm } from "react-hook-form";
import DatePicker from "react-datepicker";
import { DayPicker } from "react-day-picker";
import { Dialog, Transition } from "@headlessui/react";
import { X } from "lucide-react";
// components
import { DateFilterSelect } from "./date-filter-select";
// ui
import { Button } from "@plane/ui";
// icons
import { X } from "lucide-react";
// helpers
import { renderFormattedPayloadDate, renderFormattedDate } from "helpers/date-time.helper";
@@ -46,9 +45,6 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
const isInvalid = watch("filterType") === "range" ? new Date(watch("date1")) > new Date(watch("date2")) : false;
const nextDay = new Date(watch("date1"));
nextDay.setDate(nextDay.getDate() + 1);
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
@@ -91,12 +87,15 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
control={control}
name="date1"
render={({ field: { value, onChange } }) => (
<DatePicker
selected={value}
onChange={(val) => onChange(val)}
dateFormat="dd-MM-yyyy"
calendarClassName="h-full"
inline
<DayPicker
selected={value ? new Date(value) : undefined}
defaultMonth={value ? new Date(value) : undefined}
onSelect={(date) => onChange(date)}
mode="single"
disabled={[
{ after: new Date(watch("date2")) }
]}
className="border border-custom-border-200 p-3 rounded-md"
/>
)}
/>
@@ -105,13 +104,15 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
control={control}
name="date2"
render={({ field: { value, onChange } }) => (
<DatePicker
selected={value}
onChange={onChange}
dateFormat="dd-MM-yyyy"
calendarClassName="h-full"
minDate={nextDay}
inline
<DayPicker
selected={value ? new Date(value) : undefined}
defaultMonth={value ? new Date(value) : undefined}
onSelect={(date) => onChange(date)}
mode="single"
disabled={[
{ before: new Date(watch("date1")) }
]}
className="border border-custom-border-200 p-3 rounded-md"
/>
)}
/>

View File

@@ -51,10 +51,10 @@ export const DateFilterSelect: React.FC<Props> = ({ title, value, onChange }) =>
>
{dueDateRange.map((option, index) => (
<CustomSelect.Option key={index} value={option.value}>
<>
<div className="flex items-center gap-2">
<span>{option.icon}</span>
{title} {option.name}
</>
</div>
</CustomSelect.Option>
))}
</CustomSelect>

View File

@@ -4,3 +4,4 @@ export * from "./sidebar";
export * from "./theme";
export * from "./activity";
export * from "./image-picker-popover";
export * from "./page-title";

View File

@@ -78,7 +78,6 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
useEffect(() => {
if (!isOpen || !workspaceSlug || !projectId) return;
if (issues.length <= 0) setIsSearching(true);
projectService
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
@@ -88,16 +87,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
})
.then((res) => setIssues(res))
.finally(() => setIsSearching(false));
}, [issues, debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
useEffect(() => {
setSearchTerm("");
setIssues([]);
setSelectedIssues([]);
setIsSearching(false);
setIsSubmitting(false);
setIsWorkspaceLevel(false);
}, [isOpen]);
}, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
return (
<>

View File

@@ -0,0 +1,18 @@
import Head from "next/head";
type PageHeadTitleProps = {
title?: string;
description?: string;
};
export const PageHead: React.FC<PageHeadTitleProps> = (props) => {
const { title } = props;
if (!title) return null;
return (
<Head>
<title>{title}</title>
</Head>
);
};

View File

@@ -8,6 +8,9 @@ import { calculateTimeAgo } from "helpers/date-time.helper";
import { ILinkDetails, UserAuth } from "@plane/types";
// hooks
import useToast from "hooks/use-toast";
import { observer } from "mobx-react";
import { useMeasure } from "@nivo/core";
import { useMember } from "hooks/store";
type Props = {
links: ILinkDetails[];
@@ -16,9 +19,10 @@ type Props = {
userAuth: UserAuth;
};
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEditLink, userAuth }) => {
export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => {
// toast
const { setToastAlert } = useToast();
const { getUserDetails } = useMember();
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
@@ -33,70 +37,75 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEdit
return (
<>
{links.map((link) => (
<div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
<div className="flex w-full items-start justify-between gap-2">
<div className="flex items-start gap-2 truncate">
<span className="py-1">
<LinkIcon className="h-3 w-3 flex-shrink-0" />
</span>
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url}>
<span
className="cursor-pointer truncate text-xs"
onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
>
{link.title && link.title !== "" ? link.title : link.url}
{links.map((link) => {
const createdByDetails = getUserDetails(link.created_by);
return (
<div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
<div className="flex w-full items-start justify-between gap-2">
<div className="flex items-start gap-2 truncate">
<span className="py-1">
<LinkIcon className="h-3 w-3 flex-shrink-0" />
</span>
</Tooltip>
</div>
{!isNotAllowed && (
<div className="z-[1] flex flex-shrink-0 items-center gap-2">
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleEditLink(link);
}}
>
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</button>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
>
<ExternalLinkIcon className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</a>
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDeleteLink(link.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url}>
<span
className="cursor-pointer truncate text-xs"
onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
>
{link.title && link.title !== "" ? link.title : link.url}
</span>
</Tooltip>
</div>
)}
{!isNotAllowed && (
<div className="z-[1] flex flex-shrink-0 items-center gap-2">
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleEditLink(link);
}}
>
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</button>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
>
<ExternalLinkIcon className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</a>
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDeleteLink(link.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)}
</div>
<div className="px-5">
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
Added {calculateTimeAgo(link.created_at)}
<br />
{createdByDetails && (
<>
by{" "}
{createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
</>
)}
</p>
</div>
</div>
<div className="px-5">
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
Added {calculateTimeAgo(link.created_at)}
<br />
by{" "}
{link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.display_name}
</p>
</div>
</div>
))}
);
})}
</>
);
};
});

View File

@@ -3,12 +3,20 @@ import { Menu } from "lucide-react";
import { useApplication } from "hooks/store";
import { observer } from "mobx-react";
export const SidebarHamburgerToggle: FC = observer(() => {
const { theme: themStore } = useApplication();
type Props = {
onClick?: () => void;
}
export const SidebarHamburgerToggle: FC<Props> = observer((props) => {
const { onClick } = props
const { theme: themeStore } = useApplication();
return (
<div
className="w-7 h-7 flex-shrink-0 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
onClick={() => themStore.toggleSidebar()}
onClick={() => {
if (onClick) onClick()
else themeStore.toggleMobileSidebar()
}}
>
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
</div>

View File

@@ -125,7 +125,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab>
</Tab.List>
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
<Tab.Panel as="div" className="flex min-h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{distribution?.assignees.length > 0 ? (
distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
@@ -182,7 +185,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
</div>
)}
</Tab.Panel>
<Tab.Panel as="div" className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{distribution?.labels.length > 0 ? (
distribution.labels.map((label, index) => (
<SingleProgressStats
@@ -222,7 +228,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
</div>
)}
</Tab.Panel>
<Tab.Panel as="div" className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}

View File

@@ -66,7 +66,6 @@ export const CustomThemeSelector: React.FC = observer(() => {
const handleValueChange = (val: string | undefined, onChange: any) => {
let hex = val;
// prepend a hashtag if it doesn't exist
if (val && val[0] !== "#") hex = `#${val}`;
@@ -94,7 +93,7 @@ export const CustomThemeSelector: React.FC = observer(() => {
placeholder="#0d101b"
className="w-full"
style={{
backgroundColor: value,
backgroundColor: watch("background"),
color: watch("text"),
}}
hasError={Boolean(errors?.background)}
@@ -120,8 +119,8 @@ export const CustomThemeSelector: React.FC = observer(() => {
placeholder="#c5c5c5"
className="w-full"
style={{
backgroundColor: watch("background"),
color: value,
backgroundColor: watch("text"),
color: watch("background"),
}}
hasError={Boolean(errors?.text)}
/>
@@ -146,7 +145,7 @@ export const CustomThemeSelector: React.FC = observer(() => {
placeholder="#3f76ff"
className="w-full"
style={{
backgroundColor: value,
backgroundColor: watch("primary"),
color: watch("text"),
}}
hasError={Boolean(errors?.primary)}
@@ -172,7 +171,7 @@ export const CustomThemeSelector: React.FC = observer(() => {
placeholder="#0d101b"
className="w-full"
style={{
backgroundColor: value,
backgroundColor: watch("sidebarBackground"),
color: watch("sidebarText"),
}}
hasError={Boolean(errors?.sidebarBackground)}
@@ -200,8 +199,8 @@ export const CustomThemeSelector: React.FC = observer(() => {
placeholder="#c5c5c5"
className="w-full"
style={{
backgroundColor: watch("sidebarBackground"),
color: value,
backgroundColor: watch("sidebarText"),
color: watch("sidebarBackground"),
}}
hasError={Boolean(errors?.sidebarText)}
/>

View File

@@ -34,7 +34,8 @@ import { ICycle, TCycleGroups } from "@plane/types";
// constants
import { EIssuesStoreType } from "constants/issue";
import { CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
import { CYCLE_EMPTY_STATE_DETAILS, CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
import { CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
interface IActiveCycleDetails {
workspaceSlug: string;
@@ -68,7 +69,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
);
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by) : undefined;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && currentProjectActiveCycleId
@@ -221,12 +222,13 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div>
{activeCycle.assignees.length > 0 && (
{activeCycle.assignee_ids.length > 0 && (
<div className="flex items-center gap-1 text-custom-text-200">
<AvatarGroup>
{activeCycle.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
{activeCycle.assignee_ids.map((assigne_id) => {
const member = getUserDetails(assigne_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
</div>
)}

View File

@@ -69,7 +69,10 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.List>
{cycle && cycle.total_issues > 0 ? (
<Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="w-full items-center gap-1 overflow-y-scroll p-4 text-custom-text-200">
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1 overflow-y-auto pt-3.5 p-4 pr-0 text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle.distribution?.assignees?.map((assignee, index) => {
if (assignee.assignee_id)
return (
@@ -104,7 +107,11 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
);
})}
</Tab.Panel>
<Tab.Panel as="div" className="w-full items-center gap-1 overflow-y-scroll p-4 text-custom-text-200">
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1 overflow-y-auto pt-3.5 p-4 pr-0 text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle.distribution?.labels?.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}

View File

@@ -1,8 +1,9 @@
import { FC, MouseEvent, useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { observer } from "mobx-react";
// hooks
import { useEventTracker, useCycle, useUser } from "hooks/store";
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
@@ -26,7 +27,7 @@ export interface ICyclesBoardCard {
cycleId: string;
}
export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
@@ -39,6 +40,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
membership: { currentProjectRole },
} = useUser();
const { addCycleToFavorites, removeCycleFromFavorites, getCycleById } = useCycle();
const { getUserDetails } = useMember();
// toast alert
const { setToastAlert } = useToast();
// computed
@@ -69,8 +71,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
? cycleTotalIssues === 0
? "0 Issue"
: cycleTotalIssues === cycleDetails.completed_issues
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue";
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
@@ -211,13 +213,14 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
<LayersIcon className="h-4 w-4 text-custom-text-300" />
<span className="text-xs text-custom-text-300">{issueCount}</span>
</div>
{cycleDetails.assignees.length > 0 && (
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
{cycleDetails.assignee_ids.length > 0 && (
<Tooltip tooltipContent={`${cycleDetails.assignee_ids.length} Members`}>
<div className="flex cursor-default items-center gap-1">
<AvatarGroup showTooltip={false}>
{cycleDetails.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
{cycleDetails.assignee_ids.map((assigne_id) => {
const member = getUserDetails(assigne_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
</div>
</Tooltip>
@@ -295,4 +298,4 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
</Link>
</div>
);
};
});

View File

@@ -7,7 +7,7 @@ import { useUser } from "hooks/store";
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// constants
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle";
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
export interface ICyclesBoard {
cycleIds: string[];
@@ -39,7 +39,7 @@ export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
peekCycle
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all `}
} auto-rows-max transition-all vertical-scrollbar scrollbar-lg`}
>
{cycleIds.map((cycleId) => (
<CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />

View File

@@ -1,8 +1,9 @@
import { FC, MouseEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react";
// hooks
import { useEventTracker, useCycle, useUser } from "hooks/store";
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
@@ -30,7 +31,7 @@ type TCyclesListItem = {
projectId: string;
};
export const CyclesListItem: FC<TCyclesListItem> = (props) => {
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
@@ -43,6 +44,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
membership: { currentProjectRole },
} = useUser();
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember();
// toast alert
const { setToastAlert } = useToast();
@@ -229,13 +231,14 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
</div>
<div className="relative flex flex-shrink-0 items-center gap-3">
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignees.length > 0 ? (
{cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
{cycleDetails.assignee_ids?.map((assigne_id) => {
const member = getUserDetails(assigne_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
@@ -289,4 +292,4 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
</Link>
</>
);
};
});

View File

@@ -9,7 +9,7 @@ import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// ui
import { Loader } from "@plane/ui";
// constants
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle";
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
export interface ICyclesList {
cycleIds: string[];
@@ -37,7 +37,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
{cycleIds.length > 0 ? (
<div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
{cycleIds.map((cycleId) => (
<CyclesListItem
key={cycleId}

View File

@@ -5,7 +5,7 @@ import { useCycle } from "hooks/store";
// components
import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles";
// ui components
import { Loader } from "@plane/ui";
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
// types
import { TCycleLayout, TCycleView } from "@plane/types";
@@ -25,6 +25,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
currentProjectDraftCycleIds,
currentProjectUpcomingCycleIds,
currentProjectCycleIds,
loader,
} = useCycle();
const cyclesList =
@@ -36,55 +37,32 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
? currentProjectUpcomingCycleIds
: currentProjectCycleIds;
if (loader || !cyclesList)
return (
<>
{layout === "list" && <CycleModuleListLayout />}
{layout === "board" && <CycleModuleBoardLayout />}
{layout === "gantt" && <GanttLayoutLoader />}
</>
);
return (
<>
{layout === "list" && (
<>
{cyclesList ? (
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
) : (
<Loader className="space-y-4 p-8">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</>
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
)}
{layout === "board" && (
<>
{cyclesList ? (
<CyclesBoard
cycleIds={cyclesList}
filter={filter}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
) : (
<Loader className="grid grid-cols-1 gap-9 p-8 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
<Loader.Item height="200px" />
<Loader.Item height="200px" />
</Loader>
)}
</>
<CyclesBoard
cycleIds={cyclesList}
filter={filter}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
)}
{layout === "gantt" && (
<>
{cyclesList ? (
<CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />
) : (
<Loader className="space-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</>
)}
{layout === "gantt" && <CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />}
</>
);
});

View File

@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
// components
import { DateDropdown, ProjectDropdown } from "components/dropdowns";
import { DateRangeDropdown, ProjectDropdown } from "components/dropdowns";
// ui
import { Button, Input, TextArea } from "@plane/ui";
// helpers
@@ -32,11 +32,10 @@ export const CycleForm: React.FC<Props> = (props) => {
formState: { errors, isSubmitting, dirtyFields },
handleSubmit,
control,
watch,
reset,
} = useForm<ICycle>({
defaultValues: {
project: projectId,
project_id: projectId,
name: data?.name || "",
description: data?.description || "",
start_date: data?.start_date || null,
@@ -51,23 +50,14 @@ export const CycleForm: React.FC<Props> = (props) => {
});
}, [data, reset]);
const startDate = watch("start_date");
const endDate = watch("end_date");
const minDate = startDate ? new Date(startDate) : new Date();
minDate.setDate(minDate.getDate() + 1);
const maxDate = endDate ? new Date(endDate) : null;
maxDate?.setDate(maxDate.getDate() - 1);
return (
<form onSubmit={handleSubmit((formData)=>handleFormSubmit(formData,dirtyFields))}>
<form onSubmit={handleSubmit((formData) => handleFormSubmit(formData, dirtyFields))}>
<div className="space-y-5">
<div className="flex items-center gap-x-3">
{!status && (
<Controller
control={control}
name="project"
name="project_id"
render={({ field: { value, onChange } }) => (
<ProjectDropdown
value={value}
@@ -132,39 +122,37 @@ export const CycleForm: React.FC<Props> = (props) => {
</div>
<div className="flex flex-wrap items-center gap-2">
<div>
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)}
buttonVariant="border-with-text"
placeholder="Start date"
minDate={new Date()}
maxDate={maxDate ?? undefined}
tabIndex={3}
/>
</div>
)}
/>
</div>
<Controller
control={control}
name="end_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)}
buttonVariant="border-with-text"
placeholder="End date"
minDate={minDate}
tabIndex={4}
/>
</div>
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
buttonVariant="border-with-text"
className="h-7"
minDate={new Date()}
value={{
from: startDateValue ? new Date(startDateValue) : undefined,
to: endDateValue ? new Date(endDateValue) : undefined,
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
hideIcon={{
to: true,
}}
tabIndex={3}
/>
)}
/>
)}
/>
</div>
@@ -172,10 +160,10 @@ export const CycleForm: React.FC<Props> = (props) => {
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-100 pt-5 ">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={5}>
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={4}>
Cancel
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={6}>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}>
{data ? (isSubmitting ? "Updating" : "Update cycle") : isSubmitting ? "Creating" : "Create cycle"}
</Button>
</div>

View File

@@ -40,7 +40,7 @@ export const CycleGanttBlock: React.FC<Props> = observer((props) => {
? "rgb(var(--color-text-200))"
: "",
}}
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)}
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)}
>
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
<Tooltip
@@ -78,7 +78,7 @@ export const CycleGanttSidebarBlock: React.FC<Props> = observer((props) => {
return (
<div
className="relative flex h-full w-full items-center gap-2"
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)}
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)}
>
<ContrastIcon
className="h-5 w-5 flex-shrink-0"

View File

@@ -33,7 +33,7 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
const payload: any = { ...data };
if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder;
await updateCycleDetails(workspaceSlug.toString(), cycle.project, cycle.id, payload);
await updateCycleDetails(workspaceSlug.toString(), cycle.project_id, cycle.id, payload);
};
const blockFormat = (blocks: (ICycle | null)[]) => {

View File

@@ -40,7 +40,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const handleCreateCycle = async (payload: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return;
const selectedProjectId = payload.project ?? projectId.toString();
const selectedProjectId = payload.project_id ?? projectId.toString();
await createCycle(workspaceSlug, selectedProjectId, payload)
.then((res) => {
setToastAlert({
@@ -69,7 +69,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const handleUpdateCycle = async (cycleId: string, payload: Partial<ICycle>, dirtyFields: any) => {
if (!workspaceSlug || !projectId) return;
const selectedProjectId = payload.project ?? projectId.toString();
const selectedProjectId = payload.project_id ?? projectId.toString();
await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload)
.then((res) => {
const changed_properties = Object.keys(dirtyFields);
@@ -155,8 +155,8 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
// if data is present, set active project to the project of the
// issue. This has more priority than the project in the url.
if (data && data.project) {
setActiveProject(data.project);
if (data && data.project_id) {
setActiveProject(data.project_id);
return;
}

View File

@@ -1,8 +1,8 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { useForm } from "react-hook-form";
import { Disclosure, Popover, Transition } from "@headlessui/react";
import { Controller, useForm } from "react-hook-form";
import { Disclosure, Transition } from "@headlessui/react";
import isEmpty from "lodash/isEmpty";
// services
import { CycleService } from "services/cycle.service";
@@ -14,27 +14,12 @@ import { SidebarProgressStats } from "components/core";
import ProgressChart from "components/core/sidebar/progress-chart";
import { CycleDeleteModal } from "components/cycles/delete-modal";
// ui
import { CustomRangeDatePicker } from "components/ui";
import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui";
// icons
import {
ChevronDown,
LinkIcon,
Trash2,
UserCircle2,
AlertCircle,
ChevronRight,
CalendarCheck2,
CalendarClock,
} from "lucide-react";
import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, CalendarClock } from "lucide-react";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
import {
findHowManyDaysLeft,
isDateGreaterThanToday,
renderFormattedPayloadDate,
renderFormattedDate,
} from "helpers/date-time.helper";
import { findHowManyDaysLeft, renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { ICycle } from "@plane/types";
// constants
@@ -42,6 +27,7 @@ import { EUserWorkspaceRoles } from "constants/workspace";
import { CYCLE_UPDATED } from "constants/event-tracker";
// fetch-keys
import { CYCLE_STATUS } from "constants/cycle";
import { DateRangeDropdown } from "components/dropdowns";
type Props = {
cycleId: string;
@@ -61,9 +47,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose } = props;
// states
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
// refs
const startDateButtonRef = useRef<HTMLButtonElement | null>(null);
const endDateButtonRef = useRef<HTMLButtonElement | null>(null);
// router
const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query;
@@ -74,13 +57,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
} = useUser();
const { getCycleById, updateCycleDetails } = useCycle();
const { getUserDetails } = useMember();
// derived values
const cycleDetails = getCycleById(cycleId);
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
// toast alert
const { setToastAlert } = useToast();
const { setValue, reset, watch } = useForm({
// form info
const { control, reset } = useForm({
defaultValues,
});
@@ -145,160 +128,38 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
}
};
const handleStartDateChange = async (date: string) => {
setValue("start_date", date);
const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
if (!startDate || !endDate) return;
if (!watch("end_date") || watch("end_date") === "") endDateButtonRef.current?.click();
let isDateValid = false;
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
setToastAlert({
type: "error",
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
reset({ ...cycleDetails });
return;
}
const payload = {
start_date: renderFormattedPayloadDate(startDate),
end_date: renderFormattedPayloadDate(endDate),
};
if (cycleDetails?.start_date && cycleDetails?.end_date) {
const isDateValidForExistingCycle = await dateChecker({
start_date: `${watch("start_date")}`,
end_date: `${watch("end_date")}`,
cycle_id: cycleDetails.id,
});
if (isDateValidForExistingCycle) {
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"start_date"
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
}
reset({ ...cycleDetails });
return;
}
const isDateValid = await dateChecker({
start_date: `${watch("start_date")}`,
end_date: `${watch("end_date")}`,
if (cycleDetails && cycleDetails.start_date && cycleDetails.end_date)
isDateValid = await dateChecker({
...payload,
cycle_id: cycleDetails.id,
});
else isDateValid = await dateChecker(payload);
if (isDateValid) {
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"start_date"
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
reset({ ...cycleDetails });
}
}
};
const handleEndDateChange = async (date: string) => {
setValue("end_date", date);
if (!watch("start_date") || watch("start_date") === "") startDateButtonRef.current?.click();
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
setToastAlert({
type: "error",
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
reset({ ...cycleDetails });
return;
}
if (cycleDetails?.start_date && cycleDetails?.end_date) {
const isDateValidForExistingCycle = await dateChecker({
start_date: `${watch("start_date")}`,
end_date: `${watch("end_date")}`,
cycle_id: cycleDetails.id,
});
if (isDateValidForExistingCycle) {
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"end_date"
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
}
reset({ ...cycleDetails });
return;
}
const isDateValid = await dateChecker({
start_date: `${watch("start_date")}`,
end_date: `${watch("end_date")}`,
if (isDateValid) {
submitChanges(payload, "date_range");
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
if (isDateValid) {
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"end_date"
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
reset({ ...cycleDetails });
}
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates.",
});
reset({ ...cycleDetails });
}
};
@@ -351,9 +212,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</Loader>
);
const endDate = new Date(watch("end_date") ?? cycleDetails.end_date ?? "");
const startDate = new Date(watch("start_date") ?? cycleDetails.start_date ?? "");
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const issueCount =
@@ -440,125 +298,52 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<CalendarClock className="h-4 w-4" />
<span className="text-base">Start date</span>
<span className="text-base">Date range</span>
</div>
<div className="relative flex w-1/2 items-center rounded-sm">
<Popover className="flex h-full w-full items-center justify-center rounded-lg">
{({ close }) => (
<>
<Popover.Button
ref={startDateButtonRef}
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={isCompleted || !isEditingAllowed}
>
<span
className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${
watch("start_date") ? "" : "text-custom-text-400"
}`}
>
{renderFormattedDate(startDate) ?? "No date selected"}
</span>
</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 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_START_DATE_BUTTON");
handleStartDateChange(val);
close();
}
}}
startDate={watch("start_date") ?? watch("end_date") ?? null}
endDate={watch("end_date") ?? watch("start_date") ?? null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart={watch("end_date") ? true : false}
/>
</Popover.Panel>
</Transition>
</>
<div className="w-3/5 h-7">
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
className="h-7"
buttonContainerClassName="w-full"
buttonVariant="background-with-text"
minDate={new Date()}
value={{
from: startDateValue ? new Date(startDateValue) : undefined,
to: endDateValue ? new Date(endDateValue) : undefined,
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
/>
)}
/>
)}
</Popover>
/>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<CalendarCheck2 className="h-4 w-4" />
<span className="text-base">Target date</span>
</div>
<div className="relative flex w-1/2 items-center rounded-sm">
<Popover className="flex h-full w-full items-center justify-center rounded-lg">
{({ close }) => (
<>
<Popover.Button
ref={endDateButtonRef}
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={isCompleted || !isEditingAllowed}
>
<span
className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${
watch("end_date") ? "" : "text-custom-text-400"
}`}
>
{renderFormattedDate(endDate) ?? "No date selected"}
</span>
</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 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_END_DATE_BUTTON");
handleEndDateChange(val);
close();
}
}}
startDate={watch("start_date") ?? watch("end_date") ?? null}
endDate={watch("end_date") ?? watch("start_date") ?? null}
minDate={new Date(`${watch("start_date")}`)}
selectsEnd={watch("start_date") ? true : false}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<UserCircle2 className="h-4 w-4" />
<span className="text-base">Lead</span>
</div>
<div className="flex w-1/2 items-center rounded-sm">
<div className="flex w-3/5 items-center rounded-sm">
<div className="flex items-center gap-2.5">
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
<span className="text-sm text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
@@ -567,11 +352,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Issues</span>
</div>
<div className="flex w-1/2 items-center">
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
</div>
</div>

View File

@@ -56,7 +56,7 @@ export const TransferIssuesModal: React.FC<Props> = observer((props) => {
const filteredOptions = currentProjectIncompleteCycleIds?.filter((optionId) => {
const cycleDetails = getCycleById(optionId);
return cycleDetails?.name.toLowerCase().includes(query.toLowerCase());
return cycleDetails?.name?.toLowerCase().includes(query?.toLowerCase());
});
// useEffect(() => {

View File

@@ -4,6 +4,7 @@ import { useRouter } from "next/router";
import useSWR from "swr";
import isEmpty from "lodash/isEmpty";
// component
import { Button, TransferIcon } from "@plane/ui";
// icon
@@ -15,12 +16,13 @@ import { CYCLE_DETAILS } from "constants/fetch-keys";
type Props = {
handleClick: () => void;
disabled?: boolean;
};
const cycleService = new CycleService();
export const TransferIssues: React.FC<Props> = (props) => {
const { handleClick } = props;
const { handleClick, disabled = false } = props;
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
@@ -43,9 +45,14 @@ export const TransferIssues: React.FC<Props> = (props) => {
<span>Completed cycles are not editable.</span>
</div>
{transferableIssuesCount > 0 && (
{isEmpty(cycleDetails?.progress_snapshot) && transferableIssuesCount > 0 && (
<div>
<Button variant="primary" prependIcon={<TransferIcon color="white" />} onClick={handleClick}>
<Button
variant="primary"
prependIcon={<TransferIcon color="white" />}
onClick={handleClick}
disabled={disabled}
>
Transfer Issues
</Button>
</div>

View File

@@ -179,7 +179,7 @@ export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = observ
: "-"}
</div>
<div className="text-xs flex justify-center">
{issue.assignee_ids.length > 0 ? (
{issue.assignee_ids && issue.assignee_ids?.length > 0 ? (
<AvatarGroup>
{issue.assignee_ids?.map((assigneeId) => {
const userDetails = getUserDetails(assigneeId);

View File

@@ -14,7 +14,7 @@ import {
IssueListItemProps,
} from "components/dashboard/widgets";
// ui
import { getButtonStyling } from "@plane/ui";
import { Loader, getButtonStyling } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
import { getRedirectionFilters } from "helpers/dashboard.helper";
@@ -63,7 +63,12 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
<>
<div className="h-full">
{isLoading ? (
<></>
<Loader className="space-y-4 mx-6 mt-7">
<Loader.Item height="25px" />
<Loader.Item height="25px" />
<Loader.Item height="25px" />
<Loader.Item height="25px" />
</Loader>
) : issues.length > 0 ? (
<>
<div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1">

View File

@@ -96,7 +96,7 @@ export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
href={`/${workspaceSlug}/projects`}
className="text-lg font-semibold text-custom-text-300 mx-7 hover:underline"
>
Your projects
Recent projects
</Link>
<div className="space-y-8 mt-4 mx-7">
{canCreateProject && (

View File

@@ -10,11 +10,12 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { DropdownButton } from "./buttons";
// icons
import { ContrastIcon } from "@plane/ui";
import { ContrastIcon, CycleGroupIcon } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { TDropdownProps } from "./types";
import { TCycleGroups } from "@plane/types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
@@ -61,6 +62,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -81,17 +83,22 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
router: { workspaceSlug },
} = useApplication();
const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle();
const cycleIds = getProjectCycleIds(projectId);
const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => {
const cycleDetails = getCycleById(cycleId);
return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true;
});
const options: DropdownOptions = cycleIds?.map((cycleId) => {
const cycleDetails = getCycleById(cycleId);
const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
return {
value: cycleId,
query: `${cycleDetails?.name}`,
content: (
<div className="flex items-center gap-2">
<ContrastIcon className="h-3 w-3 flex-shrink-0" />
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="flex-grow truncate">{cycleDetails?.name}</span>
</div>
),
@@ -111,23 +118,15 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
// fetch cycles of the project if not already present in the store
useEffect(() => {
if (!workspaceSlug) return;
if (!cycleIds) fetchAllCycles(workspaceSlug, projectId);
}, [cycleIds, fetchAllCycles, projectId, workspaceSlug]);
const selectedCycle = value ? getCycleById(value) : null;
const onOpen = () => {
if (referenceElement) referenceElement.focus();
if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId);
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
@@ -151,6 +150,12 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -167,7 +172,10 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
<button
ref={setReferenceElement}
type="button"
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
className={cn(
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
buttonContainerClassName
)}
onClick={handleOnClick}
>
{button}
@@ -177,7 +185,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn(
"block h-full max-w-full outline-none",
"clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
@@ -216,6 +224,8 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}

View File

@@ -0,0 +1,261 @@
import React, { useEffect, useRef, useState } from "react";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Placement } from "@popperjs/core";
import { DateRange, DayPicker, Matcher } from "react-day-picker";
import { ArrowRight, CalendarDays } from "lucide-react";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
// components
import { DropdownButton } from "./buttons";
// ui
import { Button } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
import { renderFormattedDate } from "helpers/date-time.helper";
// types
import { TButtonVariants } from "./types";
type Props = {
applyButtonText?: string;
bothRequired?: boolean;
buttonClassName?: string;
buttonContainerClassName?: string;
buttonFromDateClassName?: string;
buttonToDateClassName?: string;
buttonVariant: TButtonVariants;
cancelButtonText?: string;
className?: string;
disabled?: boolean;
hideIcon?: {
from?: boolean;
to?: boolean;
};
icon?: React.ReactNode;
minDate?: Date;
maxDate?: Date;
onSelect: (range: DateRange | undefined) => void;
placeholder?: {
from?: string;
to?: string;
};
placement?: Placement;
required?: boolean;
showTooltip?: boolean;
tabIndex?: number;
value: {
from: Date | undefined;
to: Date | undefined;
};
};
export const DateRangeDropdown: React.FC<Props> = (props) => {
const {
applyButtonText = "Apply changes",
bothRequired = true,
buttonClassName,
buttonContainerClassName,
buttonFromDateClassName,
buttonToDateClassName,
buttonVariant,
cancelButtonText = "Cancel",
className,
disabled = false,
hideIcon = {
from: true,
to: true,
},
icon = <CalendarDays className="h-3 w-3 flex-shrink-0" />,
minDate,
maxDate,
onSelect,
placeholder = {
from: "Add date",
to: "Add date",
},
placement,
required = false,
showTooltip = false,
tabIndex,
value,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [dateRange, setDateRange] = useState<DateRange>(value);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
setDateRange({
from: value.from,
to: value.to,
});
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
const disabledDays: Matcher[] = [];
if (minDate) disabledDays.push({ before: minDate });
if (maxDate) disabledDays.push({ after: maxDate });
useEffect(() => {
setDateRange(value);
}, [value]);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full", className)}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (!isOpen) handleKeyDown(e);
} else handleKeyDown(e);
}}
>
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
},
buttonContainerClassName
)}
onClick={handleOnClick}
>
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading="Date range"
tooltipContent={
<>
{dateRange.from ? renderFormattedDate(dateRange.from) : "N/A"}
{" - "}
{dateRange.to ? renderFormattedDate(dateRange.to) : "N/A"}
</>
}
showTooltip={showTooltip}
variant={buttonVariant}
>
<span
className={cn(
"h-full flex items-center justify-center gap-1 rounded-sm flex-grow",
buttonFromDateClassName
)}
>
{!hideIcon.from && icon}
{dateRange.from ? renderFormattedDate(dateRange.from) : placeholder.from}
</span>
<ArrowRight className="h-3 w-3 flex-shrink-0" />
<span
className={cn(
"h-full flex items-center justify-center gap-1 rounded-sm flex-grow",
buttonToDateClassName
)}
>
{!hideIcon.to && icon}
{dateRange.to ? renderFormattedDate(dateRange.to) : placeholder.to}
</span>
</DropdownButton>
</button>
</Combobox.Button>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg rounded-md overflow-hidden p-3"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DayPicker
selected={dateRange}
onSelect={(val) => {
// if both the dates are not required, immediately call onSelect
if (!bothRequired) onSelect(val);
setDateRange({
from: val?.from ?? undefined,
to: val?.to ?? undefined,
});
}}
mode="range"
disabled={disabledDays}
showOutsideDays
initialFocus
footer={
bothRequired && (
<div className="grid grid-cols-2 items-center gap-3.5 pt-6 relative">
<div className="absolute left-0 top-1 h-[0.5px] w-full border-t-[0.5px] border-custom-border-300" />
<Button
variant="neutral-primary"
onClick={() => {
setDateRange({
from: undefined,
to: undefined,
});
handleClose();
}}
>
{cancelButtonText}
</Button>
<Button
onClick={() => {
onSelect(dateRange);
handleClose();
}}
// if required, both the dates should be selected
// if not required, either both or none of the dates should be selected
disabled={required ? !(dateRange.from && dateRange.to) : !!dateRange.from !== !!dateRange.to}
>
{applyButtonText}
</Button>
</div>
)
}
/>
</div>
</Combobox.Options>
)}
</Combobox>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useRef, useState } from "react";
import { Combobox } from "@headlessui/react";
import DatePicker from "react-datepicker";
import { DayPicker, Matcher } from "react-day-picker";
import { usePopper } from "react-popper";
import { CalendarDays, X } from "lucide-react";
// hooks
@@ -50,6 +50,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
tabIndex,
value,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
@@ -102,18 +103,25 @@ export const DateDropdown: React.FC<Props> = (props) => {
useOutsideClickDetector(dropdownRef, handleClose);
const disabledDays: Matcher[] = [];
if (minDate) disabledDays.push({ before: minDate });
if (maxDate) disabledDays.push({ after: maxDate });
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full", className)}
onKeyDown={handleKeyDown}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (!isOpen) handleKeyDown(e);
} else handleKeyDown(e);
}}
disabled={disabled}
>
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
@@ -123,6 +131,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
},
buttonContainerClassName
)}
ref={setReferenceElement}
onClick={handleOnClick}
>
<DropdownButton
@@ -151,15 +160,22 @@ export const DateDropdown: React.FC<Props> = (props) => {
</Combobox.Button>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div className="my-1" ref={setPopperElement} style={styles.popper} {...attributes.popper}>
<DatePicker
selected={value ? new Date(value) : null}
onChange={dropdownOnChange}
dateFormat="dd-MM-yyyy"
minDate={minDate}
maxDate={maxDate}
calendarClassName="shadow-custom-shadow-rg rounded"
inline
<div
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg rounded-md overflow-hidden p-3"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DayPicker
selected={value ? new Date(value) : undefined}
defaultMonth={value ? new Date(value) : undefined}
onSelect={(date) => {
dropdownOnChange(date ?? null);
}}
showOutsideDays
initialFocus
disabled={disabledDays}
mode="single"
/>
</div>
</Combobox.Options>

View File

@@ -1,4 +1,4 @@
import { Fragment, ReactNode, useRef, useState } from "react";
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
@@ -60,6 +60,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -110,13 +111,11 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
const onOpen = () => {
if (!activeEstimate && workspaceSlug) fetchProjectEstimates(workspaceSlug, projectId);
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
@@ -140,6 +139,12 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -205,6 +210,8 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}

View File

@@ -1,5 +1,6 @@
export * from "./member";
export * from "./cycle";
export * from "./date-range";
export * from "./date";
export * from "./estimate";
export * from "./module";

View File

@@ -1,4 +1,4 @@
import { Fragment, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
@@ -50,6 +50,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -103,13 +104,11 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
const onOpen = () => {
if (!projectMemberIds && workspaceSlug) fetchProjectMembers(workspaceSlug, projectId);
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
@@ -133,6 +132,12 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -177,7 +182,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
>
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate text-sm leading-5">
<span className="flex-grow truncate text-xs leading-5">
{Array.isArray(value) && value.length > 0
? value.length === 1
? getUserDetails(value[0])?.display_name
@@ -203,6 +208,8 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}

View File

@@ -1,4 +1,4 @@
import { Fragment, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
@@ -44,6 +44,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
const [isOpen, setIsOpen] = useState<boolean>(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -91,19 +92,13 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
};
if (multiple) comboboxProps.multiple = true;
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
@@ -122,6 +117,12 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -166,7 +167,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
>
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate text-sm leading-5">
<span className="flex-grow truncate text-xs leading-5">
{Array.isArray(value) && value.length > 0
? value.length === 1
? getUserDetails(value[0])?.display_name
@@ -192,6 +193,8 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}

View File

@@ -77,20 +77,24 @@ const ButtonContent: React.FC<ButtonContentProps> = (props) => {
return (
<>
{showCount ? (
<>
<div className="relative flex items-center gap-1">
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
<span className="flex-grow truncate text-left">
{value.length > 0 ? `${value.length} Module${value.length === 1 ? "" : "s"}` : placeholder}
</span>
</>
<div className="flex-grow truncate max-w-40">
{value.length > 0
? value.length === 1
? `${getModuleById(value[0])?.name || "module"}`
: `${value.length} Module${value.length === 1 ? "" : "s"}`
: placeholder}
</div>
</div>
) : value.length > 0 ? (
<div className="flex items-center gap-2 py-0.5 flex-wrap">
<div className="flex items-center gap-2 py-0.5 max-w-full flex-grow truncate flex-wrap">
{value.map((moduleId) => {
const moduleDetails = getModuleById(moduleId);
return (
<div
key={moduleId}
className="flex items-center gap-1 bg-custom-background-80 text-custom-text-200 rounded px-1.5 py-1"
className="flex items-center gap-1 max-w-full bg-custom-background-80 text-custom-text-200 rounded px-1.5 py-1"
>
{!hideIcon && <DiceIcon className="h-2.5 w-2.5 flex-shrink-0" />}
{!hideText && (
@@ -166,6 +170,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -216,21 +221,13 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
// fetch modules of the project if not already present in the store
useEffect(() => {
if (!workspaceSlug) return;
if (!moduleIds) fetchModules(workspaceSlug, projectId);
}, [moduleIds, fetchModules, projectId, workspaceSlug]);
const onOpen = () => {
if (referenceElement) referenceElement.focus();
if (!moduleIds && workspaceSlug) fetchModules(workspaceSlug, projectId);
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
@@ -261,6 +258,12 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
};
if (multiple) comboboxProps.multiple = true;
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -275,7 +278,10 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
<button
ref={setReferenceElement}
type="button"
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
className={cn(
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
buttonContainerClassName
)}
onClick={handleOnClick}
>
{button}
@@ -285,7 +291,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
"clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
@@ -299,7 +305,12 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
isActive={isOpen}
tooltipHeading="Module"
tooltipContent={
Array.isArray(value) ? `${value?.length ?? 0} module${value?.length !== 1 ? "s" : ""}` : ""
Array.isArray(value)
? `${value
.map((moduleId) => getModuleById(moduleId)?.name)
.toString()
.replaceAll(",", ", ")}`
: ""
}
showTooltip={showTooltip}
variant={buttonVariant}
@@ -331,6 +342,8 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}

View File

@@ -1,4 +1,4 @@
import { Fragment, ReactNode, useRef, useState } from "react";
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Check, ChevronDown, Search } from "lucide-react";
@@ -272,6 +272,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -305,19 +306,13 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
const filteredOptions =
query === "" ? options : options.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
@@ -342,6 +337,12 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
? BackgroundButton
: TransparentButton;
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -409,6 +410,8 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}

View File

@@ -1,4 +1,4 @@
import { Fragment, ReactNode, useRef, useState } from "react";
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
@@ -50,6 +50,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -94,19 +95,13 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
const selectedProject = value ? getProjectById(value) : null;
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
@@ -125,6 +120,12 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -198,6 +199,8 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}

View File

@@ -1,4 +1,4 @@
import { Fragment, ReactNode, useRef, useState } from "react";
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
@@ -52,6 +52,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -92,14 +93,12 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
const onOpen = () => {
if (!statesList && workspaceSlug) fetchProjectStates(workspaceSlug, projectId);
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
@@ -122,6 +121,12 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -193,6 +198,8 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}

View File

@@ -1,21 +1,21 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
import { useTheme } from "next-themes";
// store hooks
import { useEstimate, useProject } from "hooks/store";
import { useEstimate, useProject, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CreateUpdateEstimateModal, DeleteEstimateModal, EstimateListItem } from "components/estimates";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// ui
import { Button, Loader } from "@plane/ui";
import { EmptyState } from "components/common";
// images
import emptyEstimate from "public/empty-state/estimate.svg";
// types
import { IEstimate } from "@plane/types";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// constants
import { PROJECT_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state";
export const EstimatesList: React.FC = observer(() => {
// states
@@ -25,9 +25,12 @@ export const EstimatesList: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { updateProject, currentProjectDetails } = useProject();
const { projectEstimates, getProjectEstimateById } = useEstimate();
const { currentUser } = useUser();
// toast alert
const { setToastAlert } = useToast();
@@ -55,6 +58,10 @@ export const EstimatesList: React.FC = observer(() => {
});
};
const emptyStateDetail = PROJECT_SETTINGS_EMPTY_STATE_DETAILS["estimate"];
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const emptyStateImage = getEmptyStateImagePath("project-settings", "estimates", isLightMode);
return (
<>
<CreateUpdateEstimateModal
@@ -108,19 +115,12 @@ export const EstimatesList: React.FC = observer(() => {
))}
</section>
) : (
<div className="w-full py-8">
<div className="h-full w-full py-8">
<EmptyState
title="No estimates yet"
description="Estimates help you communicate the complexity of an issue."
image={emptyEstimate}
primaryButton={{
icon: <Plus className="h-4 w-4" />,
text: "Add Estimate",
onClick: () => {
setEstimateFormOpen(true);
setEstimateToUpdate(undefined);
},
}}
title={emptyStateDetail.title}
description={emptyStateDetail.description}
image={emptyStateImage}
size="lg"
/>
</div>
)

View File

@@ -3,25 +3,28 @@ import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
import { useTheme } from "next-themes";
import useSWR, { mutate } from "swr";
import { observer } from "mobx-react-lite";
// hooks
import { useUser } from "hooks/store";
import useUserAuth from "hooks/use-user-auth";
// services
import { IntegrationService } from "services/integrations";
// components
import { Exporter, SingleExport } from "components/exporter";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// ui
import { Button, Loader } from "@plane/ui";
import { Button } from "@plane/ui";
import { ImportExportSettingsLoader } from "components/ui";
// icons
import { MoveLeft, MoveRight, RefreshCw } from "lucide-react";
// fetch-keys
import { EXPORT_SERVICES_LIST } from "constants/fetch-keys";
// constants
import { EXPORTERS_LIST } from "constants/workspace";
import { observer } from "mobx-react-lite";
import { useUser } from "hooks/store";
import { WORKSPACE_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state";
// services
const integrationService = new IntegrationService();
@@ -34,6 +37,8 @@ const IntegrationGuide = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, provider } = router.query;
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { currentUser, currentUserLoader } = useUser();
// custom hooks
@@ -46,6 +51,10 @@ const IntegrationGuide = observer(() => {
: null
);
const emptyStateDetail = WORKSPACE_SETTINGS_EMPTY_STATE_DETAILS["export"];
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const emptyStateImage = getEmptyStateImagePath("workspace-settings", "exports", isLightMode);
const handleCsvClose = () => {
router.replace(`/${workspaceSlug?.toString()}/settings/exports`);
};
@@ -140,15 +149,17 @@ const IntegrationGuide = observer(() => {
</div>
</div>
) : (
<p className="px-4 py-6 text-sm text-custom-text-200">No previous export available.</p>
<div className="h-full w-full flex items-center justify-center">
<EmptyState
title={emptyStateDetail.title}
description={emptyStateDetail.description}
image={emptyStateImage}
size="sm"
/>
</div>
)
) : (
<Loader className="mt-6 grid grid-cols-1 gap-3">
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
</Loader>
<ImportExportSettingsLoader />
)}
</div>
</div>

View File

@@ -0,0 +1,106 @@
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "../hooks";
import { useIssueDetail } from "hooks/store";
// components
import { ChartAddBlock, ChartDraggable } from "../helpers";
// helpers
import { cn } from "helpers/common.helper";
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "../types";
// constants
import { BLOCK_HEIGHT } from "../constants";
type Props = {
block: IGanttBlock;
blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableBlockLeftResize: boolean;
enableBlockRightResize: boolean;
enableBlockMove: boolean;
enableAddBlock: boolean;
ganttContainerRef: React.RefObject<HTMLDivElement>;
};
export const GanttChartBlock: React.FC<Props> = observer((props) => {
const {
block,
blockToRender,
blockUpdateHandler,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
enableAddBlock,
ganttContainerRef,
} = props;
// store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart();
const { peekIssue } = useIssueDetail();
const isBlockVisibleOnChart = block.start_date && block.target_date;
const handleChartBlockPosition = (
block: IGanttBlock,
totalBlockShifts: number,
dragDirection: "left" | "right" | "move"
) => {
if (!block.start_date || !block.target_date) return;
const originalStartDate = new Date(block.start_date);
const updatedStartDate = new Date(originalStartDate);
const originalTargetDate = new Date(block.target_date);
const updatedTargetDate = new Date(originalTargetDate);
// update the start date on left resize
if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts);
// update the target date on right resize
else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
// update both the dates on x-axis move
else if (dragDirection === "move") {
updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts);
updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
}
// call the block update handler with the updated dates
blockUpdateHandler(block.data, {
start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined,
target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined,
});
};
return (
<div
key={`block-${block.id}`}
className="relative min-w-full w-max"
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
<div
className={cn("relative h-full", {
"bg-custom-background-80": isBlockActive(block.id),
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
peekIssue?.issueId === block.data.id,
})}
onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)}
>
{isBlockVisibleOnChart ? (
<ChartDraggable
block={block}
blockToRender={blockToRender}
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
ganttContainerRef={ganttContainerRef}
/>
) : (
enableAddBlock && <ChartAddBlock block={block} blockUpdateHandler={blockUpdateHandler} />
)}
</div>
</div>
);
});

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