Compare commits

...

156 Commits

Author SHA1 Message Date
sriramveeraghanta
d136ce48bb i18n setup 2023-10-09 18:26:24 +05:30
sriram veeraghanta
5ac2c270f7 Merge pull request #2385 from makeplane/stage-release
release: stage release to master
2023-10-05 14:52:59 +05:30
sriram veeraghanta
af61054c0b Merge pull request #2384 from makeplane/develop
promote: develop to stage release
2023-10-05 14:03:37 +05:30
guru_sainath
67922f9c5d fix: removed default theme setting in the index page (#2382)
* fix: removed default theme setting in the index page

* fix: empty space
2023-10-05 13:47:51 +05:30
Aaryan Khandelwal
85147db85f fix: state group icons (#2381) 2023-10-05 13:08:27 +05:30
guru_sainath
62035f3bad fix: added user store variables in mobx store observable (#2380) 2023-10-05 12:04:09 +05:30
Aaryan Khandelwal
1c43d313d1 chore: remove unnecessary OAuth envs (#2378)
* chore: remove unnecessary oauth envs

* merge conflicts resolved

* fix: adding new service

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2023-10-04 20:43:05 +05:30
Aaryan Khandelwal
77c1b90e6b feat: default project cover images tab on the change cover popover (#2375)
* feat: default project cover images tab

* chore: remove unnecessary env vars from turbo.json
2023-10-04 20:04:35 +05:30
Nikhil
64af5e2e75 chore: project covers endpoint (#2370)
* chore: project covers endpoint

* dev: remove print logs

* dev: formatting

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2023-10-04 19:53:58 +05:30
Anmol Singh Bhatia
4ec2811388 fix: project setting member role validation (#2369)
* fix: project setting member role validation

* chore: opacity removed from member setting page

* chore: member setting page validation
2023-10-04 19:24:13 +05:30
Dakshesh Jain
9482cc3a73 fix: 404 when redirecting user clicks on Sign In button (#2349)
* fix: 404 when redirecting user to login page

* fix: next_path redirection not working

* fix: authentication workflow update in plane deploy

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-10-04 19:21:04 +05:30
Nikhil
18bcf55f78 dev: configuration endpoint for frontend client (#2355)
* dev: configuration endpoint for frontend clients

* dev: configuration enable magic and email/password signup

* dev: update unsplash keys

* dev: add unsplash API and add  env for magic login
2023-10-04 19:06:38 +05:30
sriram veeraghanta
ea2c1e2d06 fix: login process validation based on api config (#2361) 2023-10-04 18:55:29 +05:30
guru_sainath
48c65c9c95 fix: handle cross project issues in the sub-issues. (#2357) 2023-10-04 18:44:36 +05:30
Aaryan Khandelwal
d0a51d75f6 fix: table menu positioning (#2354) 2023-10-04 18:36:14 +05:30
Aaryan Khandelwal
7bdca9c73a show current version in the help section dropdown (#2353) 2023-10-04 18:31:28 +05:30
guru_sainath
05920a72a5 chore: layout access validation and switch in plane deploy issues route (#2351)
* chore: handled route validation and layout access validation in plane deploy issues

* chore: impoved validation condition
2023-10-04 18:17:34 +05:30
sriram veeraghanta
7a3b556ae0 adding sync info in pr title (#2373) 2023-10-04 18:03:22 +05:30
Bavisetti Narayan
c2f0ae1ec5 chore: removed the issue draft log from my profile (#2368) 2023-10-04 17:47:46 +05:30
Bavisetti Narayan
fb6f6454df chore: member can change role (#2371) 2023-10-04 17:47:01 +05:30
guru_sainath
1e9149d872 fix: themening validation in store init. (#2350) 2023-10-04 17:44:54 +05:30
Nikhil
eacf543439 dev: update apiserver configuration files (#2348)
* dev: update apiserver configuration files

* dev: add email and minio redirection urls
2023-10-04 17:39:12 +05:30
Luis Cruz
981e2aafdd Update index.tsx (#2343)
Fixes #2342
2023-10-04 17:37:01 +05:30
sriram veeraghanta
4017f6bc55 fix: sync job pr description escaped values fix (#2366) 2023-10-04 15:29:45 +05:30
sriram veeraghanta
db1bcdb54f fix: sync workflow fixes (#2365) 2023-10-04 15:07:01 +05:30
Dakshesh Jain
cecdf890de fix: issue relation mutation and draft issue (#2340)
* fix: issue relation mutation and draft issue

* fix: 'New Issue' in gantt view

fix: emoji select going under

* fix: profile page typo
2023-10-04 14:55:41 +05:30
Nikhil
d9bd07886f dev: update sync workflow to run only when the source repo is configured (#2346)
* dev: update sync workflow to run only when the source repo is configured

* fix: naming convention changes

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2023-10-03 18:41:26 +05:30
sriram veeraghanta
0d0cf3052a fix: updated readme fixes (#2339) 2023-10-02 16:13:27 +05:30
Nikhil
6d463ded1c dev: rename token variables (#2338) 2023-10-02 14:50:30 +05:30
Nikhil
de33e6775f dev: update add permissions to the action (#2337) 2023-10-02 14:40:31 +05:30
Nikhil
c96960955b fix: variable name for token (#2336) 2023-10-02 14:31:25 +05:30
sriram veeraghanta
7eba4a5032 fix: ui package readme added (#2334) 2023-10-02 13:40:28 +05:30
Nikhil
c56aacdd13 dev: create action to sync PR changes to the repo (#2333) 2023-10-02 13:28:43 +05:30
sriram veeraghanta
3ff47b38f4 Merge pull request #2319 from makeplane/stage-release
release: stage release to master
2023-09-29 20:12:15 +05:30
sriram veeraghanta
25a973aaf9 Merge pull request #2318 from makeplane/develop
promote: develop to stage release
2023-09-29 20:10:41 +05:30
Anmol Singh Bhatia
ebf79ee2aa fix: workspace view add issue mutation fix (#2317) 2023-09-29 20:02:56 +05:30
sriram veeraghanta
f001852f69 Merge pull request #2316 from makeplane/develop
promote: develop to stage release
2023-09-29 19:25:02 +05:30
Nikhil
6b5fe0fff1 fix: update build workflow for the deploy app (#2315) 2023-09-29 19:14:07 +05:30
Anmol Singh Bhatia
f73135c504 fix: workspace view redirection fix, style: spreadsheet view shadow scroll fix (#2314)
* fix: workspace view redirection fix

* style: spreadsheet view scroll shadow fix
2023-09-29 19:03:36 +05:30
sriram veeraghanta
f751619759 Merge pull request #2312 from makeplane/stage-release
release: stage release to production
2023-09-29 18:08:04 +05:30
sriram veeraghanta
adf49782ba Merge pull request #2311 from makeplane/develop
promote: develop to stage release
2023-09-29 18:07:14 +05:30
sriram veeraghanta
843ba5bb63 fix: unsplash api fix (#2310) 2023-09-29 18:06:33 +05:30
sriram veeraghanta
8d4ac9b430 Merge pull request #2309 from makeplane/stage-release
release: stage release to production
2023-09-29 17:40:17 +05:30
sriram veeraghanta
7c125075b1 Merge pull request #2308 from makeplane/develop
promote: develop to stage release
2023-09-29 16:43:11 +05:30
Anmol Singh Bhatia
4cab00ec79 fix: workspace view filters count fix (#2307) 2023-09-29 16:40:02 +05:30
Bavisetti Narayan
f1879a404d dev: updated migrations for 0.13-dev (#2305)
* chore: epoch migration batch size changed

* chore: reoredered the migration files

* dev: updated migrations for 0.13-dev

* chore: added epoch field

* dev: merged the migration files
2023-09-29 16:12:54 +05:30
sriram veeraghanta
26bb51e686 Merge pull request #2304 from makeplane/develop
promote: develop to stage release
2023-09-29 15:31:31 +05:30
Anmol Singh Bhatia
1b28125919 chore: workspace view order by removed (#2303) 2023-09-29 15:29:50 +05:30
Anmol Singh Bhatia
2a770e4a95 chore: workspace view mutation fix ,bug fixes and code refactor (#2301)
* chore: workspace view mutation fix ,bug fixes and code refactor

* chore: update workspace view toast alert added
2023-09-29 15:05:27 +05:30
sriram veeraghanta
fbf88c3196 Merge pull request #2296 from makeplane/develop
promote: develop to stage release
2023-09-29 12:52:23 +05:30
Anmol Singh Bhatia
459999e8c9 chore: global issues ui improvement and bug fixes (#2300) 2023-09-29 12:36:38 +05:30
Nikhil
6cb4b222d0 chore: label create error (#2299) 2023-09-29 12:27:19 +05:30
Anmol Singh Bhatia
a048e513b7 chore: workspace view display filters and properties , code refactor (#2295)
* chore: spreadsheet view context

* chore: spreadsheet context provider

* chore: spreadsheet view context

* chore: display filters and properties added in workspace view and code refactor

* fix: build error fix

* chore: set sub-issue display option to false for global views

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-09-28 20:34:57 +05:30
Nikhil
4503810aeb fix: notifications for created by me and assigned to me (#2292) 2023-09-28 15:37:42 +05:30
Dakshesh Jain
ec91a0d2e5 fix: ui and bugs (#2289)
* fix: 24 character limit on first & last name in onboarding page

* fix: no option: 'Add Issue' in archive issue page

* fix: in archive issue directly sending to issue detail page

* fix: issue type showing in archive issue

* fix: custom menu overflowing

* fix: changing subscriber in filters has no effect

* style: border in quick-add

* fix: on onboarding member role overflowing

* fix: inconsistent icons in issue detail

* style: spacing, borders and shadows in quick-add

* fix: custom menu truncate
2023-09-28 14:02:03 +05:30
Anmol Singh Bhatia
60a69e28e3 fix: issue activity estimate value bug fix (#2281)
* fix: issue activity estimate value bug fix

* fix: activity typo fix
2023-09-28 13:18:35 +05:30
Aaryan Khandelwal
34af666b5f chore: fetch issues from previous and next month in the calendar view (#2282) 2023-09-28 13:17:46 +05:30
Nikhil
6afbd3f1ba chore: views (#2288)
* chore: global views order by

* chore: update permissions for global views

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-09-28 13:16:31 +05:30
Nikhil
32d2f912f7 fix: inbox issue deletes (#2290) 2023-09-28 13:14:37 +05:30
Bavisetti Narayan
9dd22f07f4 chore: removed project filter (#2284) 2023-09-27 19:16:22 +05:30
Bavisetti Narayan
7404fe71b1 chore: sort order and issue props for global views (#2283) 2023-09-27 17:30:52 +05:30
sriram veeraghanta
dc2b4de95e Merge pull request #2265 from makeplane/stage/dev-release-0-13
promote: Develop to Stage
2023-09-27 15:16:06 +05:30
sriram veeraghanta
62ba8d5e9f Merge branch 'develop' of github.com:makeplane/plane into stage/dev-release-0-13 2023-09-27 15:08:15 +05:30
Nikhil
7b453dd6b5 chore: make target dates inclusive when filtering (#2276) 2023-09-27 15:06:23 +05:30
Bavisetti Narayan
191aecaaac chore: updated created at in draft issue (#2278) 2023-09-27 15:05:49 +05:30
Dakshesh Jain
e00ae0b48a style: calender quick-add same width as single date (#2280)
* style: calender quick-add same width as single date

* style: margin bottom in quick-add in spreadsheet view

* fix: quick add opening in list-layout

* style: reduced margin left
2023-09-27 15:02:35 +05:30
Aaryan Khandelwal
a243bb6a15 chore: gantt chart empty state (#2279)
* chore: gantt empty state

* chore: Add heading to the gantt sidebar
2023-09-27 14:53:26 +05:30
Aaryan Khandelwal
b3be363b00 chore: handle calendar date range in frontend (#2277) 2023-09-27 14:41:32 +05:30
Aaryan Khandelwal
5298f1e53c fix: block click happening while moving (#2275) 2023-09-27 13:08:35 +05:30
Dakshesh Jain
2d8cbccfbc style: gantt layout quick-add padding (#2272)
* fix: 'Last Drafted Issue' making sidebar look weird on collapsed

* feat: scroll to the bottom when issue is created

* fix: 'Add Issue' button overlapping issue card in spreadsheet view

* fix: wrong placement of quick-add in calender layout

* fix: spacing for issue card in spreadsheet view

* style: gantt layout quick-add padding

style: removed 'State group' from draft issue

* style: decrese shadow, quick-add position on calender layout, and 'add issue' sticky

* style: button color
2023-09-27 08:51:29 +05:30
Anmol Singh Bhatia
3a6d72e4b6 feat: workspace global view, style: spreadsheet view revamp (#2273)
* chore: workspace view types, services and hooks added

* style: spreadsheet view revamp and code refactor

* feat: workspace view

* fix: build fix

* chore: sidebar workspace issues redirection updated
2023-09-26 19:56:59 +05:30
sriramveeraghanta
698b42768e Merge branch 'develop' of github.com:makeplane/plane into stage/dev-release-0-13 2023-09-26 19:49:44 +05:30
Aaryan Khandelwal
a187e7765c fix: user dashboard greeting timezone (#2267)
* chore: user greeting timezone

* fix: group by labels not working on workspace level
2023-09-26 18:08:01 +05:30
Thomas
4c333d5767 chore: add instructions to contributing guide (#2270)
* chore: add instructions to contributing guide

* dev: update contributing.md to use the new configuration

---------

Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
2023-09-26 18:06:48 +05:30
Dakshesh Jain
b317a14983 fix: bugs in quick-add and draft issues (#2269)
* fix: 'Last Drafted Issue' making sidebar look weird on collapsed

* feat: scroll to the bottom when issue is created

* fix: 'Add Issue' button overlapping issue card in spreadsheet view

* fix: wrong placement of quick-add in calender layout

* fix: spacing for issue card in spreadsheet view
2023-09-26 17:35:51 +05:30
Bavisetti Narayan
6e0999c35a dev: re-split migrations into two different files (#2268)
* dev: split issue activity migration separate files

* dev: resplit migrations into two different files

* dev: changed the batch size
2023-09-26 16:25:52 +05:30
Bavisetti Narayan
52b57b1e37 dev: migration for 0.13 (#2266)
* dev: updated migrations

* dev: migration for 0.13
2023-09-26 14:18:06 +05:30
Henit Chobisa
88a35efa06 [fix] nginx continuously rewriting and reloading on index page of spaces app (#2236)
* chore: shifted index page to /home route

* chore: added rewrite logic, to rewrite index to /home

* chore: routed home to login route as login page

* chore: updated nginx config to route to login

* chore: updated path for home
2023-09-26 13:46:38 +05:30
sriramveeraghanta
ab028a317b chore: dev merge fixes 2023-09-26 13:32:13 +05:30
Nikhil
d38594376b fix: n+1 queries for cycle list and project member endpoints (#2257) 2023-09-26 13:11:23 +05:30
Nikhil
dae8ca6053 fix: issue automation iterable error (#2208) 2023-09-26 13:11:00 +05:30
Aaryan Khandelwal
6d3bd78052 chore: add tooltip to show full time on activity logs (#2235) 2023-09-26 13:10:28 +05:30
guru_sainath
1ad99873a9 feat: Add peek overview in sub issues and updated UI for empty states. (#2263) 2023-09-26 13:09:08 +05:30
Dakshesh Jain
7db78594dc fix: draft issue delete not working (#2249)
* fix: draft issue not deleting, project can't be changed in draft issue modal

* fix: removed mutation for view where draft issues are not shown

* fix: inline create issue for draft issue

* fix: clearing data from localstorage on discard click
2023-09-25 19:11:10 +05:30
Dakshesh Jain
5e8d523ed4 feat: quick-add placement in spreadsheet and gantt (#2259)
* feat: sticking quick-add at the bottom of the screen

fix: opening create issue modal instead of quick-add in draft-issues, my-issue and profile page

* fix: build error due to dynamic import
2023-09-25 19:08:26 +05:30
Anmol Singh Bhatia
de7a672b79 fix: bug fixes and ui improvement (#2250) 2023-09-25 16:15:49 +05:30
Rhea Jain
0e96eddb57 rename view to layout (#2255)
Co-authored-by: Your Name <you@example.com>
2023-09-25 13:38:49 +05:30
Anmol Singh Bhatia
afa10d7195 fix: sub issue state and member select build error (#2254) 2023-09-25 13:18:03 +05:30
Anmol Singh Bhatia
68c8741f93 fix: bug fix related to fetching dropdown options for the profile issue (#2246) 2023-09-25 12:18:35 +05:30
Bavisetti Narayan
e8d303dd10 chore: changed priority props in workspace and project (#2253) 2023-09-22 19:48:07 +05:30
Anmol Singh Bhatia
c9a6380636 style: settings page improvement (#2211)
* style: settings page improvement

* style: toggle switch styling

---------

Co-authored-by: Anmol Singh Bhatia <asb@Anmols-MacBook-Pro.local>
2023-09-22 18:47:10 +05:30
guru_sainath
1aadbee7e2 fix: resolved pending issue graph in analytics, user wishes in dashboard, and typo in projects list (#2247) 2023-09-22 17:43:23 +05:30
Bavisetti Narayan
02060f654c chore: added epoch in draft (#2244)
* chore: added epoch in draft

* chore: removed extra spaces
2023-09-22 16:32:53 +05:30
Dakshesh Jain
771ca585db feat: quick add (#2240)
* feat: quick add

* style: made text color muted
2023-09-22 15:31:54 +05:30
Nikhil
daa0b16960 fix: cycle and module stats when issues are archived (#2185)
* fix: cycle and module stats when issues are archived

* fix: added draft filter

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-09-22 15:17:31 +05:30
Bavisetti Narayan
0005ff5f99 fix: changed priority from None to none (#2229) 2023-09-22 14:44:53 +05:30
Bavisetti Narayan
0c7b7c4e94 chore: added state and priority order in workspace user profile (#2241) 2023-09-22 14:43:55 +05:30
Nikhil
4d835c5b4a chore: updated docker naming conventions (#2239)
* naming convention changes

* dev: update docker-compose-hub in consistent with docker-compose

* dev: updated docker container name

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-09-22 13:21:55 +05:30
Bavisetti Narayan
c7092edb61 fix: aws region name (#2234) 2023-09-22 13:00:13 +05:30
guru_sainath
73afb8f4d8 fix: issues resolved in sub issues (#2238) 2023-09-21 19:12:20 +05:30
Aaryan Khandelwal
978909c021 fix: profile issues layout switch (#2228) 2023-09-21 16:04:57 +05:30
Aaryan Khandelwal
de9f34cac3 fix: activity label color (#2227) 2023-09-21 16:04:05 +05:30
Aaryan Khandelwal
e3793f4464 fix: handle no issues in custom analytics (#2226) 2023-09-21 16:03:33 +05:30
Aaryan Khandelwal
1621125f6d refactor: product updates modal layout (#2225) 2023-09-21 16:03:06 +05:30
guru_sainath
bd077e6500 Implemented nested issues in the sub issues section in issue detail page (#2233)
* feat: subissues infinte level

* feat: updated UI for sub issues

* feat: subissues new ui and nested sub issues in issue detail

* chore: removed repeated code
2023-09-21 15:39:45 +05:30
Bavisetti Narayan
60ae940d40 chore: sub issues count in individual issue (#2221) 2023-09-20 17:00:03 +05:30
Dakshesh Jain
cdfff12f4f fix: fields not getting selected in the create issue form (#2212)
* fix: hydration error and draft issue workflow

* fix: build error

* fix: properties getting de-selected after create, module & cycle not getting auto-select on the form

* fix: display layout, props being updated directly
2023-09-20 13:06:51 +05:30
Anmol Singh Bhatia
e01a0d20fe chore: dynamic position dropdown (#2138)
* chore: dynamic position state dropdown for issue view

* style: state select dropdown styling

* fix: state icon attribute names

* chore: state select dynamic dropdown

* chore: member select dynamic dropdown

* chore: label select dynamic dropdown

* chore: priority select dynamic dropdown

* chore: label select dropdown improvement

* refactor: state dropdown location

* chore: dropdown improvement and code refactor

* chore: dynamic dropdown hook type added

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-09-20 12:24:52 +05:30
Bavisetti Narayan
63c4792e70 fix: changed time to timestamp (#2217) 2023-09-19 21:36:39 +05:30
Bavisetti Narayan
ce562fa3ea fix: migration files (#2215) 2023-09-19 20:15:02 +05:30
Bavisetti Narayan
a6a0eb9774 chore: added epoch in issue activity (#2187) 2023-09-19 19:46:57 +05:30
Bavisetti Narayan
d603c1e8f0 fix: tracking logs for issue activity (#2213) 2023-09-19 19:46:03 +05:30
Bavisetti Narayan
405ef9314f feat: workspace views (#2005)
* feat: workspace views

* fix: added project member filter

* fix: added pagination in workspace views

* fix: filters and group up by for workspace issues

* fix: changed name workspace view to global view

* fix: reordered the urls
2023-09-19 19:45:37 +05:30
Nikhil
926d2ae0a0 dev: self hosted settings file (#2202)
* dev: self hosted settings file

* dev: add analytics and dockerized variable in settings

* dev: update .env.example and docker compose file also

* dev: self hosted setup minio
2023-09-19 18:30:56 +05:30
M. Palanikannan
11258686ad [fix]: Removing dependency on tiptap pro extension (#2209)
* removing dependency on tiptap pro extension

* updated docs to remove tiptap pro setup instructions

* chore: removed pro extension promt from setup.sh

* chore: Removed Pro Extension Setup from CI

---------

Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
2023-09-19 16:44:12 +05:30
Dakshesh Jain
f6b92fc953 fix: activity not coming for blocking/blocked, 'related to' and duplicate (#2189)
* fix: activity not coming for duplicate, relatesd to and for blocked/blocking

refactor: mutation logic to use relation id instead of issue id

* fix: mutation logic and changed keys to be aligned with api

* fix: build error
2023-09-19 12:58:00 +05:30
Dakshesh Jain
79bf7d4c0c fix: hydration error and draft issue workflow (#2199)
* fix: hydration error and draft issue workflow

* fix: build error
2023-09-19 12:56:32 +05:30
Anmol Singh Bhatia
5d331477ef chore: settings bug fixes and ui improvement (#2198)
* fix: settings bug fixes and ui improvement

* chore: setting sidebar scroll fix & code refactor
2023-09-15 19:30:53 +05:30
sriram veeraghanta
3d72279edb Merge pull request #2196 from makeplane/fix/bug_fix
fix: document bug fix
2023-09-15 15:42:43 +05:30
Anmol Singh Bhatia
c107b36d34 fix: document bug fix 2023-09-15 15:41:10 +05:30
Anmol Singh Bhatia
ccffbe1b4e style: workspace and profile setting revamp (#2193)
* chore: custom theme mode svg added

* style: workspace settings ui revamp

* style: project settings and image upload modal improvement

* style: profile setting ui revamp

* chore: settings ui improvement and bug fixes
2023-09-15 15:03:32 +05:30
Bavisetti Narayan
9bfdcff44d chore: changed old values (#2194) 2023-09-15 14:18:30 +05:30
Bavisetti Narayan
b274a21ca5 chore: changed issue relation history logs (#2192)
* chore: changed issue relation history logs

* chore: change field name
2023-09-15 13:12:28 +05:30
Dakshesh Jain
32d945be0d fix: edit/delete for draft issue (#2190)
* fix: edit/delete

* fix: build issue

* fix: draft issue modal opening in kanban card
2023-09-15 12:51:10 +05:30
sriram veeraghanta
2bc05cc7b7 Merge pull request #2157 from makeplane/stage-release
release: self hosting fixes
2023-09-12 21:50:34 +05:30
sriram veeraghanta
14fe545709 Merge pull request #2156 from makeplane/stage/self-hosted-fixes
fix: selfhosted fixes
2023-09-12 21:06:01 +05:30
sriram veeraghanta
bc99ec0f1d fix: selfhosted fixes (#2154)
* fix: selfhosted fixes

* fix: updated env example
2023-09-12 21:03:42 +05:30
sriram veeraghanta
cc63f67654 Merge pull request #2143 from makeplane/stage-release
promote: stage-release to master
2023-09-11 18:21:51 +05:30
sriram veeraghanta
b8dd9ca729 Merge pull request #2144 from makeplane/develop
promote: develop to stage release
2023-09-11 18:11:30 +05:30
Nikhil
73b360c2fd Merge pull request #2140 from makeplane/develop
promote: develop to stage-release
2023-09-11 14:39:11 +05:30
sriram veeraghanta
80bcca71ff Merge pull request #2091 from makeplane/stage-release
promote: stage release to master
2023-09-04 19:14:29 +05:30
sriram veeraghanta
3db0ec819a Merge pull request #2090 from makeplane/develop
Promote: Develop to Stage Release
2023-09-04 18:53:41 +05:30
sriram veeraghanta
414ea7371d Merge pull request #2085 from makeplane/develop
Promote: Develop to Stage Release
2023-09-04 18:16:47 +05:30
sriram veeraghanta
2b84b7c18d Merge pull request #2076 from makeplane/develop
Promote: Develop to Stage Release
2023-09-04 14:48:53 +05:30
sriram veeraghanta
674347c99e Merge pull request #2060 from makeplane/develop
Promote: Develop to Stage Release
2023-09-01 20:56:31 +05:30
sriram veeraghanta
c1102180e6 Merge pull request #2059 from makeplane/develop
Promote: Develop to Stage Release
2023-09-01 20:53:35 +05:30
sriram veeraghanta
650c0c3b78 promote: develop to stage-release (#2045)
promote: develop to stage-release
2023-09-01 17:23:26 +05:30
sriram veeraghanta
6f397710ce Merge pull request #1951 from makeplane/stage-release
Promote: Stage release to Production
2023-08-23 16:18:08 +05:30
sriram veeraghanta
e6bd6b6a8c Merge pull request #1950 from makeplane/develop
Promote: Develop to Stage Release
2023-08-23 15:49:14 +05:30
sriram veeraghanta
9d3952006b Merge pull request #1948 from makeplane/stage-release
Promot: Stage Release to Production
2023-08-23 14:51:27 +05:30
sriram veeraghanta
b75473a684 Merge pull request #1947 from makeplane/develop
Promote: Develop to Stage Release
2023-08-23 12:37:25 +05:30
Nikhil
2f5bd58c61 Merge pull request #1941 from makeplane/stage-release
promote: stage-release to master
2023-08-22 19:37:49 +05:30
Nikhil
e833fccf61 Merge pull request #1940 from makeplane/develop
dev: deploy docker containers (#1939)
2023-08-22 19:34:13 +05:30
Nikhil
62ba9abdb4 Merge pull request #1936 from makeplane/stage-release
promote: stage-release to master
2023-08-22 13:57:57 +05:30
Nikhil
46b138eb0b Merge pull request #1934 from makeplane/develop
fix: aws region changed for exporter (#1933)
2023-08-22 13:51:20 +05:30
Nikhil
59bdf222f5 Merge pull request #1932 from makeplane/stage-release
promote: stage release to master
2023-08-22 01:34:37 +05:30
Nikhil
eb50ade5e3 Merge pull request #1931 from makeplane/develop
fix: access environment variables is changed in services (#1930)
2023-08-22 01:14:56 +05:30
Vamsi Kurama
85a08e4abd Merge pull request #1929 from makeplane/stage-release 2023-08-22 00:17:29 +05:30
Nikhil
aa2e1697b0 Merge pull request #1928 from makeplane/develop
promote: develop to stage-release
2023-08-21 20:51:46 +05:30
Nikhil
3beab9de6f Merge pull request #1923 from makeplane/develop
promote: develop to stage-release
2023-08-21 18:18:14 +05:30
Nikhil
13d21e752d Merge pull request #1910 from makeplane/develop
promote: develop to stage-release
2023-08-18 19:29:11 +05:30
Vamsi Kurama
9ff8994c0e Merge pull request #1784 from makeplane/stage-release
promote: stage-release to master v0.10.1-patch
2023-08-03 18:55:24 +05:30
Vamsi Kurama
3488001197 Merge pull request #1762 from makeplane/stage-release
promote: stage-release to master
2023-08-01 22:38:20 +05:30
Vamsi Kurama
2ced7e4911 Merge pull request #1758 from makeplane/stage-release
promote: stage-release to master v0.10
2023-08-01 20:04:40 +05:30
Aaryan Khandelwal
e5a3bec28c Merge pull request #1709 from makeplane/stage-release
promote: stage-release to master
2023-07-28 19:47:04 +05:30
Vamsi Kurama
e930f8cc7b Merge pull request #1697 from makeplane/stage-release
promote: stage-release to master
2023-07-28 00:55:32 +05:30
314 changed files with 14502 additions and 4770 deletions

View File

@@ -33,14 +33,9 @@ jobs:
deploy:
- space/**
- name: Setup .npmrc for repository
run: |
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
- name: Build Plane's Main App
if: steps.changed-files.outputs.web_any_changed == 'true'
run: |
mv ./.npmrc ./web
cd web
yarn
yarn build

77
.github/workflows/create-sync-pr.yml vendored Normal file
View File

@@ -0,0 +1,77 @@
name: Create PR in Plane EE Repository to sync the changes
on:
pull_request:
types:
- closed
jobs:
create_pr:
# Only run the job when a PR is merged
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Check SOURCE_REPO
id: check_repo
env:
SOURCE_REPO: ${{ secrets.SOURCE_REPO_NAME }}
run: |
echo "::set-output name=is_correct_repo::$(if [[ "$SOURCE_REPO" == "makeplane/plane" ]]; then echo 'true'; else echo 'false'; fi)"
- name: Checkout Code
if: steps.check_repo.outputs.is_correct_repo == 'true'
uses: actions/checkout@v2
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Branch Name
if: steps.check_repo.outputs.is_correct_repo == 'true'
run: |
echo "SOURCE_BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
- name: Setup GH CLI
if: steps.check_repo.outputs.is_correct_repo == 'true'
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
- name: Create Pull Request
if: steps.check_repo.outputs.is_correct_repo == 'true'
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ secrets.TARGET_REPO_NAME }}"
TARGET_BRANCH="${{ secrets.TARGET_REPO_BRANCH }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target $SOURCE_BRANCH:$SOURCE_BRANCH
PR_TITLE="${{ github.event.pull_request.title }}"
PR_BODY="${{ github.event.pull_request.body }}"
# Remove double quotes
PR_TITLE_CLEANED="${PR_TITLE//\"/}"
PR_BODY_CLEANED="${PR_BODY//\"/}"
# Construct PR_BODY_CONTENT using a here-document
PR_BODY_CONTENT=$(cat <<EOF
$PR_BODY_CLEANED
EOF
)
gh pr create \
--base $TARGET_BRANCH \
--head $SOURCE_BRANCH \
--title "[SYNC] $PR_TITLE_CLEANED" \
--body "$PR_BODY_CONTENT" \
--repo $TARGET_REPO

View File

@@ -22,10 +22,6 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup .npmrc for repository
run: |
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaFrontend
uses: docker/metadata-action@v4.3.0
@@ -43,10 +39,10 @@ jobs:
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaDeploy
id: metaSpace
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-deploy
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space
tags: |
type=ref,event=tag
@@ -91,7 +87,7 @@ jobs:
file: ./space/Dockerfile.space
platforms: linux/amd64
push: true
tags: ${{ steps.metaDeploy.outputs.tags }}
tags: ${{ steps.metaSpace.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}

View File

@@ -8,8 +8,8 @@ Before submitting a new issue, please search the [issues](https://github.com/mak
While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like:
- 3rd-party libraries being used and their versions
- a use-case that fails
- 3rd-party libraries being used and their versions
- a use-case that fails
Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved.
@@ -19,10 +19,10 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
### Requirements
- Node.js version v16.18.0
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
- Node.js version v16.18.0
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
### Setup the project
@@ -30,6 +30,48 @@ The project is a monorepo, with backend api and frontend in a single repo.
The backend is a django project which is kept inside apiserver
1. Clone the repo
```bash
git clone https://github.com/makeplane/plane
cd plane
chmod +x setup.sh
```
2. Run setup.sh
```bash
./setup.sh
```
3. Define `NEXT_PUBLIC_API_BASE_URL=http://localhost` in **web/.env** and **space/.env** file
```bash
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./web/.env
```
```bash
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./space/.env
```
4. Run Docker compose up
```bash
docker compose up -d
```
5. Install dependencies
```bash
yarn install
```
6. Run the web app in development mode
```bash
yarn dev
```
## Missing a Feature?
If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository.
@@ -39,8 +81,8 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
## Need help? Questions and suggestions
@@ -48,11 +90,11 @@ Questions, suggestions, and thoughts are most welcome. We can also be reached in
## Ways to contribute
- Try Plane Cloud and the self hosting platform and give feedback
- Add new integrations
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
- Share your thoughts and suggestions with us
- Help create tutorials and blog posts
- Request a feature by submitting a proposal
- Report a bug
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
- Try Plane Cloud and the self hosting platform and give feedback
- Add new integrations
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
- Share your thoughts and suggestions with us
- Help create tutorials and blog posts
- Request a feature by submitting a proposal
- Report a bug
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.

View File

@@ -54,22 +54,11 @@ chmod +x setup.sh
- Run setup.sh
```bash
./setup.sh http://localhost
./setup.sh
```
> If running in a cloud env replace localhost with public facing IP address of the VM
- Setup Tiptap Pro
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
```
@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
```
- Run Docker compose up
```bash

View File

@@ -1,6 +1,7 @@
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
DJANGO_SETTINGS_MODULE="plane.settings.production"
# Error logs
SENTRY_DSN=""
@@ -58,3 +59,14 @@ DEFAULT_PASSWORD="password123"
# SignUps
ENABLE_SIGNUP="1"
# Enable Email/Password Signup
ENABLE_EMAIL_PASSWORD="1"
# Enable Magic link Login
ENABLE_MAGIC_LINK_LOGIN="0"
# Email redirections and minio domain settings
WEB_URL="http://localhost"

View File

@@ -58,8 +58,17 @@ class WorkspaceEntityPermission(BasePermission):
if request.user.is_anonymous:
return False
## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS:
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
).exists()
return WorkspaceMember.objects.filter(
member=request.user, workspace__slug=view.workspace_slug
member=request.user,
workspace__slug=view.workspace_slug,
role__in=[Owner, Admin],
).exists()

View File

@@ -23,7 +23,7 @@ from .project import (
ProjectPublicMemberSerializer
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer
from .asset import FileAssetSerializer
from .issue import (

View File

@@ -34,7 +34,6 @@ class CycleSerializer(BaseSerializer):
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
assignees = serializers.SerializerMethodField(read_only=True)
labels = serializers.SerializerMethodField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
@@ -50,11 +49,10 @@ class CycleSerializer(BaseSerializer):
members = [
{
"avatar": assignee.avatar,
"first_name": assignee.first_name,
"display_name": assignee.display_name,
"id": assignee.id,
}
for issue_cycle in obj.issue_cycle.all()
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
@@ -64,24 +62,6 @@ class CycleSerializer(BaseSerializer):
unique_list = [dict(item) for item in unique_objects]
return unique_list
def get_labels(self, obj):
labels = [
{
"name": label.name,
"color": label.color,
"id": label.id,
}
for issue_cycle in obj.issue_cycle.all()
for label in issue_cycle.issue.labels.all()
]
# Use a set comprehension to return only the unique objects
unique_objects = {frozenset(item.items()) for item in labels}
# Convert the set back to a list of dictionaries
unique_list = [dict(item) for item in unique_objects]
return unique_list
class Meta:
model = Cycle

View File

@@ -293,12 +293,12 @@ class IssueLabelSerializer(BaseSerializer):
class IssueRelationSerializer(BaseSerializer):
related_issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
class Meta:
model = IssueRelation
fields = [
"related_issue_detail",
"issue_detail",
"relation_type",
"related_issue",
"issue",

View File

@@ -5,10 +5,39 @@ from rest_framework import serializers
from .base import BaseSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import IssueView, IssueViewFavorite
from plane.db.models import GlobalView, IssueView, IssueViewFavorite
from plane.utils.issue_filters import issue_filters
class GlobalViewSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = GlobalView
fields = "__all__"
read_only_fields = [
"workspace",
"query",
]
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
return GlobalView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
class IssueViewSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)

View File

@@ -70,6 +70,7 @@ from plane.api.views import (
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
## End Projects
# Issues
IssueViewSet,
@@ -102,6 +103,8 @@ from plane.api.views import (
BulkEstimatePointEndpoint,
## End Estimates
# Views
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewViewSet,
ViewIssuesEndpoint,
IssueViewFavoriteViewSet,
@@ -148,12 +151,11 @@ from plane.api.views import (
GlobalSearchEndpoint,
IssueSearchEndpoint,
## End Search
# Gpt
# External
GPTIntegrationEndpoint,
## End Gpt
# Release Notes
ReleaseNotesEndpoint,
## End Release Notes
UnsplashEndpoint,
## End External
# Inbox
InboxViewSet,
InboxIssueViewSet,
@@ -184,7 +186,9 @@ from plane.api.views import (
## Exporter
ExportIssuesEndpoint,
## End Exporter
# Configuration
ConfigurationEndpoint,
## End Configuration
)
@@ -241,7 +245,11 @@ urlpatterns = [
UpdateUserTourCompletedEndpoint.as_view(),
name="user-tour",
),
path("users/workspaces/<str:slug>/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
path(
"users/workspaces/<str:slug>/activities/",
UserActivityEndpoint.as_view(),
name="user-activities",
),
# user workspaces
path(
"users/me/workspaces/",
@@ -568,6 +576,11 @@ urlpatterns = [
LeaveProjectEndpoint.as_view(),
name="project",
),
path(
"project-covers/",
ProjectPublicCoverImagesEndpoint.as_view(),
name="project-covers",
),
# End Projects
# States
path(
@@ -649,6 +662,37 @@ urlpatterns = [
ViewIssuesEndpoint.as_view(),
name="project-view-issues",
),
path(
"workspaces/<str:slug>/views/",
GlobalViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/",
GlobalViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/issues/",
GlobalViewIssuesViewSet.as_view(
{
"get": "list",
}
),
name="global-view-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
IssueViewFavoriteViewSet.as_view(
@@ -767,11 +811,6 @@ urlpatterns = [
),
name="project-issue",
),
path(
"workspaces/<str:slug>/issues/",
WorkSpaceIssuesEndpoint.as_view(),
name="workspace-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelViewSet.as_view(
@@ -1415,20 +1454,23 @@ urlpatterns = [
name="project-issue-search",
),
## End Search
# Gpt
# External
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
GPTIntegrationEndpoint.as_view(),
name="importer",
),
## End Gpt
# Release Notes
path(
"release-notes/",
ReleaseNotesEndpoint.as_view(),
name="release-notes",
),
## End Release Notes
path(
"unsplash/",
UnsplashEndpoint.as_view(),
name="release-notes",
),
## End External
# Inbox
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
@@ -1697,4 +1739,11 @@ urlpatterns = [
name="workspace-project-boards",
),
## End Public Boards
# Configuration
path(
"configs/",
ConfigurationEndpoint.as_view(),
name="configuration",
),
## End Configuration
]

View File

@@ -17,6 +17,7 @@ from .project import (
ProjectMemberEndpoint,
WorkspaceProjectDeployBoardEndpoint,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
)
from .user import (
UserEndpoint,
@@ -56,7 +57,7 @@ from .workspace import (
LeaveWorkspaceEndpoint,
)
from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .cycle import (
CycleViewSet,
CycleIssueViewSet,
@@ -147,16 +148,13 @@ from .page import (
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .gpt import GPTIntegrationEndpoint
from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint
from .estimate import (
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
)
from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
from .analytic import (
@@ -169,4 +167,6 @@ from .analytic import (
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
from .exporter import ExportIssuesEndpoint
from .exporter import ExportIssuesEndpoint
from .config import ConfigurationEndpoint

View File

@@ -0,0 +1,40 @@
# Python imports
import os
# Django imports
from django.conf import settings
# Third party imports
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from .base import BaseAPIView
class ConfigurationEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request):
try:
data = {}
data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None)
data["github"] = os.environ.get("GITHUB_CLIENT_ID", None)
data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None)
data["magic_login"] = (
bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD)
) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1"
data["email_password_login"] = (
os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1"
)
return Response(data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -80,6 +80,7 @@ class CycleViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -101,48 +102,84 @@ class CycleViewSet(BaseViewSet):
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.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"),
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"),
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"),
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"),
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"),
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"),
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"),
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.prefetch_related(
@@ -195,17 +232,30 @@ class CycleViewSet(BaseViewSet):
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(total_issues=Count("assignee_id"))
.annotate(
total_issues=Count(
"assignee_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
@@ -221,17 +271,30 @@ class CycleViewSet(BaseViewSet):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id"))
.annotate(
total_issues=Count(
"label_id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"label_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
@@ -384,17 +447,30 @@ class CycleViewSet(BaseViewSet):
.values(
"first_name", "last_name", "assignee_id", "avatar", "display_name"
)
.annotate(total_issues=Count("assignee_id"))
.annotate(
total_issues=Count(
"assignee_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
@@ -411,17 +487,30 @@ class CycleViewSet(BaseViewSet):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id"))
.annotate(
total_issues=Count(
"label_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"label_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
@@ -487,6 +576,7 @@ class CycleIssueViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -662,6 +752,7 @@ class CycleIssueViewSet(BaseViewSet):
),
}
),
epoch=int(timezone.now().timestamp())
)
# Return all Cycle Issues

View File

@@ -2,9 +2,10 @@
import requests
# Third party imports
import openai
from rest_framework.response import Response
from rest_framework import status
import openai
from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception
# Django imports
@@ -15,6 +16,7 @@ from .base import BaseAPIView
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Workspace, Project
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.utils.integrations.github import get_release_notes
class GPTIntegrationEndpoint(BaseAPIView):
@@ -73,3 +75,44 @@ class GPTIntegrationEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ReleaseNotesEndpoint(BaseAPIView):
def get(self, request):
try:
release_notes = get_release_notes()
return Response(release_notes, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
try:
query = request.GET.get("query", False)
page = request.GET.get("page", 1)
per_page = request.GET.get("per_page", 20)
url = (
f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
if query
else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
)
headers = {
"Content-Type": "application/json",
}
resp = requests.get(url=url, headers=headers)
return Response(resp.json(), status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -384,7 +384,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
sort_order=largest_sort_order,
start_date=issue_data.get("start_date", None),
target_date=issue_data.get("target_date", None),
priority=issue_data.get("priority", None),
priority=issue_data.get("priority", "none"),
created_by=request.user,
)
)

View File

@@ -173,12 +173,12 @@ class InboxIssueViewSet(BaseViewSet):
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [
if not request.data.get("issue", {}).get("priority", "none") in [
"low",
"medium",
"high",
"urgent",
None,
"none",
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
@@ -213,6 +213,7 @@ class InboxIssueViewSet(BaseViewSet):
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
# create an inbox issue
InboxIssue.objects.create(
@@ -277,6 +278,7 @@ class InboxIssueViewSet(BaseViewSet):
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
issue_serializer.save()
else:
@@ -368,6 +370,11 @@ class InboxIssueViewSet(BaseViewSet):
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
# Check the issue status
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id).delete()
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except InboxIssue.DoesNotExist:
@@ -478,12 +485,12 @@ class InboxIssuePublicViewSet(BaseViewSet):
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [
if not request.data.get("issue", {}).get("priority", "none") in [
"low",
"medium",
"high",
"urgent",
None,
"none",
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
@@ -518,6 +525,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
# create an inbox issue
InboxIssue.objects.create(
@@ -582,6 +590,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
issue_serializer.save()
return Response(issue_serializer.data, status=status.HTTP_200_OK)

View File

@@ -4,6 +4,7 @@ import random
from itertools import chain
# Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
@@ -23,7 +24,6 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError
from django.conf import settings
from django.db import IntegrityError
# Third Party imports
@@ -53,10 +53,10 @@ from plane.api.serializers import (
CommentReactionSerializer,
IssueVoteSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
)
from plane.api.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
WorkSpaceAdminPermission,
ProjectMemberPermission,
@@ -128,6 +128,7 @@ class IssueViewSet(BaseViewSet):
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@@ -148,6 +149,7 @@ class IssueViewSet(BaseViewSet):
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -314,6 +316,7 @@ class IssueViewSet(BaseViewSet):
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -325,7 +328,12 @@ class IssueViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, pk=None):
try:
issue = Issue.issue_objects.get(
issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
).get(
workspace__slug=slug, project_id=project_id, pk=pk
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
@@ -567,6 +575,7 @@ class IssueCommentViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
def perform_update(self, serializer):
@@ -585,6 +594,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@@ -606,6 +616,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -701,10 +712,18 @@ class LabelViewSet(BaseViewSet):
ProjectMemberPermission,
]
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
)
def create(self, request, slug, project_id):
try:
serializer = LabelSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response({"error": "Label with the same name already exists in the project"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
def get_queryset(self):
return self.filter_queryset(
@@ -889,6 +908,7 @@ class IssueLinkViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
def perform_update(self, serializer):
@@ -907,6 +927,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@@ -928,6 +949,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -1006,6 +1028,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
serializer.data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1028,6 +1051,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1230,6 +1254,7 @@ class IssueArchiveViewSet(BaseViewSet):
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
@@ -1434,6 +1459,7 @@ class IssueReactionViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
@@ -1457,6 +1483,7 @@ class IssueReactionViewSet(BaseViewSet):
"identifier": str(issue_reaction.id),
}
),
epoch=int(timezone.now().timestamp())
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1505,6 +1532,7 @@ class CommentReactionViewSet(BaseViewSet):
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
@@ -1529,6 +1557,7 @@ class CommentReactionViewSet(BaseViewSet):
"comment_id": str(comment_id),
}
),
epoch=int(timezone.now().timestamp())
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1625,6 +1654,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
if not ProjectMember.objects.filter(
project_id=project_id,
@@ -1674,6 +1704,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1707,6 +1738,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1781,6 +1813,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1825,6 +1858,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
"identifier": str(issue_reaction.id),
}
),
epoch=int(timezone.now().timestamp())
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1898,6 +1932,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1949,6 +1984,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
"comment_id": str(comment_id),
}
),
epoch=int(timezone.now().timestamp())
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -2012,6 +2048,7 @@ class IssueVotePublicViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -2046,6 +2083,7 @@ class IssueVotePublicViewSet(BaseViewSet):
"identifier": str(issue_vote.id),
}
),
epoch=int(timezone.now().timestamp())
)
issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -2079,15 +2117,17 @@ class IssueRelationViewSet(BaseViewSet):
IssueRelationSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
def create(self, request, slug, project_id, issue_id):
try:
related_list = request.data.get("related_list", [])
relation = request.data.get("relation", None)
project = Project.objects.get(pk=project_id)
issueRelation = IssueRelation.objects.bulk_create(
issue_relation = IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=related_issue["issue"],
@@ -2111,12 +2151,19 @@ class IssueRelationViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(
IssueRelationSerializer(issueRelation, many=True).data,
status=status.HTTP_201_CREATED,
)
if relation == "blocking":
return Response(
RelatedIssueSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
else:
return Response(
IssueRelationSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
@@ -2149,6 +2196,8 @@ class IssueRelationViewSet(BaseViewSet):
.select_related("issue")
.distinct()
)
class IssueRetrievePublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
@@ -2357,26 +2406,6 @@ class IssueDraftViewSet(BaseViewSet):
]
serializer_class = IssueFlatSerializer
model = Issue
def perform_update(self, serializer):
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
type="issue_draft.activity.updated",
requested_data=requested_data,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
)
return super().perform_update(serializer)
def perform_destroy(self, instance):
@@ -2395,6 +2424,7 @@ class IssueDraftViewSet(BaseViewSet):
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -2558,6 +2588,7 @@ class IssueDraftViewSet(BaseViewSet):
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -2568,6 +2599,47 @@ class IssueDraftViewSet(BaseViewSet):
)
def partial_update(self, request, slug, project_id, pk):
try:
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
serializer = IssueSerializer(
issue, data=request.data, partial=True
)
if serializer.is_valid():
if(request.data.get("is_draft") is not None and not request.data.get("is_draft")):
serializer.save(created_at=timezone.now(), updated_at=timezone.now())
else:
serializer.save()
issue_activity.delay(
type="issue_draft.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(issue).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Issue.DoesNotExist:
return Response(
{"error": "Issue does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, pk=None):
try:
issue = Issue.objects.get(

View File

@@ -2,6 +2,7 @@
import json
# Django Imports
from django.utils import timezone
from django.db import IntegrityError
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
from django.core import serializers
@@ -39,6 +40,7 @@ from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.utils.analytics_plot import burndown_plot
class ModuleViewSet(BaseViewSet):
model = Module
permission_classes = [
@@ -77,35 +79,63 @@ class ModuleViewSet(BaseViewSet):
queryset=ModuleLink.objects.select_related("module", "created_by"),
)
)
.annotate(total_issues=Count("issue_module"))
.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"),
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"),
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"),
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"),
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"),
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.order_by(order_by, "name")
@@ -129,6 +159,7 @@ class ModuleViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -177,18 +208,36 @@ class ModuleViewSet(BaseViewSet):
.annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.annotate(total_issues=Count("assignee_id"))
.values(
"first_name", "last_name", "assignee_id", "avatar", "display_name"
)
.annotate(
total_issues=Count(
"assignee_id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
@@ -204,17 +253,33 @@ class ModuleViewSet(BaseViewSet):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id"))
.annotate(
total_issues=Count(
"label_id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
"label_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
@@ -277,6 +342,7 @@ class ModuleIssueViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -444,6 +510,7 @@ class ModuleIssueViewSet(BaseViewSet):
),
}
),
epoch=int(timezone.now().timestamp())
)
return Response(
@@ -490,7 +557,6 @@ class ModuleLinkViewSet(BaseViewSet):
class ModuleFavoriteViewSet(BaseViewSet):
serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite

View File

@@ -1,5 +1,6 @@
# Python imports
import jwt
import boto3
from datetime import datetime
# Django imports
@@ -495,7 +496,7 @@ class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberAdminSerializer
model = ProjectMember
permission_classes = [
ProjectBasePermission,
ProjectMemberPermission,
]
search_fields = [
@@ -617,7 +618,8 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
except ProjectMember.DoesNotExist:
return Response(
{"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Project Member does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
@@ -1094,7 +1096,7 @@ class ProjectMemberEndpoint(BaseAPIView):
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member")
).select_related("project", "member", "workspace")
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
@@ -1209,3 +1211,38 @@ class LeaveProjectEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request):
try:
files = []
s3 = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
params = {
"Bucket": settings.AWS_S3_BUCKET_NAME,
"Prefix": "static/project-cover/",
}
response = s3.list_objects_v2(**params)
# Extracting file keys from the response
if "Contents" in response:
for content in response["Contents"]:
if not content["Key"].endswith(
"/"
): # This line ensures we're only getting files, not "sub-folders"
files.append(
f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
return Response(files, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response([], status=status.HTTP_200_OK)

View File

@@ -1,21 +0,0 @@
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from .base import BaseAPIView
from plane.utils.integrations.github import get_release_notes
class ReleaseNotesEndpoint(BaseAPIView):
def get(self, request):
try:
release_notes = get_release_notes()
return Response(release_notes, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -1,4 +1,18 @@
# Django imports
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError
from django.db.models import Prefetch, OuterRef, Exists
@@ -10,18 +24,192 @@ from sentry_sdk import capture_exception
# Module imports
from . import BaseViewSet, BaseAPIView
from plane.api.serializers import (
GlobalViewSerializer,
IssueViewSerializer,
IssueLiteSerializer,
IssueViewFavoriteSerializer,
)
from plane.api.permissions import ProjectEntityPermission
from plane.api.permissions import WorkspaceEntityPermission, ProjectEntityPermission
from plane.db.models import (
Workspace,
GlobalView,
IssueView,
Issue,
IssueViewFavorite,
IssueReaction,
IssueLink,
IssueAttachment,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class GlobalViewViewSet(BaseViewSet):
serializer_class = GlobalViewSerializer
model = GlobalView
permission_classes = [
WorkspaceEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(workspace_id=workspace.id)
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace")
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
class GlobalViewIssuesViewSet(BaseViewSet):
permission_classes = [
WorkspaceEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
@method_decorator(gzip_page)
def list(self, request, slug):
try:
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_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")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueViewViewSet(BaseViewSet):

View File

@@ -1197,7 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter(
~Q(field__in=["comment", "vote", "reaction"]),
~Q(field__in=["comment", "vote", "reaction", "draft"]),
workspace__slug=slug,
project__project_projectmember__member=request.user,
actor=user_id,
@@ -1239,13 +1239,21 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
.annotate(
created_issues=Count(
"project_issue",
filter=Q(project_issue__created_by_id=user_id),
filter=Q(
project_issue__created_by_id=user_id,
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
),
)
)
.annotate(
assigned_issues=Count(
"project_issue",
filter=Q(project_issue__assignees__in=[user_id]),
filter=Q(
project_issue__assignees__in=[user_id],
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
),
)
)
.annotate(
@@ -1254,6 +1262,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
filter=Q(
project_issue__completed_at__isnull=False,
project_issue__assignees__in=[user_id],
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
),
)
)
@@ -1267,6 +1277,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
"started",
],
project_issue__assignees__in=[user_id],
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
),
)
)
@@ -1317,6 +1329,11 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.filter(

View File

@@ -32,7 +32,7 @@ def delete_old_s3_link():
else:
s3 = boto3.client(
"s3",
region_name="ap-south-1",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),

View File

@@ -39,6 +39,7 @@ def track_name(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("name") != requested_data.get("name"):
issue_activities.append(
@@ -52,6 +53,7 @@ def track_name(
project=project,
workspace=project.workspace,
comment=f"updated the name to {requested_data.get('name')}",
epoch=epoch,
)
)
@@ -64,6 +66,7 @@ def track_parent(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("parent") != requested_data.get("parent"):
if requested_data.get("parent") == None:
@@ -81,6 +84,7 @@ def track_parent(
comment=f"updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
epoch=epoch,
)
)
else:
@@ -101,6 +105,7 @@ def track_parent(
comment=f"updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id,
epoch=epoch,
)
)
@@ -113,36 +118,23 @@ def track_priority(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("priority") != requested_data.get("priority"):
if requested_data.get("priority") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=None,
field="priority",
project=project,
workspace=project.workspace,
comment=f"updated the priority to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=requested_data.get("priority"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"updated the priority to {requested_data.get('priority')}",
)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=requested_data.get("priority"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"updated the priority to {requested_data.get('priority')}",
epoch=epoch,
)
)
# Track chnages in state of the issue
@@ -153,6 +145,7 @@ def track_state(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None))
@@ -171,6 +164,7 @@ def track_state(
comment=f"updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
epoch=epoch,
)
)
@@ -183,6 +177,7 @@ def track_description(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("description_html") != requested_data.get(
"description_html"
@@ -203,6 +198,7 @@ def track_description(
project=project,
workspace=project.workspace,
comment=f"updated the description to {requested_data.get('description_html')}",
epoch=epoch,
)
)
@@ -215,6 +211,7 @@ def track_target_date(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("target_date") != requested_data.get("target_date"):
if requested_data.get("target_date") == None:
@@ -229,6 +226,7 @@ def track_target_date(
project=project,
workspace=project.workspace,
comment=f"updated the target date to None",
epoch=epoch,
)
)
else:
@@ -243,6 +241,7 @@ def track_target_date(
project=project,
workspace=project.workspace,
comment=f"updated the target date to {requested_data.get('target_date')}",
epoch=epoch,
)
)
@@ -255,6 +254,7 @@ def track_start_date(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("start_date") != requested_data.get("start_date"):
if requested_data.get("start_date") == None:
@@ -269,6 +269,7 @@ def track_start_date(
project=project,
workspace=project.workspace,
comment=f"updated the start date to None",
epoch=epoch,
)
)
else:
@@ -283,6 +284,7 @@ def track_start_date(
project=project,
workspace=project.workspace,
comment=f"updated the start date to {requested_data.get('start_date')}",
epoch=epoch,
)
)
@@ -295,6 +297,7 @@ def track_labels(
project,
actor,
issue_activities,
epoch
):
# Label Addition
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
@@ -314,6 +317,7 @@ def track_labels(
comment=f"added label {label.name}",
new_identifier=label.id,
old_identifier=None,
epoch=epoch,
)
)
@@ -335,6 +339,7 @@ def track_labels(
comment=f"removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
epoch=epoch,
)
)
@@ -347,6 +352,7 @@ def track_assignees(
project,
actor,
issue_activities,
epoch
):
# Assignee Addition
if len(requested_data.get("assignees_list")) > len(
@@ -367,6 +373,7 @@ def track_assignees(
workspace=project.workspace,
comment=f"added assignee {assignee.display_name}",
new_identifier=assignee.id,
epoch=epoch,
)
)
@@ -389,12 +396,13 @@ def track_assignees(
workspace=project.workspace,
comment=f"removed assignee {assignee.display_name}",
old_identifier=assignee.id,
epoch=epoch,
)
)
def create_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@@ -404,12 +412,13 @@ def create_issue_activity(
comment=f"created the issue",
verb="created",
actor=actor,
epoch=epoch,
)
)
def track_estimate_points(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
if current_instance.get("estimate_point") != requested_data.get("estimate_point"):
if requested_data.get("estimate_point") == None:
@@ -424,6 +433,7 @@ def track_estimate_points(
project=project,
workspace=project.workspace,
comment=f"updated the estimate point to None",
epoch=epoch,
)
)
else:
@@ -438,12 +448,13 @@ def track_estimate_points(
project=project,
workspace=project.workspace,
comment=f"updated the estimate point to {requested_data.get('estimate_point')}",
epoch=epoch,
)
)
def track_archive_at(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
if requested_data.get("archived_at") is None:
issue_activities.append(
@@ -457,6 +468,7 @@ def track_archive_at(
field="archived_at",
old_value="archive",
new_value="restore",
epoch=epoch,
)
)
else:
@@ -471,12 +483,13 @@ def track_archive_at(
field="archived_at",
old_value=None,
new_value="archive",
epoch=epoch,
)
)
def track_closed_to(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
if requested_data.get("closed_to") is not None:
updated_state = State.objects.get(
@@ -496,12 +509,13 @@ def track_closed_to(
comment=f"Plane updated the state to {updated_state.name}",
old_identifier=None,
new_identifier=updated_state.id,
epoch=epoch,
)
)
def update_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
ISSUE_ACTIVITY_MAPPER = {
"name": track_name,
@@ -518,6 +532,11 @@ def update_issue_activity(
"closed_to": track_closed_to,
}
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
@@ -528,11 +547,12 @@ def update_issue_activity(
project,
actor,
issue_activities,
epoch
)
def delete_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@@ -542,12 +562,13 @@ def delete_issue_activity(
verb="deleted",
actor=actor,
field="issue",
epoch=epoch,
)
)
def create_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -566,12 +587,13 @@ def create_comment_activity(
new_value=requested_data.get("comment_html", ""),
new_identifier=requested_data.get("id", None),
issue_comment_id=requested_data.get("id", None),
epoch=epoch,
)
)
def update_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -593,12 +615,13 @@ def update_comment_activity(
new_value=requested_data.get("comment_html", ""),
new_identifier=current_instance.get("id", None),
issue_comment_id=current_instance.get("id", None),
epoch=epoch,
)
)
def delete_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@@ -609,12 +632,13 @@ def delete_comment_activity(
verb="deleted",
actor=actor,
field="comment",
epoch=epoch,
)
)
def create_cycle_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -646,6 +670,7 @@ def create_cycle_issue_activity(
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
epoch=epoch,
)
)
@@ -666,12 +691,13 @@ def create_cycle_issue_activity(
workspace=project.workspace,
comment=f"added cycle {cycle.name}",
new_identifier=cycle.id,
epoch=epoch,
)
)
def delete_cycle_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -695,12 +721,13 @@ def delete_cycle_issue_activity(
workspace=project.workspace,
comment=f"removed this issue from {cycle.name if cycle is not None else None}",
old_identifier=cycle.id if cycle is not None else None,
epoch=epoch,
)
)
def create_module_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -732,6 +759,7 @@ def create_module_issue_activity(
comment=f"updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id,
new_identifier=new_module.id,
epoch=epoch,
)
)
@@ -751,12 +779,13 @@ def create_module_issue_activity(
workspace=project.workspace,
comment=f"added module {module.name}",
new_identifier=module.id,
epoch=epoch,
)
)
def delete_module_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -780,12 +809,13 @@ def delete_module_issue_activity(
workspace=project.workspace,
comment=f"removed this issue from {module.name if module is not None else None}",
old_identifier=module.id if module is not None else None,
epoch=epoch,
)
)
def create_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -803,12 +833,13 @@ def create_link_activity(
field="link",
new_value=requested_data.get("url", ""),
new_identifier=requested_data.get("id", None),
epoch=epoch,
)
)
def update_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -829,12 +860,13 @@ def update_link_activity(
old_identifier=current_instance.get("id"),
new_value=requested_data.get("url", ""),
new_identifier=current_instance.get("id", None),
epoch=epoch,
)
)
def delete_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
@@ -851,13 +883,14 @@ def delete_link_activity(
actor=actor,
field="link",
old_value=current_instance.get("url", ""),
new_value=""
new_value="",
epoch=epoch,
)
)
def create_attachment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -875,12 +908,13 @@ def create_attachment_activity(
field="attachment",
new_value=current_instance.get("asset", ""),
new_identifier=current_instance.get("id", None),
epoch=epoch,
)
)
def delete_attachment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@@ -891,11 +925,12 @@ def delete_attachment_activity(
verb="deleted",
actor=actor,
field="attachment",
epoch=epoch,
)
)
def create_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
@@ -914,12 +949,13 @@ def create_issue_reaction_activity(
comment="added the reaction",
old_identifier=None,
new_identifier=issue_reaction,
epoch=epoch,
)
)
def delete_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
@@ -938,12 +974,13 @@ def delete_issue_reaction_activity(
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
epoch=epoch,
)
)
def create_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
@@ -963,12 +1000,13 @@ def create_comment_reaction_activity(
comment="added the reaction",
old_identifier=None,
new_identifier=comment_reaction_id,
epoch=epoch,
)
)
def delete_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
@@ -989,12 +1027,13 @@ def delete_comment_reaction_activity(
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
epoch=epoch,
)
)
def create_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("vote") is not None:
@@ -1011,12 +1050,13 @@ def create_issue_vote_activity(
comment="added the vote",
old_identifier=None,
new_identifier=None,
epoch=epoch,
)
)
def delete_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
@@ -1035,12 +1075,13 @@ def delete_issue_vote_activity(
comment="removed the vote",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
epoch=epoch,
)
)
def create_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -1048,6 +1089,25 @@ def create_issue_relation_activity(
)
if current_instance is None and requested_data.get("related_list") is not None:
for issue_relation in requested_data.get("related_list"):
if issue_relation.get("relation_type") == "blocked_by":
relation_type = "blocking"
else:
relation_type = issue_relation.get("relation_type")
issue = Issue.objects.get(pk=issue_relation.get("issue"))
issue_activities.append(
IssueActivity(
issue_id=issue_relation.get("related_issue"),
actor=actor,
verb="created",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field=relation_type,
project=project,
workspace=project.workspace,
comment=f'added {relation_type} relation',
old_identifier=issue_relation.get("issue"),
)
)
issue = Issue.objects.get(pk=issue_relation.get("related_issue"))
issue_activities.append(
IssueActivity(
@@ -1060,38 +1120,60 @@ def create_issue_relation_activity(
project=project,
workspace=project.workspace,
comment=f'added {issue_relation.get("relation_type")} relation',
old_identifier=issue_relation.get("issue"),
old_identifier=issue_relation.get("related_issue"),
epoch=epoch,
)
)
def delete_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance is not None and requested_data.get("related_list") is None:
issue = Issue.objects.get(pk=current_instance.get("issue"))
issue_activities.append(
IssueActivity(
issue_id=current_instance.get("issue"),
actor=actor,
verb="deleted",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field=f'{current_instance.get("relation_type")}',
project=project,
workspace=project.workspace,
comment=f'deleted the {current_instance.get("relation_type")} relation',
old_identifier=current_instance.get("issue"),
if current_instance.get("relation_type") == "blocked_by":
relation_type = "blocking"
else:
relation_type = current_instance.get("relation_type")
issue = Issue.objects.get(pk=current_instance.get("issue"))
issue_activities.append(
IssueActivity(
issue_id=current_instance.get("related_issue"),
actor=actor,
verb="deleted",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field=relation_type,
project=project,
workspace=project.workspace,
comment=f'deleted {relation_type} relation',
old_identifier=current_instance.get("issue"),
epoch=epoch,
)
)
issue = Issue.objects.get(pk=current_instance.get("related_issue"))
issue_activities.append(
IssueActivity(
issue_id=current_instance.get("issue"),
actor=actor,
verb="deleted",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field=f'{current_instance.get("relation_type")}',
project=project,
workspace=project.workspace,
comment=f'deleted {current_instance.get("relation_type")} relation',
old_identifier=current_instance.get("related_issue"),
epoch=epoch,
)
)
)
def create_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@@ -1102,12 +1184,13 @@ def create_draft_issue_activity(
field="draft",
verb="created",
actor=actor,
epoch=epoch,
)
)
def update_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -1122,6 +1205,7 @@ def update_draft_issue_activity(
comment=f"created the issue",
verb="updated",
actor=actor,
epoch=epoch,
)
)
else:
@@ -1134,13 +1218,14 @@ def update_draft_issue_activity(
field="draft",
verb="updated",
actor=actor,
epoch=epoch,
)
)
def delete_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@@ -1150,6 +1235,7 @@ def delete_draft_issue_activity(
field="draft",
verb="deleted",
actor=actor,
epoch=epoch,
)
)
@@ -1162,6 +1248,7 @@ def issue_activity(
issue_id,
actor_id,
project_id,
epoch,
subscriber=True,
):
try:
@@ -1238,6 +1325,7 @@ def issue_activity(
project,
actor,
issue_activities,
epoch,
)
# Save all the values to database
@@ -1301,7 +1389,7 @@ def issue_activity(
):
issue_subscribers = issue_subscribers + [issue.created_by_id]
for subscriber in issue_subscribers:
for subscriber in list(set(issue_subscribers)):
for issue_activity in issue_activities_created:
bulk_notifications.append(
Notification(

View File

@@ -58,27 +58,31 @@ def archive_old_issues():
# Check if Issues
if issues:
# Set the archive time to current time
archive_at = timezone.now()
issues_to_update = []
for issue in issues:
issue.archived_at = timezone.now()
issue.archived_at = archive_at
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
if issues_to_update:
updated_issues = Issue.objects.bulk_update(
Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100
)
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
requested_data=json.dumps({"archived_at": str(archive_at)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
subscriber=False,
epoch=int(timezone.now().timestamp())
)
for issue in updated_issues
for issue in issues_to_update
]
return
except Exception as e:
@@ -138,7 +142,7 @@ def close_old_issues():
# Bulk Update the issues and log the activity
if issues_to_update:
updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
[
issue_activity.delay(
type="issue.activity.updated",
@@ -148,8 +152,9 @@ def close_old_issues():
project_id=project_id,
current_instance=None,
subscriber=False,
epoch=int(timezone.now().timestamp())
)
for issue in updated_issues
for issue in issues_to_update
]
return
except Exception as e:

View File

@@ -33,9 +33,8 @@ def create_issue_relation(apps, schema_editor):
def update_issue_priority_choice(apps, schema_editor):
IssueModel = apps.get_model("db", "Issue")
updated_issues = []
for obj in IssueModel.objects.all():
if obj.priority is None:
obj.priority = "none"
for obj in IssueModel.objects.filter(priority=None):
obj.priority = "none"
updated_issues.append(obj)
IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100)

View File

@@ -26,19 +26,19 @@ def workspace_member_props(old_props):
"calendar_date_range": old_props.get("calendarDateRange", ""),
},
"display_properties": {
"assignee": old_props.get("properties", {}).get("assignee",None),
"attachment_count": old_props.get("properties", {}).get("attachment_count", None),
"created_on": old_props.get("properties", {}).get("created_on", None),
"due_date": old_props.get("properties", {}).get("due_date", None),
"estimate": old_props.get("properties", {}).get("estimate", None),
"key": old_props.get("properties", {}).get("key", None),
"labels": old_props.get("properties", {}).get("labels", None),
"link": old_props.get("properties", {}).get("link", None),
"priority": old_props.get("properties", {}).get("priority", None),
"start_date": old_props.get("properties", {}).get("start_date", None),
"state": old_props.get("properties", {}).get("state", None),
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", None),
"updated_on": old_props.get("properties", {}).get("updated_on", None),
"assignee": old_props.get("properties", {}).get("assignee", True),
"attachment_count": old_props.get("properties", {}).get("attachment_count", True),
"created_on": old_props.get("properties", {}).get("created_on", True),
"due_date": old_props.get("properties", {}).get("due_date", True),
"estimate": old_props.get("properties", {}).get("estimate", True),
"key": old_props.get("properties", {}).get("key", True),
"labels": old_props.get("properties", {}).get("labels", True),
"link": old_props.get("properties", {}).get("link", True),
"priority": old_props.get("properties", {}).get("priority", True),
"start_date": old_props.get("properties", {}).get("start_date", True),
"state": old_props.get("properties", {}).get("state", True),
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", True),
"updated_on": old_props.get("properties", {}).get("updated_on", True),
},
}
return new_props

View File

@@ -0,0 +1,79 @@
# Generated by Django 4.2.5 on 2023-09-29 10:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.workspace
import uuid
def update_issue_activity_priority(apps, schema_editor):
IssueActivity = apps.get_model("db", "IssueActivity")
updated_issue_activity = []
for obj in IssueActivity.objects.filter(field="priority"):
# Set the old and new value to none if it is empty for Priority
obj.new_value = obj.new_value or "none"
obj.old_value = obj.old_value or "none"
updated_issue_activity.append(obj)
IssueActivity.objects.bulk_update(
updated_issue_activity,
["new_value", "old_value"],
batch_size=2000,
)
def update_issue_activity_blocked(apps, schema_editor):
IssueActivity = apps.get_model("db", "IssueActivity")
updated_issue_activity = []
for obj in IssueActivity.objects.filter(field="blocks"):
# Set the field to blocked_by
obj.field = "blocked_by"
updated_issue_activity.append(obj)
IssueActivity.objects.bulk_update(
updated_issue_activity,
["field"],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
('db', '0044_auto_20230913_0709'),
]
operations = [
migrations.CreateModel(
name='GlobalView',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=255, verbose_name='View Name')),
('description', models.TextField(blank=True, verbose_name='View Description')),
('query', models.JSONField(verbose_name='View Query')),
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
('query_data', models.JSONField(default=dict)),
('sort_order', models.FloatField(default=65535)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')),
],
options={
'verbose_name': 'Global View',
'verbose_name_plural': 'Global Views',
'db_table': 'global_views',
'ordering': ('-created_at',),
},
),
migrations.AddField(
model_name='workspacemember',
name='issue_props',
field=models.JSONField(default=plane.db.models.workspace.get_issue_props),
),
migrations.AddField(
model_name='issueactivity',
name='epoch',
field=models.FloatField(null=True),
),
migrations.RunPython(update_issue_activity_priority),
migrations.RunPython(update_issue_activity_blocked),
]

View File

@@ -50,7 +50,7 @@ from .state import State
from .cycle import Cycle, CycleIssue, CycleFavorite
from .view import IssueView, IssueViewFavorite
from .view import GlobalView, IssueView, IssueViewFavorite
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite

View File

@@ -309,6 +309,7 @@ class IssueActivity(ProjectBaseModel):
)
old_identifier = models.UUIDField(null=True)
new_identifier = models.UUIDField(null=True)
epoch = models.FloatField(null=True)
class Meta:
verbose_name = "Issue Activity"

View File

@@ -3,7 +3,41 @@ from django.db import models
from django.conf import settings
# Module import
from . import ProjectBaseModel
from . import ProjectBaseModel, BaseModel
class GlobalView(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="global_views"
)
name = models.CharField(max_length=255, verbose_name="View Name")
description = models.TextField(verbose_name="View Description", blank=True)
query = models.JSONField(verbose_name="View Query")
access = models.PositiveSmallIntegerField(
default=1, choices=((0, "Private"), (1, "Public"))
)
query_data = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
verbose_name = "Global View"
verbose_name_plural = "Global Views"
db_table = "global_views"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
largest_sort_order = GlobalView.objects.filter(
workspace=self.workspace
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(GlobalView, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.workspace.name}>"
class IssueView(ProjectBaseModel):

View File

@@ -29,7 +29,7 @@ def get_default_props():
},
"display_filters": {
"group_by": None,
"order_by": '-created_at',
"order_by": "-created_at",
"type": None,
"sub_issue": True,
"show_empty_groups": True,
@@ -54,6 +54,15 @@ def get_default_props():
}
def get_issue_props():
return {
"subscribed": True,
"assigned": True,
"created": True,
"all_issues": True,
}
class Workspace(BaseModel):
name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.URLField(verbose_name="Logo", blank=True, null=True)
@@ -89,6 +98,7 @@ class WorkspaceMember(BaseModel):
company_role = models.TextField(null=True, blank=True)
view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props)
issue_props = models.JSONField(default=get_issue_props)
class Meta:
unique_together = ["workspace", "member"]

View File

@@ -114,3 +114,6 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL")
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")

View File

@@ -1,14 +1,13 @@
"""Production settings and globals."""
from urllib.parse import urlparse
import ssl
import certifi
import dj_database_url
from urllib.parse import urlparse
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from urllib.parse import urlparse
from .common import * # noqa
@@ -197,7 +196,6 @@ else:
STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage",
}
# AWS Settings End
# Enable Connection Pooling (if desired)
@@ -265,15 +263,19 @@ if DOCKERIZED:
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
else:
CELERY_RESULT_BACKEND = broker_url
CELERY_BROKER_URL = broker_url
CELERY_RESULT_BACKEND = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
# Enable or Disable signups
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Scout Settings
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
SCOUT_NAME = "Plane"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")

View File

@@ -0,0 +1,129 @@
"""Self hosted settings and globals."""
from urllib.parse import urlparse
import dj_database_url
from urllib.parse import urlparse
from .common import * # noqa
# Database
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
# Docker configurations
DOCKERIZED = 1
USE_MINIO = 1
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "plane",
"USER": os.environ.get("PGUSER", ""),
"PASSWORD": os.environ.get("PGPASSWORD", ""),
"HOST": os.environ.get("PGHOST", ""),
}
}
# Parse database configuration from $DATABASE_URL
DATABASES["default"] = dj_database_url.config()
SITE_ID = 1
# File size limit
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
CORS_ALLOW_METHODS = [
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
]
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = True
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
INSTALLED_APPS += ("storages",)
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
# The name of the bucket to store files in.
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
)
# Default permissions
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
# Custom Domain settings
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Allow all host headers
ALLOWED_HOSTS = [
"*",
]
# Security settings
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Redis URL
REDIS_URL = os.environ.get("REDIS_URL")
# Caches
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
# URL used for email redirects
WEB_URL = os.environ.get("WEB_URL", "http://localhost")
# Celery settings
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
# Enable or Disable signups
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Analytics
ANALYTICS_BASE_API = False
# OPEN AI Settings
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")

View File

@@ -218,3 +218,7 @@ CELERY_BROKER_URL = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")

View File

@@ -74,10 +74,10 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
sorted_data = grouped_data
if temp_axis == "priority":
order = ["low", "medium", "high", "urgent", "None"]
order = ["low", "medium", "high", "urgent", "none"]
sorted_data = {key: grouped_data[key] for key in order if key in grouped_data}
else:
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "none", x[0])))
return sorted_data

View File

@@ -40,9 +40,6 @@ def filter_priority(params, filter, method):
priorities = params.get("priority").split(",")
if len(priorities) and "" not in priorities:
filter["priority__in"] = priorities
else:
if params.get("priority", None) and len(params.get("priority")):
filter["priority__in"] = params.get("priority")
return filter
@@ -166,17 +163,17 @@ def filter_target_date(params, filter, method):
for query in target_dates:
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gt"] = target_date_query[0]
filter["target_date__gte"] = target_date_query[0]
else:
filter["target_date__lt"] = target_date_query[0]
filter["target_date__lte"] = target_date_query[0]
else:
if params.get("target_date", None) and len(params.get("target_date")):
for query in params.get("target_date"):
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gt"] = target_date_query[0]
filter["target_date__gte"] = target_date_query[0]
else:
filter["target_date__lt"] = target_date_query[0]
filter["target_date__lte"] = target_date_query[0]
return filter

View File

@@ -1,113 +1,61 @@
version: "3.8"
x-api-and-worker-env:
&api-and-worker-env
DEBUG: ${DEBUG}
SENTRY_DSN: ${SENTRY_DSN}
DJANGO_SETTINGS_MODULE: plane.settings.production
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
REDIS_URL: redis://plane-redis:6379/
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
EMAIL_PORT: ${EMAIL_PORT}
EMAIL_FROM: ${EMAIL_FROM}
EMAIL_USE_TLS: ${EMAIL_USE_TLS}
EMAIL_USE_SSL: ${EMAIL_USE_SSL}
AWS_REGION: ${AWS_REGION}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
WEB_URL: ${WEB_URL}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
DISABLE_COLLECTSTATIC: 1
DOCKERIZED: 1
OPENAI_API_BASE: ${OPENAI_API_BASE}
OPENAI_API_KEY: ${OPENAI_API_KEY}
GPT_ENGINE: ${GPT_ENGINE}
SECRET_KEY: ${SECRET_KEY}
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
USE_MINIO: ${USE_MINIO}
ENABLE_SIGNUP: ${ENABLE_SIGNUP}
services:
plane-web:
container_name: planefrontend
web:
container_name: web
image: makeplane/plane-frontend:latest
restart: always
command: /usr/local/bin/start.sh web/server.js web
env_file:
- .env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
NEXT_PUBLIC_GITHUB_ID: "0"
NEXT_PUBLIC_SENTRY_DSN: "0"
NEXT_PUBLIC_ENABLE_OAUTH: "0"
NEXT_PUBLIC_ENABLE_SENTRY: "0"
NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0"
NEXT_PUBLIC_TRACK_EVENTS: "0"
- ./web/.env
depends_on:
- plane-api
- plane-worker
- api
- worker
plane-deploy:
container_name: planedeploy
image: makeplane/plane-deploy:latest
space:
container_name: space
image: makeplane/plane-space:latest
restart: always
command: /usr/local/bin/start.sh space/server.js space
env_file:
- .env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
- ./space/.env
depends_on:
- plane-api
- plane-worker
- plane-web
- api
- worker
- web
plane-api:
container_name: planebackend
api:
container_name: api
image: makeplane/plane-backend:latest
restart: always
command: ./bin/takeoff
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-db
- plane-redis
plane-worker:
container_name: planebgworker
worker:
container_name: bgworker
image: makeplane/plane-backend:latest
restart: always
command: ./bin/worker
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-api
- api
- plane-db
- plane-redis
plane-beat-worker:
container_name: planebeatworker
beat-worker:
container_name: beatworker
image: makeplane/plane-backend:latest
restart: always
command: ./bin/beat
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-api
- api
- plane-db
- plane-redis
@@ -157,8 +105,8 @@ services:
- plane-minio
# Comment this if you already have a reverse proxy running
plane-proxy:
container_name: planeproxy
proxy:
container_name: proxy
image: makeplane/plane-proxy:latest
ports:
- ${NGINX_PORT}:80
@@ -168,8 +116,9 @@ services:
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
depends_on:
- plane-web
- plane-api
- web
- api
- space
volumes:
pgdata:

View File

@@ -1,8 +1,8 @@
version: "3.8"
services:
plane-web:
container_name: planefrontend
web:
container_name: web
build:
context: .
dockerfile: ./web/Dockerfile.web
@@ -11,11 +11,11 @@ services:
restart: always
command: /usr/local/bin/start.sh web/server.js web
depends_on:
- plane-api
- plane-worker
- api
- worker
plane-deploy:
container_name: planedeploy
space:
container_name: space
build:
context: .
dockerfile: ./space/Dockerfile.space
@@ -24,12 +24,12 @@ services:
restart: always
command: /usr/local/bin/start.sh space/server.js space
depends_on:
- plane-api
- plane-worker
- plane-web
- api
- worker
- web
plane-api:
container_name: planebackend
api:
container_name: api
build:
context: ./apiserver
dockerfile: Dockerfile.api
@@ -43,8 +43,8 @@ services:
- plane-db
- plane-redis
plane-worker:
container_name: planebgworker
worker:
container_name: bgworker
build:
context: ./apiserver
dockerfile: Dockerfile.api
@@ -55,12 +55,12 @@ services:
env_file:
- ./apiserver/.env
depends_on:
- plane-api
- api
- plane-db
- plane-redis
plane-beat-worker:
container_name: planebeatworker
beat-worker:
container_name: beatworker
build:
context: ./apiserver
dockerfile: Dockerfile.api
@@ -71,7 +71,7 @@ services:
env_file:
- ./apiserver/.env
depends_on:
- plane-api
- api
- plane-db
- plane-redis
@@ -118,8 +118,8 @@ services:
- plane-minio
# Comment this if you already have a reverse proxy running
plane-proxy:
container_name: planeproxy
proxy:
container_name: proxy
build:
context: ./nginx
dockerfile: Dockerfile
@@ -130,8 +130,9 @@ services:
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
depends_on:
- plane-web
- plane-api
- web
- api
- space
volumes:
pgdata:

View File

@@ -1,29 +1,36 @@
events { }
events {
}
http {
sendfile on;
server {
listen 80;
root /www/data/;
listen 80;
root /www/data/;
access_log /var/log/nginx/access.log;
client_max_body_size ${FILE_SIZE_LIMIT};
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "interest-cohort=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://planefrontend:3000/;
proxy_pass http://web:3000/;
}
location /api/ {
proxy_pass http://planebackend:8000/api/;
proxy_pass http://api:8000/api/;
}
location /spaces/ {
proxy_pass http://planedeploy:3000/spaces/;
rewrite ^/spaces/?$ /spaces/login break;
proxy_pass http://space:3000/spaces/;
}
location /${BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/uploads/;
}
}
}
}

View File

@@ -1,5 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.13.2",
"license": "AGPL-3.0",
"private": true,
"workspaces": [

View File

@@ -1,6 +1,6 @@
{
"name": "eslint-config-custom",
"version": "0.0.0",
"version": "0.13.2",
"main": "index.js",
"license": "MIT",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.0.1",
"version": "0.13.2",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "tsconfig",
"version": "0.0.0",
"version": "0.13.2",
"private": true,
"files": [
"base.json",

1
packages/ui/README.md Normal file
View File

@@ -0,0 +1 @@
# UI Package

View File

@@ -1,6 +1,6 @@
{
"name": "ui",
"version": "0.0.0",
"version": "0.13.2",
"main": "./index.tsx",
"types": "./index.tsx",
"license": "MIT",

View File

@@ -10,15 +10,4 @@ cp ./space/.env.example ./space/.env
cp ./apiserver/.env.example ./apiserver/.env
# Generate the SECRET_KEY that will be used by django
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
# Generate Prompt for taking tiptap auth key
echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n"
echo -e "\e[1;38m 1. Head over to TipTap cloud's Pro Extensions Page, https://collab.tiptap.dev/pro-extensions \e[0m"
echo -e "\e[1;38m 2. Copy the token given to you under the first paragraph, after 'Here it is' \e[0m \n"
read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken
echo "@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env

View File

@@ -1,4 +1,2 @@
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
# Flag to toggle OAuth
NEXT_PUBLIC_ENABLE_OAUTH=0

View File

@@ -10,9 +10,12 @@ import githubWhiteImage from "public/logos/github-white.svg";
export interface GithubLoginButtonProps {
handleSignIn: React.Dispatch<string>;
clientId: string;
}
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
const { handleSignIn, clientId } = props;
// states
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
const [gitCode, setGitCode] = useState<null | string>(null);
@@ -38,7 +41,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
<div className="w-full flex justify-center items-center">
<Link
className="w-full"
href={`https://github.com/login/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
>
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
<Image

View File

@@ -1,22 +1,23 @@
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
import { FC, useEffect, useRef, useCallback, useState } from "react";
import Script from "next/script";
export interface IGoogleLoginButton {
text?: string;
clientId: string;
handleSignIn: React.Dispatch<any>;
styles?: CSSProperties;
}
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
const { handleSignIn, clientId } = props;
// refs
const googleSignInButton = useRef<HTMLDivElement>(null);
// states
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
const loadScript = useCallback(() => {
if (!googleSignInButton.current || gsiScriptLoaded) return;
(window as any)?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
client_id: clientId,
callback: handleSignIn,
});

View File

@@ -1,26 +1,30 @@
import React, { useEffect } from "react";
import Image from "next/image";
import React from "react";
import useSWR from "swr";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import authenticationService from "services/authentication.service";
import { AppConfigService } from "services/app-config.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
import { EmailPasswordForm, GoogleLoginButton, EmailCodeForm } from "components/accounts";
// images
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
const appConfig = new AppConfigService();
export const SignInView = observer(() => {
const { user: userStore } = useMobxStore();
// router
const router = useRouter();
const { next_path } = router.query as { next_path: string };
// toast
const { setToastAlert } = useToast();
// fetch app config
const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig());
const onSignInError = (error: any) => {
setToastAlert({
@@ -31,17 +35,17 @@ export const SignInView = observer(() => {
};
const onSignInSuccess = (response: any) => {
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/";
userStore.setCurrentUser(response?.user);
if (!isOnboarded) {
router.push(`/onboarding?next_path=${nextPath}`);
return;
const isOnboard = response?.user?.onboarding_step?.profile_complete || false;
if (isOnboard) {
if (next_path) router.push(next_path);
else router.push("/login");
} else {
if (next_path) router.push(`/onboarding?next_path=${next_path}`);
else router.push("/onboarding");
}
router.push((nextPath ?? "/").toString());
};
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
@@ -63,24 +67,6 @@ export const SignInView = observer(() => {
}
};
const handleGitHubSignIn = async (credential: string) => {
try {
if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) {
const socialAuthPayload = {
medium: "github",
credential,
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
};
const response = await authenticationService.socialAuth(socialAuthPayload);
onSignInSuccess(response);
} else {
throw Error("Cant find credentials");
}
} catch (err: any) {
onSignInError(err);
}
};
const handlePasswordSignIn = async (formData: any) => {
await authenticationService
.emailLogin(formData)
@@ -118,38 +104,32 @@ export const SignInView = observer(() => {
</div>
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
<div>
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
<>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
Sign in to Plane
</h1>
<div className="flex flex-col divide-y divide-custom-border-200">
<div className="pb-7">
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
</div>
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
{/* <GithubLoginButton handleSignIn={handleGitHubSignIn} /> */}
</div>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">Sign in to Plane</h1>
{data?.email_password_login && <EmailPasswordForm onSubmit={handlePasswordSignIn} />}
{data?.magic_login && (
<div className="flex flex-col divide-y divide-custom-border-200">
<div className="pb-7">
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
</div>
</>
) : (
<EmailPasswordForm onSubmit={handlePasswordSignIn} />
</div>
)}
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
<p className="pt-16 text-custom-text-200 text-sm text-center">
By signing up, you agree to the{" "}
<a
href="https://plane.so/terms-and-conditions"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline"
>
Terms & Conditions
</a>
</p>
) : null}
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
{data?.google && <GoogleLoginButton clientId={data.google} handleSignIn={handleGoogleSignIn} />}
</div>
<p className="pt-16 text-custom-text-200 text-sm text-center">
By signing up, you agree to the{" "}
<a
href="https://plane.so/terms-and-conditions"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline"
>
Terms & Conditions
</a>
</p>
</div>
</div>
</div>

View File

@@ -44,19 +44,43 @@ const IssueNavbar = observer(() => {
}, [projectStore, workspace_slug, project_slug]);
useEffect(() => {
if (workspace_slug && project_slug) {
if (!board) {
router.push({
pathname: `/${workspace_slug}/${project_slug}`,
query: {
board: "list",
},
});
return projectStore.setActiveBoard("list");
if (workspace_slug && project_slug && projectStore?.deploySettings) {
const viewsAcceptable: string[] = [];
let currentBoard: string | null = null;
if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list");
if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban");
if (projectStore?.deploySettings?.views?.calendar) viewsAcceptable.push("calendar");
if (projectStore?.deploySettings?.views?.gantt) viewsAcceptable.push("gantt");
if (projectStore?.deploySettings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet");
if (board) {
if (viewsAcceptable.includes(board.toString())) {
currentBoard = board.toString();
} else {
if (viewsAcceptable && viewsAcceptable.length > 0) {
currentBoard = viewsAcceptable[0];
}
}
} else {
if (viewsAcceptable && viewsAcceptable.length > 0) {
currentBoard = viewsAcceptable[0];
}
}
if (currentBoard) {
if (projectStore?.activeBoard === null || projectStore?.activeBoard !== currentBoard) {
projectStore.setActiveBoard(currentBoard);
router.push({
pathname: `/${workspace_slug}/${project_slug}`,
query: {
board: currentBoard,
},
});
}
}
projectStore.setActiveBoard(board.toString());
}
}, [board, workspace_slug, project_slug]);
}, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings]);
return (
<div className="px-5 relative w-full flex items-center gap-4">
@@ -105,7 +129,7 @@ const IssueNavbar = observer(() => {
</div>
) : (
<div className="flex-shrink-0">
<Link href={`/?next_path=${router.asPath}`}>
<Link href={`/login/?next_path=${router.asPath}`}>
<a>
<PrimaryButton className="flex-shrink-0" outline>
Sign in

View File

@@ -18,7 +18,6 @@ import Gapcursor from "@tiptap/extension-gapcursor";
import ts from "highlight.js/lib/languages/typescript";
import "highlight.js/styles/github-dark.css";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
import { CustomTableCell } from "./table/table-cell";
@@ -121,9 +120,6 @@ export const TiptapExtensions = (
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,

View File

@@ -1,13 +0,0 @@
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { SignInView, UserLoggedIn } from "components/accounts";
export const HomeView = observer(() => {
const { user: userStore } = useMobxStore();
if (!userStore.currentUser) return <SignInView />;
return <UserLoggedIn />;
});

View File

@@ -1 +1 @@
export * from "./home";
export * from "./login";

View File

@@ -0,0 +1,19 @@
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { SignInView, UserLoggedIn } from "components/accounts";
export const LoginView = observer(() => {
const { user: userStore } = useMobxStore();
return (
<>
{userStore?.loader ? (
<div className="relative w-screen h-screen flex justify-center items-center">Loading</div>
) : (
<>{userStore.currentUser ? <UserLoggedIn /> : <SignInView />}</>
)}
</>
);
});

View File

@@ -3,12 +3,14 @@
import { useEffect } from "react";
// next imports
import { useRouter } from "next/router";
// js cookie
import Cookie from "js-cookie";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
const MobxStoreInit = () => {
const store: RootStore = useMobxStore();
const { user: userStore }: RootStore = useMobxStore();
const router = useRouter();
const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] };
@@ -19,6 +21,11 @@ const MobxStoreInit = () => {
// store.issue.userSelectedStates = states || [];
// }, [store.issue]);
useEffect(() => {
const authToken = Cookie.get("accessToken") || null;
if (authToken) userStore.fetchCurrentUser();
}, [userStore]);
return <></>;
};

View File

@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.0.1",
"version": "0.13.2",
"private": true,
"scripts": {
"dev": "next dev -p 4000",
@@ -17,7 +17,6 @@
"@heroicons/react": "^2.0.12",
"@mui/icons-material": "^5.14.1",
"@mui/material": "^5.14.1",
"@tiptap-pro/extension-unique-id": "^2.1.0",
"@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.0.4",
"@tiptap/extension-gapcursor": "^2.1.7",

View File

@@ -1,8 +1,19 @@
import React from "react";
import { useEffect } from "react";
// components
import { HomeView } from "components/views";
// next
import { NextPage } from "next";
import { useRouter } from "next/router";
const HomePage = () => <HomeView />;
const Index: NextPage = () => {
const router = useRouter();
const { next_path } = router.query as { next_path: string };
export default HomePage;
useEffect(() => {
if (next_path) router.push(`/login?next_path=${next_path}`);
else router.push(`/login`);
}, [router, next_path]);
return null;
};
export default Index;

View File

@@ -0,0 +1,8 @@
import React from "react";
// components
import { LoginView } from "components/views";
const LoginPage = () => <LoginView />;
export default LoginPage;

View File

@@ -0,0 +1,30 @@
// services
import APIService from "services/api.service";
// helper
import { API_BASE_URL } from "helpers/common.helper";
export interface IEnvConfig {
github: string;
google: string;
github_app_name: string | null;
email_password_login: boolean;
magic_login: boolean;
}
export class AppConfigService extends APIService {
constructor() {
super(API_BASE_URL);
}
async envConfig(): Promise<IEnvConfig> {
return this.get("/api/configs/", {
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -74,24 +74,6 @@ class FileServices extends APIService {
throw error?.response?.data;
});
}
async getUnsplashImages(page: number = 1, query?: string): Promise<UnSplashImage[]> {
const url = "/api/unsplash";
return this.request({
method: "get",
url,
params: {
page,
per_page: 20,
query,
},
})
.then((response) => response?.data?.results ?? response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
const fileServices = new FileServices();

View File

@@ -7,12 +7,17 @@ import { ActorDetail } from "types/issue";
import { IUser } from "types/user";
export interface IUserStore {
loader: boolean;
error: any | null;
currentUser: any | null;
fetchCurrentUser: () => void;
currentActor: () => any;
}
class UserStore implements IUserStore {
loader: boolean = false;
error: any | null = null;
currentUser: IUser | null = null;
// root store
rootStore;
@@ -22,6 +27,9 @@ class UserStore implements IUserStore {
constructor(_rootStore: any) {
makeObservable(this, {
// observable
loader: observable.ref,
error: observable.ref,
currentUser: observable.ref,
// actions
setCurrentUser: action,
@@ -73,14 +81,19 @@ class UserStore implements IUserStore {
fetchCurrentUser = async () => {
try {
this.loader = true;
this.error = null;
const response = await this.userService.currentUser();
if (response) {
runInAction(() => {
this.loader = false;
this.currentUser = response;
});
}
} catch (error) {
console.error("Failed to fetch current user", error);
this.loader = false;
this.error = error;
}
};
}

View File

@@ -1,8 +1,6 @@
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": [
"NEXT_PUBLIC_GITHUB_ID",
"NEXT_PUBLIC_GOOGLE_CLIENTID",
"NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_DEPLOY_URL",
"API_BASE_URL",
@@ -12,8 +10,6 @@
"NEXT_PUBLIC_GITHUB_APP_NAME",
"NEXT_PUBLIC_ENABLE_SENTRY",
"NEXT_PUBLIC_ENABLE_OAUTH",
"NEXT_PUBLIC_UNSPLASH_ACCESS",
"NEXT_PUBLIC_UNSPLASH_ENABLED",
"NEXT_PUBLIC_TRACK_EVENTS",
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
"NEXT_PUBLIC_CRISP_ID",

View File

@@ -1,24 +1,4 @@
# Extra image domains that need to be added for Next Image
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
# GitHub App ID for GitHub OAuth
NEXT_PUBLIC_GITHUB_ID=""
# GitHub App Name for GitHub Integration
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Sentry DSN for error monitoring
NEXT_PUBLIC_SENTRY_DSN=""
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0
# Enable/Disable Sentry
NEXT_PUBLIC_ENABLE_SENTRY=0
# Enable/Disable session recording
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
# Enable/Disable event tracking
NEXT_PUBLIC_TRACK_EVENTS=0
# Slack Client ID for Slack Integration
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL="http://localhost:3000/spaces"
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"

View File

@@ -1,12 +1,5 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// react hook form
import React from "react";
import { useForm } from "react-hook-form";
// components
import { EmailResetPasswordForm } from "components/account";
// ui
import { Input, PrimaryButton } from "components/ui";
// types
@@ -18,14 +11,12 @@ type EmailPasswordFormValues = {
type Props = {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
setIsResettingPassword: (value: boolean) => void;
};
export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
const [isResettingPassword, setIsResettingPassword] = useState(false);
const router = useRouter();
const isSignUpPage = router.pathname === "/sign-up";
export const EmailPasswordForm: React.FC<Props> = (props) => {
const { onSubmit, setIsResettingPassword } = props;
// form info
const {
register,
handleSubmit,
@@ -42,94 +33,62 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
return (
<>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
{isResettingPassword
? "Reset your password"
: isSignUpPage
? "Sign up on Plane"
: "Sign in to Plane"}
</h1>
{isResettingPassword ? (
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
) : (
<form
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
onSubmit={handleSubmit(onSubmit)}
>
<div className="space-y-1">
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
error={errors.email}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="space-y-1">
<Input
id="password"
type="password"
name="password"
register={register}
validations={{
required: "Password is required",
}}
error={errors.password}
placeholder="Enter your password..."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="text-right text-xs">
{isSignUpPage ? (
<Link href="/">
<a className="text-custom-text-200 hover:text-custom-primary-100">
Already have an account? Sign in.
</a>
</Link>
) : (
<button
type="button"
onClick={() => setIsResettingPassword(true)}
className="text-custom-text-200 hover:text-custom-primary-100"
>
Forgot your password?
</button>
)}
</div>
<div>
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSignUpPage
? isSubmitting
? "Signing up..."
: "Sign up"
: isSubmitting
? "Signing in..."
: "Sign in"}
</PrimaryButton>
{!isSignUpPage && (
<Link href="/sign-up">
<a className="block text-custom-text-200 hover:text-custom-primary-100 text-xs mt-4">
Don{"'"}t have an account? Sign up.
</a>
</Link>
)}
</div>
</form>
)}
<form
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
onSubmit={handleSubmit(onSubmit)}
>
<div className="space-y-1">
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
error={errors.email}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="space-y-1">
<Input
id="password"
type="password"
name="password"
register={register}
validations={{
required: "Password is required",
}}
error={errors.password}
placeholder="Enter your password..."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="text-right text-xs">
<button
type="button"
onClick={() => setIsResettingPassword(true)}
className="text-custom-text-200 hover:text-custom-primary-100"
>
Forgot your password?
</button>
</div>
<div>
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</PrimaryButton>
</div>
</form>
</>
);
};

View File

@@ -0,0 +1,114 @@
import React from "react";
import Link from "next/link";
import { useForm } from "react-hook-form";
// ui
import { Input, PrimaryButton } from "components/ui";
// types
type EmailPasswordFormValues = {
email: string;
password?: string;
confirm_password: string;
medium?: string;
};
type Props = {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
};
export const EmailSignUpForm: React.FC<Props> = (props) => {
const { onSubmit } = props;
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailPasswordFormValues>({
defaultValues: {
email: "",
password: "",
confirm_password: "",
medium: "email",
},
mode: "onChange",
reValidateMode: "onChange",
});
return (
<>
<form
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
onSubmit={handleSubmit(onSubmit)}
>
<div className="space-y-1">
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
error={errors.email}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="space-y-1">
<Input
id="password"
type="password"
name="password"
register={register}
validations={{
required: "Password is required",
}}
error={errors.password}
placeholder="Enter your password..."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="space-y-1">
<Input
id="confirm_password"
type="password"
name="confirm_password"
register={register}
validations={{
required: "Password is required",
validate: (val: string) => {
if (watch("password") != val) {
return "Your passwords do no match";
}
},
}}
error={errors.confirm_password}
placeholder="Confirm your password..."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="text-right text-xs">
<Link href="/">
<a className="text-custom-text-200 hover:text-custom-primary-100">
Already have an account? Sign in.
</a>
</Link>
</div>
<div>
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing up..." : "Sign up"}
</PrimaryButton>
</div>
</form>
</>
);
};

View File

@@ -1,29 +1,27 @@
import { useEffect, useState, FC } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// next-themes
import { useTheme } from "next-themes";
// images
import githubBlackImage from "/public/logos/github-black.png";
import githubWhiteImage from "/public/logos/github-white.png";
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
export interface GithubLoginButtonProps {
handleSignIn: React.Dispatch<string>;
clientId: string;
}
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
const { handleSignIn, clientId } = props;
// states
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
const [gitCode, setGitCode] = useState<null | string>(null);
// router
const {
query: { code },
} = useRouter();
// theme
const { theme } = useTheme();
useEffect(() => {
@@ -42,7 +40,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
return (
<div className="w-full flex justify-center items-center">
<Link
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
>
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
<Image

View File

@@ -1,22 +1,23 @@
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
import { FC, useEffect, useRef, useCallback, useState } from "react";
import Script from "next/script";
export interface IGoogleLoginButton {
text?: string;
handleSignIn: React.Dispatch<any>;
styles?: CSSProperties;
clientId: string;
}
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
const { handleSignIn, clientId } = props;
// refs
const googleSignInButton = useRef<HTMLDivElement>(null);
// states
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
const loadScript = useCallback(() => {
if (!googleSignInButton.current || gsiScriptLoaded) return;
window?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
client_id: clientId,
callback: handleSignIn,
});
@@ -39,7 +40,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
setGsiScriptLoaded(true);
}, [handleSignIn, gsiScriptLoaded]);
}, [handleSignIn, gsiScriptLoaded, clientId]);
useEffect(() => {
if (window?.google?.accounts?.id) {

View File

@@ -3,3 +3,4 @@ export * from "./email-password-form";
export * from "./email-reset-password-form";
export * from "./github-login-button";
export * from "./google-login";
export * from "./email-signup-form";

View File

@@ -9,7 +9,6 @@ import { findStringWithMostCharacters } from "helpers/array.helper";
import { generateBarColor } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
// constants
type Props = {
analytics: IAnalyticsResponse;

View File

@@ -15,17 +15,19 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
<div className="divide-y divide-custom-border-200">
<div>
<h6 className="px-3 text-base font-medium">Pending issues</h6>
{defaultAnalytics.pending_issue_user.length > 0 ? (
{defaultAnalytics.pending_issue_user && defaultAnalytics.pending_issue_user.length > 0 ? (
<BarGraph
data={defaultAnalytics.pending_issue_user}
indexBy="assignees__display_name"
indexBy="assignees__id"
keys={["count"]}
height="250px"
colors={() => `#f97316`}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) =>
d.count > 0 ? d.count : 50
)}
tooltip={(datum) => {
const assignee = defaultAnalytics.pending_issue_user.find(
(a) => a.assignees__display_name === `${datum.indexValue}`
(a) => a.assignees__id === `${datum.indexValue}`
);
return (
@@ -39,10 +41,9 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
}}
axisBottom={{
renderTick: (datum) => {
const avatar =
defaultAnalytics.pending_issue_user[datum.tickIndex]?.assignees__avatar ?? "";
const assignee = defaultAnalytics.pending_issue_user[datum.tickIndex] ?? "";
if (avatar && avatar !== "")
if (assignee && assignee?.assignees__avatar && assignee?.assignees__avatar !== "")
return (
<g transform={`translate(${datum.x},${datum.y})`}>
<image
@@ -50,7 +51,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
y={10}
width={16}
height={16}
xlinkHref={avatar}
xlinkHref={assignee?.assignees__avatar}
style={{ clipPath: "circle(50%)" }}
/>
</g>
@@ -60,7 +61,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
<g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{datum.value ? `${datum.value}`.toUpperCase()[0] : "?"}
{datum.value ? `${assignee.assignees__display_name}`.toUpperCase()[0] : "?"}
</text>
</g>
);

View File

@@ -13,9 +13,14 @@ import { IProject } from "types";
type Props = {
projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>;
disabled?: boolean;
};
export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
export const AutoArchiveAutomation: React.FC<Props> = ({
projectDetails,
handleChange,
disabled = false,
}) => {
const [monthModal, setmonthModal] = useState(false);
const initialValues: Partial<IProject> = { archive_in: 1 };
@@ -49,6 +54,7 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
: handleChange({ archive_in: 0 })
}
size="sm"
disabled={disabled}
/>
</div>
@@ -70,6 +76,7 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
input
verticalPosition="bottom"
width="w-full"
disabled={disabled}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (

View File

@@ -24,9 +24,14 @@ import { getStatesList } from "helpers/state.helper";
type Props = {
projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>;
disabled?: boolean;
};
export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
export const AutoCloseAutomation: React.FC<Props> = ({
projectDetails,
handleChange,
disabled = false,
}) => {
const [monthModal, setmonthModal] = useState(false);
const router = useRouter();
@@ -98,13 +103,14 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
: handleChange({ close_in: 0, default_state: null })
}
size="sm"
disabled={disabled}
/>
</div>
{projectDetails?.close_in !== 0 && (
<div className="ml-12">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
<div className="flex flex-col rounded bg-custom-background-90 border border-custom-border-200 p-2">
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">
Auto-close issues that are inactive for
</div>
@@ -119,6 +125,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
}}
input
width="w-full"
disabled={disabled}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
@@ -138,7 +145,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
</div>
</div>
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-close Status</div>
<div className="w-1/2 ">
<CustomSearchSelect

View File

@@ -41,7 +41,7 @@ export const CommandPalette: React.FC = observer(() => {
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, issueId, inboxId } = router.query;
const { workspaceSlug, projectId, issueId, inboxId, cycleId, moduleId } = router.query;
const { user } = useUser();
@@ -183,6 +183,13 @@ export const CommandPalette: React.FC = observer(() => {
isOpen={isIssueModalOpen}
handleClose={() => setIsIssueModalOpen(false)}
fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]}
prePopulateData={
cycleId
? { cycle: cycleId.toString() }
: moduleId
? { module: moduleId.toString() }
: undefined
}
/>
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssuesModalOpen}

View File

@@ -1,14 +1,41 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// hook
import useEstimateOption from "hooks/use-estimate-option";
// services
import issuesService from "services/issues.service";
// icons
import { Icon, Tooltip } from "components/ui";
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons";
import {
TagIcon,
CopyPlus,
Calendar,
Link2Icon,
RocketIcon,
Users2Icon,
ArchiveIcon,
PaperclipIcon,
ContrastIcon,
TriangleIcon,
LayoutGridIcon,
SignalMediumIcon,
MessageSquareIcon,
} from "lucide-react";
import {
BlockedIcon,
BlockerIcon,
RelatedIcon,
StackedLayersHorizontalIcon,
} from "components/icons";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
// types
import { IIssueActivity } from "types";
// fetch-keys
import { WORKSPACE_LABELS } from "constants/fetch-keys";
const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const router = useRouter();
@@ -29,7 +56,7 @@ const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
{activity.issue_detail
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
: "Issue"}
<Icon iconName="launch" className="!text-xs" />
<RocketIcon size={12} color="#6b7280" />
</a>
</Tooltip>
);
@@ -51,6 +78,38 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => {
);
};
const LabelPill = ({ labelId }: { labelId: string }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: labels } = useSWR(
workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null
);
return (
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: labels?.find((l) => l.id === labelId)?.color ?? "#000000",
}}
aria-hidden="true"
/>
);
};
const EstimatePoint = ({ point }: { point: string }) => {
const { estimateValue, isEstimateActive } = useEstimateOption(Number(point));
const currentPoint = Number(point) + 1;
return (
<span className="font-medium text-custom-text-100">
{isEstimateActive
? estimateValue
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
</span>
);
};
const activityDetails: {
[key: string]: {
message: (
@@ -90,14 +149,14 @@ const activityDetails: {
</>
);
},
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />,
icon: <Users2Icon size={12} color="#6b7280" aria-hidden="true" />,
},
archived_at: {
message: (activity) => {
if (activity.new_value === "restore") return "restored the issue.";
else return "archived the issue.";
},
icon: <Icon iconName="archive" className="!text-sm" aria-hidden="true" />,
icon: <ArchiveIcon size={12} color="#6b7280" aria-hidden="true" />,
},
attachment: {
message: (activity, showIssue) => {
@@ -112,7 +171,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
attachment
<Icon iconName="launch" className="!text-xs" />
<RocketIcon size={12} color="#6b7280" />
</a>
{showIssue && (
<>
@@ -136,7 +195,7 @@ const activityDetails: {
</>
);
},
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />,
icon: <PaperclipIcon size={12} color="#6b7280" aria-hidden="true" />,
},
blocking: {
message: (activity) => {
@@ -157,7 +216,7 @@ const activityDetails: {
},
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
blocks: {
blocked_by: {
message: (activity) => {
if (activity.old_value === "")
return (
@@ -176,6 +235,44 @@ const activityDetails: {
},
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
},
duplicate: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked this issue as duplicate of{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed this issue as a duplicate of{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <CopyPlus size={12} color="#6b7280" />,
},
relates_to: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked that this issue relates to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the relation from{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <RelatedIcon height="12" width="12" color="#6b7280" />,
},
cycles: {
message: (activity, showIssue, workspaceSlug) => {
if (activity.verb === "created")
@@ -189,7 +286,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
{activity.new_value}
<Icon iconName="launch" className="!text-xs" />
<RocketIcon size={12} color="#6b7280" />
</a>
</>
);
@@ -204,7 +301,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
{activity.new_value}
<Icon iconName="launch" className="!text-xs" />
<RocketIcon size={12} color="#6b7280" />
</a>
</>
);
@@ -219,12 +316,12 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
{activity.old_value}
<Icon iconName="launch" className="!text-xs" />
<RocketIcon size={12} color="#6b7280" />
</a>
</>
);
},
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />,
icon: <ContrastIcon size={12} color="#6b7280" aria-hidden="true" />,
},
description: {
message: (activity, showIssue) => (
@@ -239,7 +336,7 @@ const activityDetails: {
.
</>
),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
},
estimate_point: {
message: (activity, showIssue) => {
@@ -259,8 +356,7 @@ const activityDetails: {
else
return (
<>
set the estimate point to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
set the estimate point to <EstimatePoint point={activity.new_value} />
{showIssue && (
<>
{" "}
@@ -271,14 +367,14 @@ const activityDetails: {
</>
);
},
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />,
icon: <TriangleIcon size={12} color="#6b7280" aria-hidden="true" />,
},
issue: {
message: (activity) => {
if (activity.verb === "created") return "created the issue.";
else return "deleted an issue.";
},
icon: <Icon iconName="stack" className="!text-sm" aria-hidden="true" />,
icon: <StackedLayersHorizontalIcon width={12} height={12} color="#6b7280" aria-hidden="true" />,
},
labels: {
message: (activity, showIssue) => {
@@ -286,14 +382,8 @@ const activityDetails: {
return (
<>
added a new label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<LabelPill labelId={activity.new_identifier ?? ""} />
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
</span>
{showIssue && (
@@ -309,13 +399,7 @@ const activityDetails: {
<>
removed the label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<LabelPill labelId={activity.old_identifier ?? ""} />
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
</span>
{showIssue && (
@@ -327,7 +411,7 @@ const activityDetails: {
</>
);
},
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />,
icon: <TagIcon size={12} color="#6b7280" aria-hidden="true" />,
},
link: {
message: (activity, showIssue) => {
@@ -342,7 +426,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
link
<Icon iconName="launch" className="!text-xs" />
<RocketIcon size={12} color="#6b7280" />
</a>
{showIssue && (
<>
@@ -364,7 +448,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
link
<Icon iconName="launch" className="!text-xs" />
<RocketIcon size={12} color="#6b7280" />
</a>
{showIssue && (
<>
@@ -386,7 +470,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
link
<Icon iconName="launch" className="!text-xs" />
<RocketIcon size={12} color="#6b7280" />
</a>
{showIssue && (
<>
@@ -398,7 +482,7 @@ const activityDetails: {
</>
);
},
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />,
icon: <Link2Icon size={12} color="#6b7280" aria-hidden="true" />,
},
modules: {
message: (activity, showIssue, workspaceSlug) => {
@@ -413,7 +497,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
{activity.new_value}
<Icon iconName="launch" className="!text-xs" />
<RocketIcon size={12} color="#6b7280" />
</a>
</>
);
@@ -428,7 +512,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
{activity.new_value}
<Icon iconName="launch" className="!text-xs" />
<RocketIcon size={12} color="#6b7280" />
</a>
</>
);
@@ -443,12 +527,12 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
{activity.old_value}
<Icon iconName="launch" className="!text-xs" />
<RocketIcon size={12} color="#6b7280" />
</a>
</>
);
},
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />,
icon: <Icon iconName="dataset" className="!text-xs !text-[#6b7280]" aria-hidden="true" />,
},
name: {
message: (activity, showIssue) => (
@@ -463,7 +547,7 @@ const activityDetails: {
.
</>
),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
},
parent: {
message: (activity, showIssue) => {
@@ -496,7 +580,13 @@ const activityDetails: {
</>
);
},
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />,
icon: (
<Icon
iconName="supervised_user_circle"
className="!text-xs !text-[#6b7280]"
aria-hidden="true"
/>
),
},
priority: {
message: (activity, showIssue) => (
@@ -514,7 +604,7 @@ const activityDetails: {
.
</>
),
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />,
icon: <SignalMediumIcon size={12} color="#6b7280" aria-hidden="true" />,
},
start_date: {
message: (activity, showIssue) => {
@@ -548,7 +638,7 @@ const activityDetails: {
</>
);
},
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
},
state: {
message: (activity, showIssue) => (
@@ -564,7 +654,7 @@ const activityDetails: {
.
</>
),
icon: <Squares2X2Icon className="h-3 w-3" aria-hidden="true" />,
icon: <LayoutGridIcon size={12} color="#6b7280" aria-hidden="true" />,
},
target_date: {
message: (activity, showIssue) => {
@@ -598,7 +688,7 @@ const activityDetails: {
</>
);
},
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
},
};

View File

@@ -167,7 +167,9 @@ export const FiltersList: React.FC<Props> = ({
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
subscriber: filters.subscriber?.filter(
(p: any) => p !== memberId
),
})
}
>

View File

@@ -1,4 +1,5 @@
export * from "./date-filter-modal";
export * from "./date-filter-select";
export * from "./filters-list";
export * from "./workspace-filters-list";
export * from "./issues-view-filter";

View File

@@ -52,10 +52,22 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
},
];
const issueViewForDraftIssues: { type: TIssueViewOptions; Icon: any }[] = [
{
type: "list",
Icon: FormatListBulletedOutlined,
},
{
type: "kanban",
Icon: GridViewOutlined,
},
];
export const IssuesFilterView: React.FC = () => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues";
const {
displayFilters,
@@ -75,9 +87,41 @@ export const IssuesFilterView: React.FC = () => {
return (
<div className="flex items-center gap-2">
{!isArchivedIssues && (
{!isArchivedIssues && !isDraftIssues && (
<div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={
<span className="capitalize">
{replaceUnderscoreIfSnakeCase(option.type)} Layout
</span>
}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
displayFilters.layout === option.type
? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200"
}`}
onClick={() => setDisplayFilters({ layout: option.type })}
>
<option.Icon
sx={{
fontSize: 16,
}}
className={option.type === "gantt_chart" ? "rotate-90" : ""}
/>
</button>
</Tooltip>
))}
</div>
)}
{isDraftIssues && (
<div className="flex items-center gap-x-1">
{issueViewForDraftIssues.map((option) => (
<Tooltip
key={option.type}
tooltipContent={
@@ -186,6 +230,9 @@ export const IssuesFilterView: React.FC = () => {
return null;
if (option.key === "project") return null;
if (isDraftIssues && option.key === "state_detail.group")
return null;
return (
<CustomMenu.MenuItem
key={option.key}
@@ -230,33 +277,35 @@ export const IssuesFilterView: React.FC = () => {
</div>
</div>
)}
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<div className="w-28">
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find(
(option) => option.key === displayFilters.type
)?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setDisplayFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
{!isArchivedIssues && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<div className="w-28">
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find(
(option) => option.key === displayFilters.type
)?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setDisplayFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</div>
</div>
)}
{displayFilters.layout !== "calendar" &&
displayFilters.layout !== "spreadsheet" && (
@@ -276,7 +325,7 @@ export const IssuesFilterView: React.FC = () => {
displayFilters.layout !== "spreadsheet" &&
displayFilters.layout !== "gantt_chart" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4>
<h4 className="text-custom-text-200">Show empty groups</h4>
<div className="w-28">
<ToggleSwitch
value={displayFilters.show_empty_groups ?? true}

View File

@@ -0,0 +1,364 @@
import React from "react";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
import { PriorityIcon, StateGroupIcon } from "components/icons";
// ui
import { Avatar } from "components/ui";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import {
IIssueLabels,
IProject,
IUserLite,
IWorkspaceIssueFilterOptions,
TStateGroups,
} from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
filters: Partial<IWorkspaceIssueFilterOptions>;
setFilters: (updatedFilter: Partial<IWorkspaceIssueFilterOptions>) => void;
clearAllFilters: (...args: any) => void;
labels: IIssueLabels[] | undefined;
members: IUserLite[] | undefined;
stateGroup: string[] | undefined;
project?: IProject[] | undefined;
};
export const WorkspaceFiltersList: React.FC<Props> = ({
filters,
setFilters,
clearAllFilters,
labels,
members,
stateGroup,
project,
}) => {
if (!filters) return <></>;
const nullFilters = Object.keys(filters).filter(
(key) => filters[key as keyof IWorkspaceIssueFilterOptions] === null
);
return (
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
{Object.keys(filters).map((filterKey) => {
const key = filterKey as keyof typeof filters;
if (filters[key] === null || (filters[key]?.length ?? 0) <= 0) return null;
return (
<div
key={key}
className="flex items-center gap-x-2 rounded-full border border-custom-border-200 bg-custom-background-80 px-2 py-1"
>
<span className="capitalize text-custom-text-200">
{key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}:
</span>
{filters[key] === null || (filters[key]?.length ?? 0) <= 0 ? (
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
) : Array.isArray(filters[key]) ? (
<div className="space-x-2">
<div className="flex flex-wrap items-center gap-1">
{key === "state_group"
? filters.state_group?.map((stateGroup) => {
const group = stateGroup as TStateGroups;
return (
<p
key={group}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
style={{
color: STATE_GROUP_COLORS[group],
backgroundColor: `${STATE_GROUP_COLORS[group]}20`,
}}
>
<span>
<StateGroupIcon stateGroup={group} color={undefined} />
</span>
<span>{group}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
state_group: filters.state_group?.filter((g) => g !== group),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
);
})
: key === "priority"
? filters.priority?.map((priority: any) => (
<p
key={priority}
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${
priority === "urgent"
? "bg-red-500/20 text-red-500"
: priority === "high"
? "bg-orange-500/20 text-orange-500"
: priority === "medium"
? "bg-yellow-500/20 text-yellow-500"
: priority === "low"
? "bg-green-500/20 text-green-500"
: "bg-custom-background-90 text-custom-text-200"
}`}
>
<span>
<PriorityIcon priority={priority} />
</span>
<span>{priority === "null" ? "None" : priority}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
priority: filters.priority?.filter((p: any) => p !== priority),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
))
: key === "assignees"
? filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "subscriber"
? filters.subscriber?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "created_by"
? filters.created_by?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={`${memberId}-${key}`}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
created_by: filters.created_by?.filter(
(p: any) => p !== memberId
),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "labels"
? filters.labels?.map((labelId: string) => {
const label = labels?.find((l) => l.id === labelId);
if (!label) return null;
const color = label.color !== "" ? label.color : "#0f172a";
return (
<div
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5"
style={{
color: color,
backgroundColor: `${color}20`, // add 20% opacity
}}
key={labelId}
>
<div
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: color,
}}
/>
<span>{label.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
labels: filters.labels?.filter((l: any) => l !== labelId),
})
}
>
<XMarkIcon
className="h-3 w-3"
style={{
color: color,
}}
/>
</span>
</div>
);
})
: key === "start_date"
? filters.start_date?.map((date: string) => {
if (filters.start_date && filters.start_date.length <= 0) return null;
const splitDate = date.split(";");
return (
<div
key={date}
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
>
<div className="h-1.5 w-1.5 rounded-full" />
<span className="capitalize">
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
start_date: filters.start_date?.filter((d: any) => d !== date),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "target_date"
? filters.target_date?.map((date: string) => {
if (filters.target_date && filters.target_date.length <= 0) return null;
const splitDate = date.split(";");
return (
<div
key={date}
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
>
<div className="h-1.5 w-1.5 rounded-full" />
<span className="capitalize">
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
target_date: filters.target_date?.filter((d: any) => d !== date),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "project"
? filters.project?.map((projectId) => {
const currentProject = project?.find((p) => p.id === projectId);
return (
<p
key={currentProject?.id}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
>
<span>{currentProject?.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
project: filters.project?.filter((p) => p !== projectId),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
);
})
: (filters[key] as any)?.join(", ")}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-x-1 capitalize">
{filters[key as keyof typeof filters]}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
)}
</div>
);
})}
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
<button
type="button"
onClick={clearAllFilters}
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-xs"
>
<span>Clear all filters</span>
<XMarkIcon className="h-3 w-3" />
</button>
)}
</div>
);
};

View File

@@ -1,32 +1,23 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
// next
import Image from "next/image";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// react-dropdown
import { useDropzone } from "react-dropzone";
// headless ui
import { Tab, Transition, Popover } from "@headlessui/react";
// services
import fileService from "services/file.service";
// components
import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui";
// hooks
import useWorkspaceDetails from "hooks/use-workspace-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
const unsplashEnabled =
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1";
// components
import { Input, PrimaryButton, SecondaryButton, Loader } from "components/ui";
const tabOptions = [
{
key: "unsplash",
title: "Unsplash",
},
{
key: "images",
title: "Images",
@@ -64,8 +55,22 @@ export const ImagePickerPopover: React.FC<Props> = ({
search: "",
});
const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () =>
fileService.getUnsplashImages(1, searchParams)
const { data: unsplashImages, error: unsplashError } = useSWR(
`UNSPLASH_IMAGES_${searchParams}`,
() => fileService.getUnsplashImages(searchParams),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
const { data: projectCoverImages } = useSWR(
`PROJECT_COVER_IMAGES`,
() => fileService.getProjectCoverImages(),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
const imagePickerRef = useRef<HTMLDivElement>(null);
@@ -115,18 +120,17 @@ export const ImagePickerPopover: React.FC<Props> = ({
};
useEffect(() => {
if (!images || value !== null) return;
onChange(images[0].urls.regular);
}, [value, onChange, images]);
if (!unsplashImages || value !== null) return;
onChange(unsplashImages[0].urls.regular);
}, [value, onChange, unsplashImages]);
useOutsideClickDetector(imagePickerRef, () => setIsOpen(false));
if (!unsplashEnabled) return null;
return (
<Popover className="relative z-[2]" ref={ref}>
<Popover.Button
className="rounded-sm border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
onClick={() => setIsOpen((prev) => !prev)}
disabled={disabled}
>
@@ -141,15 +145,19 @@ export const ImagePickerPopover: React.FC<Props> = ({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-80 shadow-lg">
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-sm">
<div
ref={imagePickerRef}
className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl"
>
<Tab.Group>
<div>
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
{tabOptions.map((tab) => (
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
{tabOptions.map((tab) => {
if (!unsplashImages && unsplashError && tab.key === "unsplash") return null;
if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images")
return null;
return (
<Tab
key={tab.key}
className={({ selected }) =>
@@ -160,50 +168,106 @@ export const ImagePickerPopover: React.FC<Props> = ({
>
{tab.title}
</Tab>
))}
</Tab.List>
</div>
);
})}
</Tab.List>
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
<Tab.Panel className="h-full w-full space-y-4">
<div className="flex gap-x-2 pt-7">
<Input
name="search"
className="text-sm"
id="search"
value={formData.search}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
placeholder="Search for images"
/>
<PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm">
Search
</PrimaryButton>
</div>
{images ? (
<div className="grid grid-cols-4 gap-4">
{images.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
>
<img
src={image.urls.small}
alt={image.alt_description}
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
/>
{(unsplashImages || !unsplashError) && (
<Tab.Panel className="h-full w-full space-y-4 mt-4">
<div className="flex gap-x-2">
<Input
name="search"
className="text-sm"
id="search"
value={formData.search}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
placeholder="Search for images"
/>
<PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm">
Search
</PrimaryButton>
</div>
{unsplashImages ? (
unsplashImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{unsplashImages.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
>
<img
src={image.urls.small}
alt={image.alt_description}
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
/>
</div>
))}
</div>
))}
</div>
) : (
<div className="flex justify-center pt-20">
<Spinner />
</div>
)}
</Tab.Panel>
<Tab.Panel className="h-full w-full pt-5">
) : (
<p className="text-center text-custom-text-300 text-xs pt-7">
No images found.
</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4">
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</Tab.Panel>
)}
{(!projectCoverImages || projectCoverImages.length !== 0) && (
<Tab.Panel className="h-full w-full space-y-4 mt-4">
{projectCoverImages ? (
projectCoverImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{projectCoverImages.map((image, index) => (
<div
key={image}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image);
}}
>
<img
src={image}
alt={`Default project cover image- ${index}`}
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
/>
</div>
))}
</div>
) : (
<p className="text-center text-custom-text-300 text-xs pt-7">
No images found.
</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4 pt-4">
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</Tab.Panel>
)}
<Tab.Panel className="h-full w-full mt-4">
<div className="w-full h-full flex flex-col gap-y-2">
<div className="flex items-center gap-3 w-full flex-1">
<div

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useState } from "react";
import NextImage from "next/image";
import { useRouter } from "next/router";
// react-dropzone
@@ -12,7 +11,7 @@ import fileServices from "services/file.service";
// hooks
import useWorkspaceDetails from "hooks/use-workspace-details";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
import { DangerButton, PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { UserCircleIcon } from "components/icons";
@@ -21,6 +20,8 @@ type Props = {
onClose: () => void;
isOpen: boolean;
onSuccess: (url: string) => void;
isRemoving: boolean;
handleDelete: () => void;
userImage?: boolean;
};
@@ -29,6 +30,8 @@ export const ImageUploadModal: React.FC<Props> = ({
onSuccess,
isOpen,
onClose,
isRemoving,
handleDelete,
userImage,
}) => {
const [image, setImage] = useState<File | null>(null);
@@ -148,12 +151,10 @@ export const ImageUploadModal: React.FC<Props> = ({
>
Edit
</button>
<NextImage
layout="fill"
objectFit="cover"
<img
src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image"
className="rounded-lg"
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
/>
</>
) : (
@@ -182,15 +183,22 @@ export const ImageUploadModal: React.FC<Props> = ({
<p className="my-4 text-custom-text-200 text-sm">
File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p>
<div className="flex items-center justify-end gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</PrimaryButton>
<div className="flex items-center justify-between">
<div className="flex items-center">
<DangerButton onClick={handleDelete} outline disabled={!value}>
{isRemoving ? "Removing..." : "Remove"}
</DangerButton>
</div>
<div className="flex items-center gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</PrimaryButton>
</div>
</div>
</Dialog.Panel>
</Transition.Child>

View File

@@ -12,6 +12,7 @@ import stateService from "services/state.service";
// hooks
import useUser from "hooks/use-user";
import { useProjectMyMembership } from "contexts/project-member.context";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// components
import {
AllLists,
@@ -49,7 +50,8 @@ type Props = {
};
secondaryButton?: React.ReactNode;
};
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit" | "updateDraft") => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleOnDragEnd: (result: DropResult) => Promise<void>;
openIssuesListModal: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
@@ -66,6 +68,7 @@ export const AllViews: React.FC<Props> = ({
dragDisabled = false,
emptyState,
handleIssueAction,
handleDraftIssueAction,
handleOnDragEnd,
openIssuesListModal,
removeIssue,
@@ -84,6 +87,8 @@ export const AllViews: React.FC<Props> = ({
const { groupedIssues, isEmpty, displayFilters } = viewProps;
const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug
@@ -132,6 +137,7 @@ export const AllViews: React.FC<Props> = ({
states={states}
addIssueToGroup={addIssueToGroup}
handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
removeIssue={removeIssue}
myIssueProjectId={myIssueProjectId}
@@ -149,6 +155,7 @@ export const AllViews: React.FC<Props> = ({
disableAddIssueOption={disableAddIssueOption}
dragDisabled={dragDisabled}
handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
handleTrashBox={handleTrashBox}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
myIssueProjectId={myIssueProjectId}
@@ -170,6 +177,8 @@ export const AllViews: React.FC<Props> = ({
) : displayFilters?.layout === "spreadsheet" ? (
<SpreadsheetView
handleIssueAction={handleIssueAction}
spreadsheetIssues={spreadsheetIssues}
mutateIssues={mutateIssues}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
disableUserActions={disableUserActions}
user={user}

View File

@@ -19,7 +19,8 @@ type Props = {
disableUserActions: boolean;
disableAddIssueOption?: boolean;
dragDisabled: boolean;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit" | "updateDraft") => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
@@ -37,6 +38,7 @@ export const AllBoards: React.FC<Props> = ({
disableAddIssueOption = false,
dragDisabled,
handleIssueAction,
handleDraftIssueAction,
handleTrashBox,
openIssuesListModal,
myIssueProjectId,
@@ -74,7 +76,7 @@ export const AllBoards: React.FC<Props> = ({
readOnly={disableUserActions}
/>
{groupedIssues ? (
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8 bg-custom-background-90">
{Object.keys(groupedIssues).map((singleGroup, index) => {
const currentState =
displayFilters?.group_by === "state"
@@ -94,6 +96,7 @@ export const AllBoards: React.FC<Props> = ({
dragDisabled={dragDisabled}
groupTitle={singleGroup}
handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
handleTrashBox={handleTrashBox}
openIssuesListModal={openIssuesListModal ?? null}
handleMyIssueOpen={handleMyIssueOpen}

View File

@@ -20,7 +20,7 @@ import { renderEmoji } from "helpers/emoji.helper";
// types
import { IIssueViewProps, IState, TIssuePriorities, TStateGroups } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, WORKSPACE_LABELS } from "constants/fetch-keys";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
@@ -50,8 +50,6 @@ export const BoardHeader: React.FC<Props> = ({
const { displayFilters, groupedIssues } = viewProps;
console.log("dF", displayFilters);
const { data: issueLabels } = useSWR(
workspaceSlug && projectId && displayFilters?.group_by === "labels"
? PROJECT_ISSUE_LABELS(projectId.toString())
@@ -61,6 +59,15 @@ export const BoardHeader: React.FC<Props> = ({
: null
);
const { data: workspaceLabels } = useSWR(
workspaceSlug && displayFilters?.group_by === "labels"
? WORKSPACE_LABELS(workspaceSlug.toString())
: null,
workspaceSlug && displayFilters?.group_by === "labels"
? () => issuesService.getWorkspaceLabels(workspaceSlug.toString())
: null
);
const { data: members } = useSWR(
workspaceSlug &&
projectId &&
@@ -84,7 +91,10 @@ export const BoardHeader: React.FC<Props> = ({
title = addSpaceIfCamelCase(currentState?.name ?? "");
break;
case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
title =
[...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.name ?? "None";
break;
case "project":
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
@@ -139,7 +149,9 @@ export const BoardHeader: React.FC<Props> = ({
break;
case "labels":
const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
[...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.color ?? "#000000";
icon = (
<span
className="h-3.5 w-3.5 flex-shrink-0 rounded-full"

View File

@@ -2,3 +2,4 @@ export * from "./all-boards";
export * from "./board-header";
export * from "./single-board";
export * from "./single-issue";
export * from "./inline-create-issue-form";

View File

@@ -0,0 +1,62 @@
import { useEffect } from "react";
// react hook form
import { useFormContext } from "react-hook-form";
// components
import { InlineCreateIssueFormWrapper } from "components/core";
// hooks
import useProjectDetails from "hooks/use-project-details";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<div>
<h4 className="text-sm font-medium leading-5 text-custom-text-300">
{projectDetails?.identifier ?? "..."}
</h4>
<input
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 pl-0 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
);
};
export const BoardInlineCreateIssueForm: React.FC<Props> = (props) => (
<>
<InlineCreateIssueFormWrapper
className="flex flex-col border-[0.5px] border-custom-border-100 justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px] mb-3 rounded bg-custom-background-100 shadow-custom-shadow-sm"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
{props.isOpen && (
<p className="text-xs ml-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
</>
);

View File

@@ -6,7 +6,8 @@ import { useRouter } from "next/router";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd";
// components
import { BoardHeader, SingleBoardIssue } from "components/core";
import { CreateUpdateDraftIssueModal } from "components/issues";
import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core";
// ui
import { CustomMenu } from "components/ui";
// icons
@@ -24,6 +25,7 @@ type Props = {
dragDisabled: boolean;
groupTitle: string;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null;
handleMyIssueOpen?: (issue: IIssue) => void;
@@ -33,30 +35,40 @@ type Props = {
viewProps: IIssueViewProps;
};
export const SingleBoard: React.FC<Props> = ({
addIssueToGroup,
currentState,
groupTitle,
disableUserActions,
disableAddIssueOption = false,
dragDisabled,
handleIssueAction,
handleTrashBox,
openIssuesListModal,
handleMyIssueOpen,
removeIssue,
user,
userAuth,
viewProps,
}) => {
export const SingleBoard: React.FC<Props> = (props) => {
const {
addIssueToGroup,
currentState,
groupTitle,
disableUserActions,
disableAddIssueOption = false,
dragDisabled,
handleIssueAction,
handleDraftIssueAction,
handleTrashBox,
openIssuesListModal,
handleMyIssueOpen,
removeIssue,
user,
userAuth,
viewProps,
} = props;
// collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true);
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
const [isCreateDraftIssueModalOpen, setIsCreateDraftIssueModalOpen] = useState(false);
const { displayFilters, groupedIssues } = viewProps;
const router = useRouter();
const { cycleId, moduleId } = router.query;
const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues";
const isProfileIssuesPage = router.pathname.split("/")[2] === "profile";
const isDraftIssuesPage = router.pathname.split("/")[4] === "draft-issues";
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
// Check if it has at least 4 tickets since it is enough to accommodate the Calendar height
@@ -65,10 +77,48 @@ export const SingleBoard: React.FC<Props> = ({
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
const scrollToBottom = () => {
const boardListElement = document.getElementById(`board-list-${groupTitle}`);
// timeout is needed because the animation
// takes time to complete & we can scroll only after that
const timeoutId = setTimeout(() => {
if (boardListElement)
boardListElement.scrollBy({
top: boardListElement.scrollHeight,
left: 0,
behavior: "smooth",
});
clearTimeout(timeoutId);
}, 10);
};
const onCreateClick = () => {
setIsInlineCreateIssueFormOpen(true);
scrollToBottom();
};
const handleAddIssueToGroup = () => {
if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true);
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
else onCreateClick();
};
return (
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
<CreateUpdateDraftIssueModal
isOpen={isCreateDraftIssueModalOpen}
handleClose={() => setIsCreateDraftIssueModalOpen(false)}
prePopulateData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by! === "labels" ? "labels_list" : displayFilters?.group_by!]:
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
}}
/>
<BoardHeader
addIssueToGroup={addIssueToGroup}
addIssueToGroup={handleAddIssueToGroup}
currentState={currentState}
groupTitle={groupTitle}
isCollapsed={isCollapsed}
@@ -113,6 +163,7 @@ export const SingleBoard: React.FC<Props> = ({
</>
)}
<div
id={`board-list-${groupTitle}`}
className={`pt-3 ${
hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : ""
} `}
@@ -132,10 +183,21 @@ export const SingleBoard: React.FC<Props> = ({
type={type}
index={index}
issue={issue}
projectId={issue.project_detail.id}
groupTitle={groupTitle}
editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleDraftIssueEdit={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "edit")
: undefined
}
handleDraftIssueDelete={() =>
handleDraftIssueAction
? handleDraftIssueAction(issue, "delete")
: undefined
}
handleTrashBox={handleTrashBox}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => {
@@ -155,23 +217,42 @@ export const SingleBoard: React.FC<Props> = ({
display: displayFilters?.order_by === "sort_order" ? "inline" : "none",
}}
>
{provided.placeholder}
<>{provided.placeholder}</>
</span>
<BoardInlineCreateIssueForm
isOpen={isInlineCreateIssueFormOpen}
handleClose={() => setIsInlineCreateIssueFormOpen(false)}
onSuccess={() => scrollToBottom()}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by! === "labels"
? "labels_list"
: displayFilters?.group_by!]:
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
}}
/>
</div>
{displayFilters?.group_by !== "created_by" && (
<div>
{type === "issue"
? !disableAddIssueOption && (
? !disableAddIssueOption &&
!isDraftIssuesPage && (
<button
type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
onClick={addIssueToGroup}
onClick={() => {
if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
else onCreateClick();
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
)
: !disableUserActions && (
: !disableUserActions &&
!isDraftIssuesPage && (
<CustomMenu
customButton={
<button
@@ -185,7 +266,13 @@ export const SingleBoard: React.FC<Props> = ({
position="left"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToGroup}>
<CustomMenu.MenuItem
onClick={() => {
if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true);
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
else onCreateClick();
}}
>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (

View File

@@ -13,19 +13,14 @@ import {
} from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
import trackEventServices from "services/track-event.service";
// hooks
import useToast from "hooks/use-toast";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import {
ViewAssigneeSelect,
ViewDueDateSelect,
ViewEstimateSelect,
ViewIssueLabel,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues";
import { MembersSelect, LabelSelect, PrioritySelect } from "components/project";
import { StateSelect } from "components/states";
// ui
import { ContextMenu, CustomMenu, Tooltip } from "components/ui";
// icons
@@ -44,7 +39,15 @@ import { LayerDiagonalIcon } from "components/icons";
import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
import {
ICurrentUserResponse,
IIssue,
IIssueViewProps,
IState,
ISubIssueResponse,
TIssuePriorities,
UserAuth,
} from "types";
// fetch-keys
import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys";
@@ -53,6 +56,7 @@ type Props = {
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
issue: IIssue;
projectId: string;
groupTitle?: string;
index: number;
editIssue: () => void;
@@ -60,6 +64,8 @@ type Props = {
handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
handleDraftIssueEdit?: () => void;
handleDraftIssueDelete?: () => void;
handleTrashBox: (isDragging: boolean) => void;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
@@ -72,6 +78,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
provided,
snapshot,
issue,
projectId,
index,
editIssue,
makeIssueCopy,
@@ -79,6 +86,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
removeIssue,
groupTitle,
handleDeleteIssue,
handleDraftIssueEdit,
handleDraftIssueDelete,
handleTrashBox,
disableUserActions,
user,
@@ -97,7 +106,9 @@ export const SingleBoardIssue: React.FC<Props> = ({
const { displayFilters, properties, mutateIssues } = viewProps;
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { workspaceSlug, cycleId, moduleId } = router.query;
const isDraftIssue = router.pathname.includes("draft-issues");
const { setToastAlert } = useToast();
@@ -182,6 +193,86 @@ export const SingleBoardIssue: React.FC<Props> = ({
});
};
const handleStateChange = (data: string, states: IState[] | undefined) => {
const oldState = states?.find((s) => s.id === issue.state);
const newState = states?.find((s) => s.id === data);
partialUpdateIssue(
{
state: data,
state_detail: newState,
},
issue
);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_STATE",
user
);
if (oldState?.group !== "completed" && newState?.group !== "completed") {
trackEventServices.trackIssueMarkedAsDoneEvent(
{
workspaceSlug: issue.workspace_detail.slug,
workspaceId: issue.workspace_detail.id,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
user
);
}
};
const handleAssigneeChange = (data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data);
partialUpdateIssue({ assignees_list: data }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
user
);
};
const handleLabelChange = (data: any) => {
partialUpdateIssue({ labels_list: data }, issue);
};
const handlePriorityChange = (data: TIssuePriorities) => {
partialUpdateIssue({ priority: data }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_PRIORITY",
user
);
};
useEffect(() => {
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
}, [snapshot, handleTrashBox]);
@@ -211,29 +302,47 @@ export const SingleBoardIssue: React.FC<Props> = ({
>
{!isNotAllowed && (
<>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
<ContextMenu.Item
Icon={PencilIcon}
onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else editIssue();
}}
>
Edit issue
</ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
Make a copy...
</ContextMenu.Item>
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
{!isDraftIssue && (
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
Make a copy...
</ContextMenu.Item>
)}
<ContextMenu.Item
Icon={TrashIcon}
onClick={() => {
if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete();
else handleDeleteIssue(issue);
}}
>
Delete issue
</ContextMenu.Item>
</>
)}
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</ContextMenu.Item>
<a
href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
{!isDraftIssue && (
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</ContextMenu.Item>
</a>
)}
{!isDraftIssue && (
<a
href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item>
</a>
)}
</ContextMenu>
<div
className={`mb-3 rounded bg-custom-background-100 shadow ${
@@ -268,13 +377,18 @@ export const SingleBoardIssue: React.FC<Props> = ({
</button>
}
>
<CustomMenu.MenuItem onClick={editIssue}>
<CustomMenu.MenuItem
onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else editIssue();
}}
>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit issue</span>
</div>
</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
{type !== "issue" && removeIssue && !isDraftIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<div className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" />
@@ -282,18 +396,25 @@ export const SingleBoardIssue: React.FC<Props> = ({
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
<CustomMenu.MenuItem
onClick={() => {
if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete();
else handleDeleteIssue(issue);
}}
>
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue Link</span>
</div>
</CustomMenu.MenuItem>
{!isDraftIssue && (
<CustomMenu.MenuItem onClick={handleCopyText}>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue Link</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
@@ -307,10 +428,12 @@ export const SingleBoardIssue: React.FC<Props> = ({
)}
<button
type="button"
className="text-sm text-left break-words line-clamp-2"
onClick={openPeekOverview}
onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else openPeekOverview();
}}
>
{issue.name}
<span className="text-sm text-left break-words line-clamp-2">{issue.name}</span>
</button>
</div>
@@ -320,21 +443,20 @@ export const SingleBoardIssue: React.FC<Props> = ({
}`}
>
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
user={user}
selfPositioned
<PrioritySelect
value={issue.priority}
onChange={handlePriorityChange}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
user={user}
selfPositioned
<StateSelect
value={issue.state_detail}
onChange={handleStateChange}
projectId={projectId}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.start_date && issue.start_date && (
@@ -358,16 +480,24 @@ export const SingleBoardIssue: React.FC<Props> = ({
/>
)}
{properties.labels && issue.labels.length > 0 && (
<ViewIssueLabel labelDetails={issue.label_details} maxRender={2} />
<LabelSelect
value={issue.labels}
projectId={projectId}
onChange={handleLabelChange}
labelsDetails={issue.label_details}
hideDropdownArrow
user={user}
disabled={isNotAllowed}
/>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
customButton
user={user}
selfPositioned
<MembersSelect
value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange}
membersDetails={issue.assignee_details}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.estimate && issue.estimate_point !== null && (

View File

@@ -5,25 +5,12 @@ import { Popover, Transition } from "@headlessui/react";
// ui
import { CustomMenu, ToggleSwitch } from "components/ui";
// icons
import {
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "@heroicons/react/24/outline";
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
// helpers
import {
addMonths,
addSevenDaysToDate,
formatDate,
getCurrentWeekEndDate,
getCurrentWeekStartDate,
isSameMonth,
isSameYear,
lastDayOfWeek,
startOfWeek,
subtract7DaysToDate,
subtractMonths,
updateDateWithMonth,
updateDateWithYear,
} from "helpers/calendar.helper";
@@ -31,190 +18,136 @@ import {
import { MONTHS_LIST, YEARS_LIST } from "constants/calendar";
type Props = {
isMonthlyView: boolean;
setIsMonthlyView: React.Dispatch<React.SetStateAction<boolean>>;
currentDate: Date;
setCurrentDate: React.Dispatch<React.SetStateAction<Date>>;
showWeekEnds: boolean;
setShowWeekEnds: React.Dispatch<React.SetStateAction<boolean>>;
changeDateRange: (startDate: Date, endDate: Date) => void;
};
export const CalendarHeader: React.FC<Props> = ({
setIsMonthlyView,
isMonthlyView,
currentDate,
setCurrentDate,
showWeekEnds,
setShowWeekEnds,
changeDateRange,
}) => {
const updateDate = (date: Date) => {
setCurrentDate(date);
}) => (
<div className="mb-4 flex items-center justify-between">
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm ">
<Popover className="flex h-full items-center justify-start rounded-lg">
{({ open }) => (
<>
<Popover.Button>
<div className="flex items-center justify-center gap-2 text-2xl font-semibold text-custom-text-100">
<span>{formatDate(currentDate, "Month")}</span>{" "}
<span>{formatDate(currentDate, "yyyy")}</span>
</div>
</Popover.Button>
changeDateRange(startOfWeek(date), lastDayOfWeek(date));
};
return (
<div className="mb-4 flex items-center justify-between">
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm ">
<Popover className="flex h-full items-center justify-start rounded-lg">
{({ open }) => (
<>
<Popover.Button>
<div className="flex items-center justify-center gap-2 text-2xl font-semibold text-custom-text-100">
<span>{formatDate(currentDate, "Month")}</span>{" "}
<span>{formatDate(currentDate, "yyyy")}</span>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-custom-background-80 shadow-lg">
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
{YEARS_LIST.map((year) => (
<button
onClick={() => setCurrentDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-custom-text-100"
: "text-xs text-custom-text-200 "
} hover:text-sm hover:font-medium hover:text-custom-text-100`}
>
{year.label}
</button>
))}
</div>
</Popover.Button>
<div className="grid grid-cols-4 border-t border-custom-border-200 px-2">
{MONTHS_LIST.map((month) => (
<button
onClick={() =>
setCurrentDate(updateDateWithMonth(`${month.value}`, currentDate))
}
className={`px-2 py-2 text-xs text-custom-text-200 hover:font-medium hover:text-custom-text-100 ${
isSameMonth(`${month.value}`, currentDate)
? "font-medium text-custom-text-100"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-custom-background-80 shadow-lg">
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
{YEARS_LIST.map((year) => (
<button
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-custom-text-100"
: "text-xs text-custom-text-200 "
} hover:text-sm hover:font-medium hover:text-custom-text-100`}
>
{year.label}
</button>
))}
</div>
<div className="grid grid-cols-4 border-t border-custom-border-200 px-2">
{MONTHS_LIST.map((month) => (
<button
onClick={() =>
updateDate(updateDateWithMonth(`${month.value}`, currentDate))
}
className={`px-2 py-2 text-xs text-custom-text-200 hover:font-medium hover:text-custom-text-100 ${
isSameMonth(`${month.value}`, currentDate)
? "font-medium text-custom-text-100"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<div className="flex items-center gap-2">
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(subtractMonths(currentDate, 1));
} else {
setCurrentDate(subtract7DaysToDate(currentDate));
changeDateRange(
getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
getCurrentWeekEndDate(subtract7DaysToDate(currentDate))
);
}
}}
>
<ChevronLeftIcon className="h-4 w-4" />
</button>
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(addMonths(currentDate, 1));
} else {
setCurrentDate(addSevenDaysToDate(currentDate));
changeDateRange(
getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
getCurrentWeekEndDate(addSevenDaysToDate(currentDate))
);
}
}}
>
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex w-full items-center justify-end gap-2">
<div className="flex items-center gap-2">
<button
className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none"
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(new Date());
} else {
setCurrentDate(new Date());
changeDateRange(
getCurrentWeekStartDate(new Date()),
getCurrentWeekEndDate(new Date())
);
}
const previousMonthYear =
currentDate.getMonth() === 0
? currentDate.getFullYear() - 1
: currentDate.getFullYear();
const previousMonthMonth =
currentDate.getMonth() === 0 ? 11 : currentDate.getMonth() - 1;
const previousMonthFirstDate = new Date(previousMonthYear, previousMonthMonth, 1);
setCurrentDate(previousMonthFirstDate);
}}
>
Today
<ChevronLeftIcon className="h-4 w-4" />
</button>
<button
className="cursor-pointer"
onClick={() => {
const nextMonthYear =
currentDate.getMonth() === 11
? currentDate.getFullYear() + 1
: currentDate.getFullYear();
const nextMonthMonth = (currentDate.getMonth() + 1) % 12;
<CustomMenu
customButton={
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none ">
{isMonthlyView ? "Monthly" : "Weekly"}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
const nextMonthFirstDate = new Date(nextMonthYear, nextMonthMonth, 1);
setCurrentDate(nextMonthFirstDate);
}}
>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(true);
changeDateRange(startOfWeek(currentDate), lastDayOfWeek(currentDate));
}}
className="w-52 text-sm text-custom-text-200"
>
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
<span className="flex items-center gap-2">Monthly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-100" : "opacity-0"}`}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(false);
changeDateRange(
getCurrentWeekStartDate(currentDate),
getCurrentWeekEndDate(currentDate)
);
}}
className="w-52 text-sm text-custom-text-200"
>
<div className="flex w-full items-center justify-between gap-2">
<span className="flex items-center gap-2">Weekly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-0" : "opacity-100"}`}
/>
</div>
</CustomMenu.MenuItem>
<div className="mt-1 flex w-52 items-center justify-between border-t border-custom-border-200 py-2 px-1 text-sm text-custom-text-200">
<h4>Show weekends</h4>
<ToggleSwitch value={showWeekEnds} onChange={() => setShowWeekEnds(!showWeekEnds)} />
</div>
</CustomMenu>
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
</div>
);
};
<div className="flex w-full items-center justify-end gap-2">
<button
className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none"
onClick={() => setCurrentDate(new Date())}
>
Today
</button>
<CustomMenu
customButton={
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none">
Options
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
>
<div className="flex w-52 items-center justify-between px-1 text-sm text-custom-text-200">
<h4>Show weekends</h4>
<ToggleSwitch value={showWeekEnds} onChange={() => setShowWeekEnds(!showWeekEnds)} />
</div>
</CustomMenu>
</div>
</div>
);
export default CalendarHeader;

View File

@@ -1,10 +1,6 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
@@ -50,31 +46,27 @@ export const CalendarView: React.FC<Props> = ({
userAuth,
}) => {
const [showWeekEnds, setShowWeekEnds] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date());
const [isMonthlyView, setIsMonthlyView] = useState(true);
const { calendarIssues, mutateIssues, params, activeMonthDate, setActiveMonthDate } =
useCalendarIssuesView();
const [calendarDates, setCalendarDates] = useState<ICalendarRange>({
startDate: startOfWeek(currentDate),
endDate: lastDayOfWeek(currentDate),
startDate: startOfWeek(activeMonthDate),
endDate: lastDayOfWeek(activeMonthDate),
});
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { calendarIssues, mutateIssues, params, displayFilters, setDisplayFilters } =
useCalendarIssuesView();
const totalDate = eachDayOfInterval({
start: calendarDates.startDate,
end: calendarDates.endDate,
});
const onlyWeekDays = weekDayInterval({
start: calendarDates.startDate,
end: calendarDates.endDate,
});
const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays;
const currentViewDays = showWeekEnds
? eachDayOfInterval({
start: calendarDates.startDate,
end: calendarDates.endDate,
})
: weekDayInterval({
start: calendarDates.startDate,
end: calendarDates.endDate,
});
const currentViewDaysData = currentViewDays.map((date: Date) => {
const filterIssue =
@@ -148,27 +140,12 @@ export const CalendarView: React.FC<Props> = ({
.then(() => mutate(fetchKey));
};
const changeDateRange = (startDate: Date, endDate: Date) => {
setCalendarDates({
startDate,
endDate,
});
setDisplayFilters({
calendar_date_range: `${renderDateFormat(startDate)};after,${renderDateFormat(
endDate
)};before`,
});
};
useEffect(() => {
if (!displayFilters || displayFilters.calendar_date_range === "")
setDisplayFilters({
calendar_date_range: `${renderDateFormat(
startOfWeek(currentDate)
)};after,${renderDateFormat(lastDayOfWeek(currentDate))};before`,
});
}, [currentDate, displayFilters, setDisplayFilters]);
setCalendarDates({
startDate: startOfWeek(activeMonthDate),
endDate: lastDayOfWeek(activeMonthDate),
});
}, [activeMonthDate]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
@@ -183,15 +160,15 @@ export const CalendarView: React.FC<Props> = ({
{calendarIssues ? (
<div className="h-full overflow-y-auto">
<DragDropContext onDragEnd={onDragEnd}>
<div className="h-full rounded-lg p-8 text-custom-text-200">
<div
id={`calendar-view-${cycleId ?? moduleId ?? viewId ?? ""}`}
className="h-full rounded-lg p-8 text-custom-text-200"
>
<CalendarHeader
isMonthlyView={isMonthlyView}
setIsMonthlyView={setIsMonthlyView}
showWeekEnds={showWeekEnds}
setShowWeekEnds={setShowWeekEnds}
currentDate={currentDate}
setCurrentDate={setCurrentDate}
changeDateRange={changeDateRange}
currentDate={activeMonthDate}
setCurrentDate={setActiveMonthDate}
/>
<div
@@ -202,30 +179,15 @@ export const CalendarView: React.FC<Props> = ({
{weeks.map((date, index) => (
<div
key={index}
className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200 ${
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`}
className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200`}
>
<span>
{isMonthlyView
? formatDate(date, "eee").substring(0, 3)
: formatDate(date, "eee")}
</span>
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
<span>{formatDate(date, "eee").substring(0, 3)}</span>
</div>
))}
</div>
<div
className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${
className={`grid h-full auto-rows-min ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `}
>
@@ -236,7 +198,6 @@ export const CalendarView: React.FC<Props> = ({
date={date}
handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate}
isMonthlyView={isMonthlyView}
showWeekEnds={showWeekEnds}
user={user}
isNotAllowed={isNotAllowed}

View File

@@ -2,3 +2,4 @@ export * from "./calendar-header";
export * from "./calendar";
export * from "./single-date";
export * from "./single-issue";
export * from "./inline-create-issue-form";

View File

@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from "react";
// next
import { useRouter } from "next/router";
// react hook form
import { useFormContext } from "react-hook-form";
import { InlineCreateIssueFormWrapper } from "components/core";
// hooks
import useProjectDetails from "hooks/use-project-details";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
dependencies: any[];
};
const useCheckIfThereIsSpaceOnRight = (ref: React.RefObject<HTMLDivElement>, deps: any[]) => {
const [isThereSpaceOnRight, setIsThereSpaceOnRight] = useState(true);
const router = useRouter();
const { moduleId, cycleId, viewId } = router.query;
const container = document.getElementById(`calendar-view-${cycleId ?? moduleId ?? viewId}`);
useEffect(() => {
if (!ref.current) return;
const { right } = ref.current.getBoundingClientRect();
const width = right;
const innerWidth = container?.getBoundingClientRect().width ?? window.innerWidth;
if (width > innerWidth) setIsThereSpaceOnRight(false);
else setIsThereSpaceOnRight(true);
}, [ref, deps, container]);
return isThereSpaceOnRight;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<h4 className="text-sm font-medium leading-5 text-custom-text-400">
{projectDetails?.identifier ?? "..."}
</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full pr-2 py-2.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const CalendarInlineCreateIssueForm: React.FC<Props> = (props) => {
const { isOpen, dependencies } = props;
const ref = useRef<HTMLDivElement>(null);
const isSpaceOnRight = useCheckIfThereIsSpaceOnRight(ref, dependencies);
return (
<>
<div
ref={ref}
className={`absolute top-10 transition-all z-20 w-full max-w-[calc(100%-1.25rem)] ${
isOpen ? "opacity-100 scale-100" : "opacity-0 pointer-events-none scale-95"
} right-2.5`}
>
<InlineCreateIssueFormWrapper
{...props}
className="flex w-full px-1.5 border-[0.5px] border-custom-border-100 rounded z-50 items-center gap-x-2 bg-custom-background-100 shadow-custom-shadow-sm transition-opacity"
>
<InlineInput />
</InlineCreateIssueFormWrapper>
</div>
{/* Added to make any other element as outside click. This will make input also to be outside. */}
{isOpen && <div className="w-screen h-screen fixed inset-0 z-10" />}
</>
);
};

View File

@@ -1,10 +1,14 @@
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// react-beautiful-dnd
import { Draggable } from "react-beautiful-dnd";
// component
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { SingleCalendarIssue } from "./single-issue";
import { CalendarInlineCreateIssueForm } from "./inline-create-issue-form";
// icons
import { PlusSmallIcon } from "@heroicons/react/24/outline";
// helper
@@ -20,23 +24,21 @@ type Props = {
issues: IIssue[];
};
addIssueToDate: (date: string) => void;
isMonthlyView: boolean;
showWeekEnds: boolean;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SingleCalendarDate: React.FC<Props> = ({
handleIssueAction,
date,
index,
addIssueToDate,
isMonthlyView,
showWeekEnds,
user,
isNotAllowed,
}) => {
export const SingleCalendarDate: React.FC<Props> = (props) => {
const { handleIssueAction, date, index, showWeekEnds, user, isNotAllowed } = props;
const router = useRouter();
const { cycleId, moduleId } = router.query;
const [showAllIssues, setShowAllIssues] = useState(false);
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
const [formPosition, setFormPosition] = useState({ x: 0, y: 0 });
const totalIssues = date.issues.length;
@@ -48,8 +50,6 @@ export const SingleCalendarDate: React.FC<Props> = ({
ref={provided.innerRef}
{...provided.droppableProps}
className={`group relative flex min-h-[150px] flex-col gap-1.5 border-t border-custom-border-200 p-2.5 text-left text-sm font-medium hover:bg-custom-background-90 ${
isMonthlyView ? "" : "pt-9"
} ${
showWeekEnds
? (index + 1) % 7 === 0
? ""
@@ -59,48 +59,66 @@ export const SingleCalendarDate: React.FC<Props> = ({
: "border-r"
}`}
>
{isMonthlyView && <span>{formatDate(new Date(date.date), "d")}</span>}
{totalIssues > 0 &&
date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => (
<Draggable key={issue.id} draggableId={issue.id} index={index}>
{(provided, snapshot) => (
<SingleCalendarIssue
key={index}
index={index}
provided={provided}
snapshot={snapshot}
issue={issue}
handleEditIssue={() => handleIssueAction(issue, "edit")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</Draggable>
))}
{totalIssues > 4 && (
<button
type="button"
className="w-min whitespace-nowrap rounded-md border border-custom-border-200 bg-custom-background-80 px-1.5 py-1 text-xs"
onClick={() => setShowAllIssues((prevData) => !prevData)}
>
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
</button>
)}
<>
<span>{formatDate(new Date(date.date), "d")}</span>
{totalIssues > 0 &&
date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => (
<Draggable key={issue.id} draggableId={issue.id} index={index}>
{(provided, snapshot) => (
<SingleCalendarIssue
key={index}
index={index}
provided={provided}
snapshot={snapshot}
issue={issue}
projectId={issue.project_detail.id}
handleEditIssue={() => handleIssueAction(issue, "edit")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</Draggable>
))}
<div
className={`absolute top-2 right-2 flex items-center justify-center rounded-md bg-custom-background-80 p-1 text-xs text-custom-text-200 opacity-0 group-hover:opacity-100`}
>
<button
className="flex items-center justify-center gap-1 text-center"
onClick={() => addIssueToDate(date.date)}
>
<PlusSmallIcon className="h-4 w-4 text-custom-text-200" />
Add issue
</button>
</div>
<CalendarInlineCreateIssueForm
isOpen={isCreateIssueFormOpen}
dependencies={[showWeekEnds]}
handleClose={() => setIsCreateIssueFormOpen(false)}
prePopulatedData={{
target_date: date.date,
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
}}
/>
{provided.placeholder}
{totalIssues > 4 && (
<button
type="button"
className="w-min whitespace-nowrap rounded-md border border-custom-border-200 bg-custom-background-80 px-1.5 py-1 text-xs"
onClick={() => setShowAllIssues((prevData) => !prevData)}
>
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
</button>
)}
<div
className={`absolute top-2 right-2 flex items-center justify-center rounded-md bg-custom-background-80 p-1 text-xs text-custom-text-200 opacity-0 group-hover:opacity-100`}
>
<button
onClick={(e) => {
setIsCreateIssueFormOpen(true);
setFormPosition({ x: e.clientX, y: e.clientY });
}}
className="flex items-center justify-center gap-1 text-center"
>
<PlusSmallIcon className="h-4 w-4 text-custom-text-200" />
Add issue
</button>
</div>
{provided.placeholder}
</>
</div>
)}
</StrictModeDroppable>

View File

@@ -8,28 +8,23 @@ import { mutate } from "swr";
import { DraggableProvided, DraggableStateSnapshot } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
import trackEventServices from "services/track-event.service";
// hooks
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
import useIssuesProperties from "hooks/use-issue-properties";
import useToast from "hooks/use-toast";
// components
import { CustomMenu, Tooltip } from "components/ui";
import {
ViewAssigneeSelect,
ViewDueDateSelect,
ViewEstimateSelect,
ViewLabelSelect,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues";
import { LabelSelect, MembersSelect, PrioritySelect } from "components/project";
import { StateSelect } from "components/states";
// icons
import { LinkIcon, PaperClipIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// helper
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// type
import { ICurrentUserResponse, IIssue, ISubIssueResponse } from "types";
import { ICurrentUserResponse, IIssue, IState, ISubIssueResponse, TIssuePriorities } from "types";
// fetch-keys
import {
CYCLE_ISSUES_WITH_PARAMS,
@@ -46,6 +41,7 @@ type Props = {
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
issue: IIssue;
projectId: string;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
@@ -57,11 +53,12 @@ export const SingleCalendarIssue: React.FC<Props> = ({
provided,
snapshot,
issue,
projectId,
user,
isNotAllowed,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { workspaceSlug, cycleId, moduleId, viewId } = router.query;
const { setToastAlert } = useToast();
@@ -153,6 +150,86 @@ export const SingleCalendarIssue: React.FC<Props> = ({
});
};
const handleStateChange = (data: string, states: IState[] | undefined) => {
const oldState = states?.find((s) => s.id === issue.state);
const newState = states?.find((s) => s.id === data);
partialUpdateIssue(
{
state: data,
state_detail: newState,
},
issue
);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_STATE",
user
);
if (oldState?.group !== "completed" && newState?.group !== "completed") {
trackEventServices.trackIssueMarkedAsDoneEvent(
{
workspaceSlug: issue.workspace_detail.slug,
workspaceId: issue.workspace_detail.id,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
user
);
}
};
const handleAssigneeChange = (data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data);
partialUpdateIssue({ assignees_list: data }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
user
);
};
const handleLabelChange = (data: any) => {
partialUpdateIssue({ labels_list: data }, issue);
};
const handlePriorityChange = (data: TIssuePriorities) => {
partialUpdateIssue({ priority: data }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_PRIORITY",
user
);
};
const displayProperties = properties
? Object.values(properties).some((value) => value === true)
: false;
@@ -225,22 +302,20 @@ export const SingleCalendarIssue: React.FC<Props> = ({
{displayProperties && (
<div className="relative mt-1.5 w-full flex flex-wrap items-center gap-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
user={user}
isNotAllowed={isNotAllowed}
<PrioritySelect
value={issue.priority}
onChange={handlePriorityChange}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
className="max-w-full"
isNotAllowed={isNotAllowed}
user={user}
<StateSelect
value={issue.state_detail}
projectId={projectId}
onChange={handleStateChange}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.start_date && issue.start_date && (
@@ -260,21 +335,25 @@ export const SingleCalendarIssue: React.FC<Props> = ({
/>
)}
{properties.labels && issue.labels.length > 0 && (
<ViewLabelSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
<LabelSelect
value={issue.labels}
projectId={projectId}
onChange={handleLabelChange}
labelsDetails={issue.label_details}
hideDropdownArrow
maxRender={1}
user={user}
isNotAllowed={isNotAllowed}
disabled={isNotAllowed}
/>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
user={user}
isNotAllowed={isNotAllowed}
<MembersSelect
value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange}
membersDetails={issue.assignee_details}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.estimate && issue.estimate_point !== null && (

View File

@@ -0,0 +1,62 @@
import { useEffect } from "react";
// react hook form
import { useFormContext } from "react-hook-form";
// hooks
import useProjectDetails from "hooks/use-project-details";
// components
import { InlineCreateIssueFormWrapper } from "components/core";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<div className="w-[14px] h-[14px] rounded-full border border-custom-border-1000 flex-shrink-0" />
<h4 className="text-sm text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const GanttInlineCreateIssueForm: React.FC<Props> = (props) => (
<>
<InlineCreateIssueFormWrapper
className="flex py-3 px-4 border-[0.5px] border-custom-border-100 mr-2.5 items-center rounded gap-x-2 bg-custom-background-100 shadow-custom-shadow-sm"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
{props.isOpen && (
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
</>
);

View File

@@ -5,3 +5,4 @@ export * from "./list-view";
export * from "./spreadsheet-view";
export * from "./all-views";
export * from "./issues-view";
export * from "./inline-issue-create-wrapper";

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