Compare commits

...
Author SHA1 Message Date
Anmol Singh Bhatia d6e4c6da41 chore: icon picker list consisteny 2024-05-08 15:30:13 +05:30
Anmol Singh Bhatia 8ae1e38ef9 chore: project list page icon and emoji improvement 2024-05-08 15:27:58 +05:30
Anmol Singh Bhatia c37264f3c8 chore: project modal icon and emoji improvement 2024-05-08 15:25:01 +05:30
Anmol Singh Bhatia 043d8e8227 chore: project modal icon and emoji improvement 2024-05-08 15:23:31 +05:30
Anmol Singh Bhatia a488a3411c chore: app sidebar emoji and icon consisteny 2024-05-08 15:22:44 +05:30
Bavisetti NarayanandGitHub b8f1734738 fix: draft archive issues (#4393) 2024-05-07 20:31:44 +05:30
Bavisetti NarayanandGitHub d946c51ef1 chore: created by key changed (#4389) 2024-05-07 19:22:00 +05:30
NikhilandGitHub a40517015b [WEB - 1213] fix: module webhook (#4385)
* dev: fix module webhook

* dev: correct the comment

* dev: handle does not exist exception
2024-05-07 15:51:08 +05:30
Aaryan KhandelwalandGitHub 967ad77078 fix: set focus on editor conditionally after image upload (#4387) 2024-05-07 15:49:11 +05:30
Prateek ShouryaandGitHub f5f683b8b8 [WEB-1086] chore: update toast messages for consistency. (#4384) 2024-05-07 15:03:24 +05:30
Anmol Singh BhatiaandGitHub 2aef40b7c5 [WEB-1176] chore: filter dropdown indicator and code refactor (#4379)
* chore: filter dropdown indicator and code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: refactor calculateTotalFilters function with typescript generics
2024-05-07 14:56:19 +05:30
1eba6c24cd [WEB-859] chore: inbox sidebar improvement (#4381)
* chore: added created by field in inbox issue

* chore: inbox sidebar list item created by avatar added

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-05-07 14:10:27 +05:30
Prateek ShouryaandGitHub a46eccf300 [WEB-1117] chore: add tool tip for Snooze under Notification Panel. (#4377) 2024-05-07 14:08:02 +05:30
Aaryan KhandelwalandGitHub 780b239ecb [WEB-1142] chore: optimistically add issue to cycle/modules (#4334)
* chore: optimistically add issue to cycle and module

* chore: update toast alerts

* refactor: module issue store

* chore: added addCycleToIssueFunction
2024-05-07 14:05:56 +05:30
Anmol Singh BhatiaandGitHub a85517de99 [WEB-1098] chore: dropdowns enhancement (#4375) 2024-05-07 14:02:48 +05:30
P BandGitHub fb68b77068 Changed tagline, description, and disclaimer (#4382)
- Minor grammatical and language corrections
- Another update coming soon
2024-05-07 13:56:03 +05:30
Aaryan KhandelwalandGitHub 20e7dc68e6 [WEB-1127] style: create and delete modals' consistency (#4345)
* style: update modals typography, alignment

* style: made the modal separator full width

* style: delete modals consistency

* style: update the remaining delete modals

* chore: delete modal secondary button text

* style: update the remaining create modals

* chore: update cancel button text

* chore: created modal core

* style: modals responsiveness
2024-05-07 12:44:36 +05:30
5ef51edad7 [WEB-1134] fix: module link create and delete mutation (#4373)
* fix: module link create and delete mutation

* chore: module link mutation store updates

* chore: code refactor

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-05-06 17:56:35 +05:30
Bavisetti NarayanandGitHub 06a664f6b9 [WEB-1151] fix: email notification for mentions (#4360)
* chore: mention notification

* chore: mention filters
2024-05-06 16:07:56 +05:30
Anmol Singh BhatiaandGitHub cdab12e4b6 [WEB-1157] chore: label select improvement (#4370) 2024-05-06 15:30:58 +05:30
Anmol Singh BhatiaandGitHub 59f1cc1962 [WEB-1114] chore: recent activity message updated (#4371) 2024-05-06 15:28:33 +05:30
Anmol Singh BhatiaandGitHub 562e50815d [WEB-1007] chore: invalid issue error empty state added (#4372) 2024-05-06 15:27:56 +05:30
rahulrameshaandGitHub 463f4781ae [WEB-1137] fix: Firefox distorted vertical text (#4376) 2024-05-06 15:22:45 +05:30
Bavisetti NarayanandGitHub 653005bb3b [WEB-1206] chore: bulk delete api logs (#4374)
* chore: bulk delete api logs

* chore: deletion time change
2024-05-06 15:19:03 +05:30
NikhilandGitHub f1fda4ae4a [WEB - 1122] fix: webhook for issues, issue comments, projects, cycles and modules. (#4330)
* dev: update webhook logic for issues

* dev: update issue webhooks for cycle and module

* dev: webhook for comment

* dev: issue attachment webhooks

* dev: add logging

* dev: add inbox issue webhooks

* dev: update the webhook send task

* dev: project webhooks for api

* dev: webhooks update for projects, cycles and modules

* dev: fix webhook on cycle and module create from external apis
2024-05-06 14:13:49 +05:30
Aaryan KhandelwalandGitHub fb74875cde [WEB-1181] chore: added a loader for page description (#4358)
* chore: add loader for page description

* chore: added skeleton loader

* fix: title loader margin

* chore: increased laoder width
2024-05-03 23:12:44 +05:30
c96225c812 [WEB-1183] fix: updated global issues filter while updating global view (#4357)
* chore: Updated global issues filter while updating global view

* fix: globale view modal clear all

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-05-03 20:29:11 +05:30
Aaryan KhandelwalandGitHub 527ecd7d22 style: make edito height according to the content height (#4347) 2024-05-03 19:20:13 +05:30
rahulrameshaandGitHub acd8f8d2d6 [WEB-1173] fix: order by for last updated when issue is updated (#4353)
* update the issue's updated at date when issue is updated

* sort issue's updated and created at regardless of the date format.

* move the logic to date time helpers

* revert back the third variable in update issue
2024-05-03 19:08:20 +05:30
Anmol Singh BhatiaandGitHub f4cc103238 [WEB-1175] fix: modal context (#4352)
* fix: modal context

* chore: code refactor
2024-05-03 17:58:15 +05:30
Aaryan KhandelwalandGitHub 89f2f87b97 fix: decrease gantt full size z-index (#4354) 2024-05-03 17:38:21 +05:30
sriram veeraghanta eed047bfe3 chore: update version 2024-05-03 17:23:12 +05:30
Aaryan KhandelwalandGitHub 8cf4260219 fix: dropdowns should close on tab key (#4351) 2024-05-03 15:39:14 +05:30
rahulrameshaandGitHub 1b55411919 [WEB-1136] chore: Kanban drag and drop improvements (#4350)
* Kanban DnD improvement

* minor fixes for kanban dnd improvement

* change scroll duration

* fix feedback on the UX

* add highlight before drop

* add toast message explain drag and drop is currently disabled

* Change warning dnd message

* add comments

* fix minor build error
2024-05-03 15:12:06 +05:30
NikhilandGitHub dc5edca34d chore: update the default name that is added to instance (#4348) 2024-05-03 14:35:36 +05:30
Anmol Singh BhatiaandGitHub 2884b0e22e fix: inbox issue header quick action validation (#4344) 2024-05-03 14:17:21 +05:30
Aaryan KhandelwalandGitHub 091fdb89ac chore: hide display properties in the calendar layout (#4346) 2024-05-03 14:16:27 +05:30
Anmol Singh BhatiaandGitHub a678844073 [WEB-859] [WEB-1162] chore: issue detail improvement and assignees filter fix (#4342)
* chore: created by option added to inbox issue detail section

* chore: issue detail page improvement

* fix: inbox assignee filter key
2024-05-02 20:39:43 +05:30
Bavisetti NarayanandGitHub 4cb5c4d9df chore: workspace module list endpoint (#4343) 2024-05-02 19:18:41 +05:30
Aaryan KhandelwalandGitHub 45c9dfb3cf chore: update useCallback dependencies (#4341) 2024-05-02 19:18:08 +05:30
M. PalanikannanandGitHub 4c78cd7c7e fix: inbox create issue comment fixed (#4340) 2024-05-02 18:22:00 +05:30
Aaryan KhandelwalandGitHub c4229c9d55 fix: quick actions dropdown disabled state (#4335) 2024-05-02 16:13:58 +05:30
Aaryan KhandelwalandGitHub 42c4c46939 [WEB-1154] fix: delete attachment modal logic (#4338)
* fix: delete attachment modal logic

* chore: remove console log

* chore: update delete attachment button type
2024-05-02 16:13:04 +05:30
Anmol Singh BhatiaandGitHub 6918393b63 fix: existing issue list modal loading flicker (#4337) 2024-05-02 16:11:38 +05:30
Aaryan KhandelwalandGitHub 6196c750f1 fix: issue description persistence (#4331) 2024-05-01 19:55:37 +05:30
Anmol Singh BhatiaandGitHub ed6dd37043 fix: module sub-header empty state validation (#4329) 2024-05-01 18:36:11 +05:30
efa3eda85e [WEB-1145] chore: updated sub issue count in cycles, modules and project (#4328)
* chore: total issue count

* chore: removed the migration file

* fix: issue count

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-05-01 18:24:54 +05:30
Aaryan KhandelwalandGitHub eb0877a3c8 [WEB-1135] chore: store page full width information in local storage (#4327)
* chore: store page full width information in local storage

* chore: update page types
2024-05-01 18:10:39 +05:30
Aaryan KhandelwalandGitHub 73fd6e641c chore: show display name character in avatar (#4326) 2024-05-01 18:05:24 +05:30
Aaryan KhandelwalandGitHub 34dd19cb00 chore: default pages sorting order (#4325) 2024-05-01 18:04:41 +05:30
Anmol Singh BhatiaandGitHub ecc277c571 [WEB-1101] chore: workspace view quick action enhancement (#4324)
* chore: workspace view quick action enhancement

* fix: issue quick action height
2024-05-01 18:03:13 +05:30
Bavisetti NarayanandGitHub d69f025b9a [WEB-1132] fix: display datetime fields in user time zone format (#4323)
* fix: user timezone response

* chore: removed unused variables
2024-05-01 18:01:53 +05:30
Aaryan KhandelwalandGitHub ed4a0518fc [WEB-1119] style: editor typography, borders and alignment throughout the platform (#4322)
* chore: new font sizes

* chore: update space app editor border

* chore: issue detials page x-padding

* chore: editor width
2024-05-01 18:01:30 +05:30
rahulrameshaandGitHub 2e2747c1f9 fix kanban collapsed vertical writing mode in firefox (#4320) 2024-05-01 12:21:26 +05:30
Daniel Albaandsriram veeraghanta aa09ec7cd4 feat(dashboard): improve label readability (#4321)
change none label for all time in dashbard filters
2024-05-01 00:10:52 +05:30
Anmol Singh BhatiaandGitHub 037ddd8bdb fix: project view application error (#4319) 2024-04-30 20:14:35 +05:30
Anmol Singh BhatiaandGitHub d1978be778 [WEB-1100] fix: bug fixes and enhancement (#4318)
* fix: inbox issue description

* chore: outline heading removed from page toc

* chore: label setting page ui improvement

* fix: update issue modal description resetting

* chore: project page head title improvement
2024-04-30 19:39:50 +05:30
Aaryan KhandelwalandGitHub d2717a221c [WEB-1110] dev: custom context menu for issues, cycles, modules, views, pages and projects (#4267)
* dev: context menu

* chore: handle menu position on close

* chore: project quick actions

* chore: add more options to the project context menu

* chore: cycle item context menu

* refactor: context menu folder structure

* chore: module custom context menu

* chore: view custom context menu

* chore: issues custom context menu

* chore: reorder options

* chore: issues custom context menu

* chore: render the context menu in a portal
2024-04-30 18:59:07 +05:30
sriram veeraghanta cb6ecc86cc fix: sync action variable names 2024-04-30 17:53:35 +05:30
sriram veeraghanta 6972a520ce chore: rename workflows 2024-04-30 17:30:35 +05:30
Aaryan KhandelwalandGitHub 4f4f1d92e8 fix: issue description placeholder (#4312) 2024-04-30 17:21:52 +05:30
Anmol Singh BhatiaandGitHub 87a606446f [WEB-1093] chore: padding and borders consistency (#4315)
* chore: global list layout and list item component added

* chore: project view list layout consistency

* chore: project view sub header consistency

* chore: pages list layout consistency

* chore: project view sub header improvement

* chore: list layout item component improvement

* chore: module list layout consistency

* chore: cycle list layout consistency

* chore: issue list layout consistency

* chore: header height consistency

* chore: sub header consistency

* chore: list layout improvement

* chore: inbox sidebar improvement

* fix: cycle quick action

* chore: inbox selected issue improvement

* chore: label option removed from pages filter

* chore: inbox create issue modal improvement
2024-04-30 17:21:24 +05:30
Aaryan KhandelwalandGitHub 1b79517f07 [WEB-1111] chore: added a helper function to check if issue is peeked (#4305)
* chore: added a helper function to check if issue is peeked

* chore: make the kanban block observer

* chore: rename isIssuePeekd helper function
2024-04-30 17:20:02 +05:30
e5681534d7 [WEB-1065] chore: workspace view and empty filter improvement (#4308)
* chore: workspace view layout improvement

* fix: empty applied filters

* chore: code refactor

* chore: code refactor

* fix: build errors

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-04-29 19:45:06 +05:30
Anmol Singh BhatiaandGitHub 03065d2c1d [WEB-1094] chore: inbox sidebar mobile responsiveness (#4309)
* chore: inbox sidebar mobile responsiveness

* chore: code refactor
2024-04-29 19:38:46 +05:30
Anmol Singh BhatiaandGitHub 5d3c64752c fix: switch toggle ui (#4311) 2024-04-29 18:59:05 +05:30
Anmol Singh BhatiaandGitHub 4104f87d41 fix: view, api token and estimate modal description height (#4310) 2024-04-29 18:58:31 +05:30
sriram veeraghanta 32d14d7000 fix: auto merge workflow fixes 2024-04-29 18:50:44 +05:30
sriram veeraghanta 1c4ca42603 fix: auto merge workflow fixes 2024-04-29 18:46:50 +05:30
sriram veeraghanta 3077cc410e Merge branch 'preview' of github.com:makeplane/plane into preview 2024-04-29 18:33:29 +05:30
sriram veeraghanta 392075a9eb fix: workflow fixes 2024-04-29 18:33:09 +05:30
Anmol Singh BhatiaandGitHub c252650c9a chore: project pages order by option improvement (#4307) 2024-04-29 18:24:58 +05:30
sriram veeraghanta 16b43c7b02 fix: pr check validation in workflow 2024-04-29 16:58:54 +05:30
sriram veeraghanta 354c3d95ce Merge branch 'preview' of github.com:makeplane/plane into preview 2024-04-29 16:48:04 +05:30
sriram veeraghanta 9739aa574d chore: remove comments from workflows 2024-04-29 16:47:49 +05:30
Bhavya GogriandGitHub a6b0a7fa8e make redis version same as the one in selfhosted docker-compose (#4183) 2024-04-29 16:42:25 +05:30
sriram veeraghanta 7fdaa64b90 fix: workflow fixes 2024-04-29 16:17:22 +05:30
sriram veeraghanta 6072c8b550 fix: revert the auto merge workflow changes 2024-04-29 16:13:52 +05:30
Aaryan KhandelwalandGitHub 84fd1dca4b [WEB-436] chore: added h4 to h6 heading options (#4304)
* chore: added h4 to h6 heading options

* fix: build errors
2024-04-29 16:04:37 +05:30
sriram veeraghanta 49a6c9582c fix: autommerge fixes 2024-04-29 14:09:36 +05:30
sriram veeraghanta 245a0e92ee fix: automerge workflow 2024-04-29 13:43:16 +05:30
709d3a115b [WEB-711] style: profile and its settings pages responsiveness (#4022)
* [WEB-711] style: profile and its settings pages responsiveness

* chore: linting issues fix

* fix: mobile-view padding

---------

Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com>
2024-04-29 12:59:49 +05:30
9c8b4afc20 [WEB-808] style: workspace settings mobile responsiveness (#4047)
* [WEB-808] style: workspace settings mobile responsiveness

* fix: scroll on mobile-view

* responsiveness fixes

---------

Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com>
2024-04-29 12:59:04 +05:30
Lakhan BahetiandGitHub 0c880bbbc8 [WEB-704] fix: inbox responsiveness (#4275)
* chore: inbox responsiveness

* fix: sidebar in full view

* style: border theme

* condition update
2024-04-29 00:54:35 +05:30
Ramesh Kumar ChandraandGitHub ea436c925a projects list responsiveness (#4279) 2024-04-29 00:54:02 +05:30
Lakhan BahetiandGitHub 4bccbc9804 pages responsiveness (#4287) 2024-04-29 00:52:43 +05:30
Aaryan KhandelwalandGitHub ac4bb1c1b4 refactor: remove unused icon files (#4297)
* refactor: remove unused icon files

* refactor: attachment icons folder structure
2024-04-29 00:51:31 +05:30
Aaryan KhandelwalandGitHub 0e3d5cc4eb fix: incomplete cycle issues auth (#4299) 2024-04-29 00:50:15 +05:30
rahulrameshaandGitHub 6ac3cb9b31 [WEB-1073] fix: Kanban dnd to work as tested on Chrome, Safari and Firefox (#4301)
* fix Kanban dnd to work as tested on Chrome Safari and Firefox

* fix edge cases

* revert back unintentional change
2024-04-29 00:48:50 +05:30
Aaryan KhandelwalandGitHub e75947a5f4 fix: double slashes in the global issues layout routes (#4298) 2024-04-26 18:32:20 +05:30
Anmol Singh BhatiaandGitHub e4777157a2 fix: project active cycle progress (#4296) 2024-04-26 18:30:38 +05:30
Anmol Singh BhatiaandGitHub 0605b5f60c chore: show sub-issue option added in profile display filter (#4295) 2024-04-26 18:30:02 +05:30
Aaryan KhandelwalandGitHub ad27184a91 [WEB-1072] fix: pages UI improvements (#4294)
* fix: outline alignment

* fix: textarea auto-resize logic
2024-04-26 18:29:18 +05:30
Aaryan KhandelwalandGitHub f87bb95236 chore: clear search term on escape key (#4289) 2024-04-26 18:27:32 +05:30
Anmol Singh BhatiaandGitHub f2fa6452c9 fix: cycle and module quick action z-index (#4293) 2024-04-26 14:56:51 +05:30
Anmol Singh BhatiaandGitHub 80461e6484 chore: filter member option sorting improvement (#4285) 2024-04-26 13:21:08 +05:30
Anmol Singh BhatiaandGitHub 88165a8fdb chore: module and cycle sidebar stats item filter implementation (#4286) 2024-04-26 12:58:27 +05:30
Anmol Singh BhatiaandGitHub 42cceb5e65 fix: filter state option order (#4284) 2024-04-26 12:57:36 +05:30
Anmol Singh BhatiaandGitHub 15c7deb2db fix: existing and parent issue modal empty state flicker (#4281) 2024-04-24 20:48:44 +05:30
Anmol Singh BhatiaandGitHub e60ef36bfe fix: module and cycle event propagation (#4280) 2024-04-24 20:20:41 +05:30
sriram veeraghanta bc2c97b9c3 Merge branch 'develop' of github.com:makeplane/plane into develop 2024-04-24 17:43:02 +05:30
Michael ErmerandGitHub 7f99b9a554 feat(api/issuesBySequenceId): add api to retrieve issue based on its sequence identitifier (#4170) 2024-04-24 17:42:12 +05:30
Aaryan KhandelwalandGitHub b74a0ea4d3 fix: list layout block border color (#4278) 2024-04-24 17:33:12 +05:30
sriram veeraghanta d9f11733ad Merge branches 'preview' and 'develop' of github.com:makeplane/plane into preview 2024-04-24 17:24:07 +05:30
Anmol Singh BhatiaandGitHub 87aab74579 chore: delete label modal content updated (#4276) 2024-04-24 17:21:52 +05:30
Bavisetti NarayanandGitHub d5dd971fb4 chore: state triage filter (#4277) 2024-04-24 16:28:35 +05:30
Anmol Singh BhatiaandGitHub 1789b8ddeb fix: observer added to empty state component (#4274) 2024-04-24 16:27:01 +05:30
sriram veeraghanta 1caa109c16 fix: update actions ubuntu version 2024-04-24 15:32:56 +05:30
Lakhan BahetiandGitHub b711fedb65 [WEB-1046] fix: user activity overflow & repsonsiveness (#4262)
* fix: activity responsiveness

* fix: activity icon placement

* fix: build
2024-04-24 15:20:32 +05:30
Anmol Singh BhatiaandGitHub deaa63488b chore: project date filter updated (#4273) 2024-04-24 15:18:13 +05:30
Anmol Singh BhatiaandGitHub 87737dbfbe chore: input character limit error message improvement (#4271) 2024-04-24 15:17:50 +05:30
Anmol Singh BhatiaandGitHub fc1cffd524 chore: active cycle stats improvement (#4268) 2024-04-24 15:17:04 +05:30
Anmol Singh BhatiaandGitHub 6e574515e0 fix: module delete modal outside click event propagation (#4265) 2024-04-24 15:16:47 +05:30
Anmol Singh BhatiaandGitHub d87edede79 chore: created by added in issue sidebar and peek overview (#4264) 2024-04-24 15:16:30 +05:30
Anmol Singh BhatiaandGitHub 196724214b chore: applied filter responsiveness (#4263) 2024-04-24 15:16:00 +05:30
Anmol Singh BhatiaandGitHub 695892d66e chore: issue filter member options improvement (#4261) 2024-04-24 15:15:10 +05:30
Anmol Singh BhatiaandGitHub a4e5138d1c [WEB-1047] chore: create page modal improvement (#4266)
* chore: create page modal improvement

* chore: create page modal improvement
2024-04-23 19:59:34 +05:30
NikhilandGitHub f174a96ef2 dev: update python runtime (#4259) 2024-04-23 13:42:48 +05:30
sriram veeraghanta 5e53279734 Merge branch 'develop' of github.com:makeplane/plane into preview 2024-04-23 13:27:49 +05:30
Anmol Singh BhatiaandGitHub bf852739cd fix: preserve initial value on create more issues (#4258) 2024-04-23 13:25:27 +05:30
Anmol Singh BhatiaandGitHub f17e4c73a2 [WEB-1015] fix: kanban layout cycle and module quick add (#4252)
* fix: kanban layout cycle and module quick add

* fix: kanban layout cycle and module quick add
2024-04-23 13:07:20 +05:30
Bavisetti NarayanandGitHub aee48f6fa4 [WEB-1042] fix: dashboard collaborators active issue count (#4256)
* chore: recent collaborators based on workspace

* chore: removed the duplicate issue
2024-04-23 13:04:14 +05:30
Anmol Singh BhatiaandGitHub f7d6219bd1 [WEB-1038] fix: kanban layout drag permission validation (#4255)
* fix: kanban layout drag permission validation

* chore: code refactor
2024-04-23 13:03:30 +05:30
NikhilandGitHub 75d14ce1ef fix: workspace redirection when logging in (#4254) 2024-04-23 13:02:48 +05:30
Anmol Singh BhatiaandGitHub 9659da5b31 [WEB-1034] fix: inbox issue user activity (#4251)
* fix: inbox issue user activity

* chore: code refactor

* fix: sentry issue
2024-04-23 13:01:26 +05:30
Anmol Singh BhatiaandGitHub 51ade1295c fix: comment text editor user mention validation addded (#4250) 2024-04-23 13:00:58 +05:30
Anmol Singh BhatiaandGitHub ac3def2929 fix: calendar header action alignment (#4249) 2024-04-23 12:59:48 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
098a1950c7 chore(deps): bump gunicorn in /apiserver/requirements (#4247)
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 21.2.0 to 22.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/21.2.0...22.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 12:58:28 +05:30
03fd5feda6 [WEB-1035] fix: peek module auto closing (#4246)
* fix: peek module auto closing

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: archived at in module

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-04-23 12:58:01 +05:30
Anmol Singh BhatiaandGitHub 270f7c4503 fix: sign in redirection flicker (#4245) 2024-04-23 12:57:10 +05:30
Anmol Singh BhatiaandGitHub beff8536c9 fix: filter custom date select toggle (#4244) 2024-04-23 12:56:21 +05:30
Anmol Singh BhatiaandGitHub 5d8c5b22e8 chore: kanban layout sub-group ui revamp & sub-group icon fix (#4243) 2024-04-23 12:55:42 +05:30
Anmol Singh BhatiaandGitHub 60663821df chore: list layout issue block improvement (#4241) 2024-04-23 12:55:19 +05:30
Prateek ShouryaandGitHub 302da646a8 [WEB-857] chore: update issue priority text color for custom theme in spreadsheet layout. (#4235) 2024-04-23 12:54:19 +05:30
Anmol Singh BhatiaandGitHub e0e8ce633b [WEB-1027] fix: overflow & alignment fixes (#4234)
* fix: list layout issue title overflow

* fix: project feature toggle modal project name overflow

* fix: app sidebar project section alignment

* fix: issue title textarea

* fix: create issue modal project select overflow

* fix: module and cycle applied filters overflow fix
2024-04-23 12:53:52 +05:30
Prateek ShouryaandGitHub f77d2d8c0a [WEB-643] chore: update issue activity tabs. (#4232)
* remove `updates` tab.
* make `comments` as primary tab.
2024-04-23 12:52:31 +05:30
Prateek ShouryaandGitHub c50a0602f7 [WEB-871] chore: update leave project modal message in members settings page. (#4230)
* [WEB-871] chore: update leave project modal message in members settings page.

* fix: build errors.
2024-04-23 12:51:20 +05:30
Prateek ShouryaandGitHub 38daf72361 [WEB-872] chore: add tooltip to peek overview header icons. (#4229) 2024-04-23 12:49:29 +05:30
544 changed files with 9673 additions and 9581 deletions
-84
View File
@@ -1,84 +0,0 @@
name: Auto Merge or Create PR on Push
on:
workflow_dispatch:
push:
branches:
- "sync/**"
env:
CURRENT_BRANCH: ${{ github.ref_name }}
SOURCE_BRANCH: ${{ secrets.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
TARGET_BRANCH: ${{ secrets.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
REVIEWER: ${{ secrets.SYNC_PR_REVIEWER }}
jobs:
Check_Branch:
runs-on: ubuntu-latest
outputs:
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
steps:
- name: Check if current branch matches the secret
id: check-branch
run: |
if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then
echo "MATCH=true" >> $GITHUB_OUTPUT
else
echo "MATCH=false" >> $GITHUB_OUTPUT
fi
Auto_Merge:
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
needs: [Check_Branch]
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4.1.1
with:
fetch-depth: 0 # Fetch all history for all branches and tags
- name: Setup Git
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
- name: Setup GH CLI and Git Config
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: Check for merge conflicts
id: conflicts
run: |
git fetch origin $TARGET_BRANCH
git checkout $TARGET_BRANCH
# Attempt to merge the main branch into the current branch
if $(git merge --no-commit --no-ff $SOURCE_BRANCH); then
echo "No merge conflicts detected."
echo "HAS_CONFLICTS=false" >> $GITHUB_ENV
else
echo "Merge conflicts detected."
echo "HAS_CONFLICTS=true" >> $GITHUB_ENV
git merge --abort
fi
- name: Merge Change to Target Branch
if: env.HAS_CONFLICTS == 'false'
run: |
git commit -m "Merge branch '$SOURCE_BRANCH' into $TARGET_BRANCH"
git push origin $TARGET_BRANCH
- name: Create PR to Target Branch
if: env.HAS_CONFLICTS == 'true'
run: |
# Replace 'username' with the actual GitHub username of the reviewer.
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "" --reviewer $REVIEWER)
echo "Pull Request created: $PR_URL"
+44 -30
View File
@@ -1,28 +1,53 @@
name: Create Sync Action
name: Create PR on Sync
on:
workflow_dispatch:
push:
branches:
- preview
- "sync/**"
env:
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
CURRENT_BRANCH: ${{ github.ref_name }}
SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
REVIEWER: ${{ vars.SYNC_PR_REVIEWER }}
ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }}
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
jobs:
sync_changes:
Check_Branch:
runs-on: ubuntu-latest
outputs:
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
steps:
- name: Check if current branch matches the secret
id: check-branch
run: |
if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then
echo "MATCH=true" >> $GITHUB_OUTPUT
else
echo "MATCH=false" >> $GITHUB_OUTPUT
fi
Auto_Merge:
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
needs: [Check_Branch]
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
contents: write
steps:
- name: Checkout Code
- name: Checkout code
uses: actions/checkout@v4.1.1
with:
persist-credentials: false
fetch-depth: 0
fetch-depth: 0 # Fetch all history for all branches and tags
- name: Setup GH CLI
- name: Setup Git
run: |
git config user.name "$ACCOUNT_USER_NAME"
git config user.email "$ACCOUNT_USER_EMAIL"
- name: Setup GH CLI and Git Config
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
@@ -31,25 +56,14 @@ jobs:
sudo apt update
sudo apt install gh -y
- name: Push Changes to Target Repo A
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- name: Create PR to Target Branch
run: |
TARGET_REPO="${{ secrets.TARGET_REPO_A }}"
TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
- name: Push Changes to Target Repo B
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ secrets.TARGET_REPO_B }}"
TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH
# get all pull requests and check if there is already a PR
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number')
if [ -n "$PR_EXISTS" ]; then
echo "Pull Request already exists: $PR_EXISTS"
else
echo "Creating new pull request"
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "")
echo "Pull Request created: $PR_URL"
fi
+44
View File
@@ -0,0 +1,44 @@
name: Sync Repositories
on:
workflow_dispatch:
push:
branches:
- preview
env:
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
jobs:
sync_changes:
runs-on: ubuntu-20.04
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v4.1.1
with:
persist-credentials: false
fetch-depth: 0
- name: Setup GH CLI
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: Push Changes to Target Repo
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
+7 -7
View File
@@ -7,7 +7,7 @@
</p>
<h3 align="center"><b>Plane</b></h3>
<p align="center"><b>Open-source project management that unlocks customer value.</b></p>
<p align="center"><b>Open-source project management that unlocks customer value</b></p>
<p align="center">
<a href="https://discord.com/invite/A92xrEGCge">
@@ -40,22 +40,22 @@
</a>
</p>
Meet [Plane](https://dub.sh/plane-website-readme). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘‍♀️
Meet [Plane](https://dub.sh/plane-website-readme), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘‍♀️
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve in our upcoming releases.
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most.
## ⚡ Installation
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users.
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account.
If you want more control over your data, prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose).
| Installation Methods | Documentation Link |
| Installation methods | Docs link |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.plane.so/self-hosting/methods/docker-compose) |
| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://docs.plane.so/kubernetes) |
`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature.
`Instance admins` can configure instance settings with [God-mode](https://docs.plane.so/instance-admin).
## 🚀 Features
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.18.0"
"version": "0.19.0"
}
+4
View File
@@ -1,9 +1,13 @@
# Module improts
from .base import BaseSerializer
from .issue import IssueExpandSerializer
from plane.db.models import InboxIssue
class InboxIssueSerializer(BaseSerializer):
issue_detail = IssueExpandSerializer(read_only=True, source="issue")
class Meta:
model = InboxIssue
fields = "__all__"
+6
View File
@@ -6,9 +6,15 @@ from plane.api.views import (
IssueLinkAPIEndpoint,
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
WorkspaceIssueAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/issues/<str:project__identifier>-<str:issue__identifier>/",
WorkspaceIssueAPIEndpoint.as_view(),
name="issue-by-identifier",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueAPIEndpoint.as_view(),
+1
View File
@@ -3,6 +3,7 @@ from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint
from .state import StateAPIEndpoint
from .issue import (
WorkspaceIssueAPIEndpoint,
IssueAPIEndpoint,
LabelAPIEndpoint,
IssueLinkAPIEndpoint,
-35
View File
@@ -19,7 +19,6 @@ from rest_framework.views import APIView
# Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle
from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
@@ -38,40 +37,6 @@ class TimezoneMixin:
timezone.deactivate()
class WebhookMixin:
webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(
request, response, *args, **kwargs
)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
url = request.build_absolute_uri()
parsed_url = urlparse(url)
# Extract the scheme and netloc
scheme = parsed_url.scheme
netloc = parsed_url.netloc
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
payload=response.data,
kw=self.kwargs,
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
current_site=f"{scheme}://{netloc}",
)
return response
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
authentication_classes = [
APIKeyAuthentication,
+31 -3
View File
@@ -5,6 +5,7 @@ import json
from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Q, Sum
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
@@ -26,10 +27,11 @@ from plane.db.models import (
)
from plane.utils.analytics_plot import burndown_plot
from .base import BaseAPIView, WebhookMixin
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
class CycleAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle.
@@ -277,6 +279,16 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
project_id=project_id,
owned_by=request.user,
)
# Send the model activity
model_activity.delay(
model_name="cycle",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
@@ -295,6 +307,11 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
if cycle.archived_at:
return Response(
{"error": "Archived cycle cannot be edited"},
@@ -344,6 +361,17 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer.save()
# Send the model activity
model_activity.delay(
model_name="cycle",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -515,7 +543,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
class CycleIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
+10 -8
View File
@@ -154,6 +154,13 @@ class InboxIssueAPIEndpoint(BaseAPIView):
state=state,
)
# create an inbox issue
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox.id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
@@ -163,14 +170,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# create an inbox issue
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox.id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
inbox=str(inbox_issue.id),
)
serializer = InboxIssueSerializer(inbox_issue)
@@ -260,6 +260,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
inbox=(inbox_issue.id),
)
issue_serializer.save()
else:
@@ -327,6 +328,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
epoch=int(timezone.now().timestamp()),
notification=False,
origin=request.META.get("HTTP_ORIGIN"),
inbox=str(inbox_issue.id),
)
return Response(serializer.data, status=status.HTTP_200_OK)
+66 -4
View File
@@ -48,10 +48,72 @@ from plane.db.models import (
ProjectMember,
)
from .base import BaseAPIView, WebhookMixin
from .base import BaseAPIView
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
class WorkspaceIssueAPIEndpoint(BaseAPIView):
"""
This viewset provides `retrieveByIssueId` on workspace level
"""
model = Issue
webhook_event = "issue"
permission_classes = [ProjectEntityPermission]
serializer_class = IssueSerializer
@property
def project__identifier(self):
return self.kwargs.get("project__identifier", None)
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"))
.filter(project__identifier=self.kwargs.get("project__identifier"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by(self.kwargs.get("order_by", "-created_at"))
).distinct()
def get(
self, request, slug, project__identifier=None, issue__identifier=None
):
if issue__identifier and project__identifier:
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__identifier=project__identifier,
sequence_id=issue__identifier,
)
return Response(
IssueSerializer(
issue,
fields=self.fields,
expand=self.expand,
).data,
status=status.HTTP_200_OK,
)
class IssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to issue.
@@ -282,7 +344,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
)
if serializer.is_valid():
if (
str(request.data.get("external_id"))
request.data.get("external_id")
and (issue.external_id != str(request.data.get("external_id")))
and Issue.objects.filter(
project_id=project_id,
@@ -593,7 +655,7 @@ class IssueLinkAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
class IssueCommentAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to comments of the particular issue.
+32 -3
View File
@@ -5,6 +5,7 @@ import json
from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
@@ -28,10 +29,11 @@ from plane.db.models import (
Project,
)
from .base import BaseAPIView, WebhookMixin
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
class ModuleAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module.
@@ -163,6 +165,16 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer.save()
# Send the model activity
model_activity.delay(
model_name="module",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -172,6 +184,11 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
module = Module.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
current_instance = json.dumps(
ModuleSerializer(module).data, cls=DjangoJSONEncoder
)
if module.archived_at:
return Response(
{"error": "Archived module cannot be edited"},
@@ -204,6 +221,18 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer.save()
# Send the model activity
model_activity.delay(
model_name="module",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -260,7 +289,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
class ModuleIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module issues.
+32 -4
View File
@@ -1,7 +1,11 @@
# Python imports
import json
# Django imports
from django.db import IntegrityError
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
@@ -23,11 +27,11 @@ from plane.db.models import (
State,
Workspace,
)
from .base import BaseAPIView, WebhookMixin
from plane.bgtasks.webhook_task import model_activity
from .base import BaseAPIView
class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
class ProjectAPIEndpoint(BaseAPIView):
"""Project Endpoints to create, update, list, retrieve and delete endpoint"""
serializer_class = ProjectSerializer
@@ -236,6 +240,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(pk=serializer.data["id"])
.first()
)
# Model activity
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
serializer = ProjectSerializer(project)
return Response(
serializer.data, status=status.HTTP_201_CREATED
@@ -265,7 +280,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
try:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
current_instance = json.dumps(
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
if project.archived_at:
return Response(
{"error": "Archived project cannot be updated"},
@@ -303,6 +320,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(pk=serializer.data["id"])
.first()
)
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
serializer = ProjectSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
@@ -79,6 +79,16 @@ class ProjectEntityPermission(BasePermission):
if request.user.is_anonymous:
return False
# Handle requests based on project__identifier
if hasattr(view, "project__identifier") and view.project__identifier:
if request.method in SAFE_METHODS:
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
project__identifier=view.project__identifier,
is_active=True,
).exists()
## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS:
return ProjectMember.objects.filter(
+2 -1
View File
@@ -442,7 +442,7 @@ class IssueLinkSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid URL format.")
# Check URL scheme
if not value.startswith(('http://', 'https://')):
if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.")
return value
@@ -636,6 +636,7 @@ class IssueInboxSerializer(DynamicBaseSerializer):
"project_id",
"created_at",
"label_ids",
"created_by",
]
read_only_fields = fields
@@ -210,6 +210,7 @@ class ModuleSerializer(DynamicBaseSerializer):
"backlog_issues",
"created_at",
"updated_at",
"archived_at",
]
read_only_fields = fields
+9 -4
View File
@@ -30,7 +30,7 @@ from .user.base import (
from .oauth import OauthEndpoint
from .base import BaseAPIView, BaseViewSet, WebhookMixin
from .base import BaseAPIView, BaseViewSet
from .workspace.base import (
WorkSpaceViewSet,
@@ -38,7 +38,7 @@ from .workspace.base import (
WorkSpaceAvailabilityCheckEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
ExportWorkspaceUserActivityEndpoint
ExportWorkspaceUserActivityEndpoint,
)
from .workspace.member import (
@@ -91,12 +91,14 @@ from .cycle.base import (
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleArchiveUnarchiveEndpoint,
CycleUserPropertiesEndpoint,
)
from .cycle.issue import (
CycleIssueViewSet,
)
from .cycle.archive import (
CycleArchiveUnarchiveEndpoint,
)
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue.base import (
@@ -170,7 +172,6 @@ from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
ModuleFavoriteViewSet,
ModuleArchiveUnarchiveEndpoint,
ModuleUserPropertiesEndpoint,
)
@@ -178,6 +179,10 @@ from .module.issue import (
ModuleIssueViewSet,
)
from .module.archive import (
ModuleArchiveUnarchiveEndpoint,
)
from .api import ApiTokenEndpoint
-30
View File
@@ -19,7 +19,6 @@ from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
# Module imports
from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
@@ -38,35 +37,6 @@ class TimezoneMixin:
timezone.deactivate()
class WebhookMixin:
webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(
request, response, *args, **kwargs
)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
payload=response.data,
kw=self.kwargs,
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
current_site=request.META.get("HTTP_ORIGIN"),
)
return response
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
model = None
+409
View File
@@ -0,0 +1,409 @@
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
Case,
CharField,
Count,
Exists,
F,
Func,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Cycle,
CycleFavorite,
Issue,
Label,
User,
)
from plane.utils.analytics_plot import burndown_plot
# Module imports
from .. import BaseAPIView
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
favorite_subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(archived_at__isnull=False)
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("project", "workspace", "owned_by")
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
)
)
.annotate(is_favorite=Exists(favorite_subquery))
.annotate(
total_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__id",
distinct=True,
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__id",
distinct=True,
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__id",
distinct=True,
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__id",
distinct=True,
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__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(), then=Value("UPCOMING")
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.annotate(
assignee_ids=Coalesce(
ArrayAgg(
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(
issue_cycle__issue__assignees__id__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "name")
.distinct()
)
def get(self, request, slug, project_id, pk=None):
if pk is None:
queryset = (
self.get_queryset()
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
"archived_at",
)
).order_by("-is_favorite", "-created_at")
return Response(queryset, status=status.HTTP_200_OK)
else:
queryset = (
self.get_queryset()
.filter(archived_at__isnull=False)
.filter(pk=pk)
)
data = (
self.get_queryset()
.filter(pk=pk)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_cycle__cycle_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
"sub_issues",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
)
.first()
)
queryset = queryset.first()
if data is None:
return Response(
{"error": "Cycle does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Assignee Distribution
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(display_name=F("assignees__display_name"))
.values(
"first_name",
"last_name",
"assignee_id",
"avatar",
"display_name",
)
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
)
# Label Distribution
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if queryset.start_date and queryset.end_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset,
slug=slug,
project_id=project_id,
cycle_id=pk,
)
return Response(
data,
status=status.HTTP_200_OK,
)
def post(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
if cycle.end_date >= timezone.now().date():
return Response(
{"error": "Only completed cycles can be archived"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle.archived_at = timezone.now()
cycle.save()
return Response(
{"archived_at": str(cycle.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
+33 -379
View File
@@ -1,10 +1,9 @@
# Python imports
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Django imports
from django.db.models import (
Case,
CharField,
@@ -21,11 +20,11 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
@@ -49,10 +48,11 @@ from plane.db.models import (
from plane.utils.analytics_plot import burndown_plot
# Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
class CycleViewSet(WebhookMixin, BaseViewSet):
class CycleViewSet(BaseViewSet):
serializer_class = CycleSerializer
model = Cycle
webhook_event = "cycle"
@@ -414,6 +414,17 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.first()
)
# Send the model activity
model_activity.delay(
model_name="cycle",
model_id=str(cycle["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(cycle, status=status.HTTP_201_CREATED)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
@@ -436,6 +447,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
{"error": "Archived cycle cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
request_data = request.data
if (
@@ -489,6 +505,18 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"assignee_ids",
"status",
).first()
# Send the model activity
model_activity.delay(
model_name="cycle",
model_id=str(cycle["id"]),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(cycle, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -686,380 +714,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
favorite_subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(archived_at__isnull=False)
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("project", "workspace", "owned_by")
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
)
)
.annotate(is_favorite=Exists(favorite_subquery))
.annotate(
total_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__id",
distinct=True,
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__id",
distinct=True,
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__id",
distinct=True,
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__id",
distinct=True,
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__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(), then=Value("UPCOMING")
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.annotate(
assignee_ids=Coalesce(
ArrayAgg(
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(
issue_cycle__issue__assignees__id__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "name")
.distinct()
)
def get(self, request, slug, project_id, pk=None):
if pk is None:
queryset = (
self.get_queryset()
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
"archived_at",
)
).order_by("-is_favorite", "-created_at")
return Response(queryset, status=status.HTTP_200_OK)
else:
queryset = (
self.get_queryset()
.filter(archived_at__isnull=False)
.filter(pk=pk)
)
data = (
self.get_queryset()
.filter(pk=pk)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_cycle__cycle_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
"sub_issues",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
)
.first()
)
queryset = queryset.first()
if data is None:
return Response(
{"error": "Cycle does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Assignee Distribution
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(display_name=F("assignees__display_name"))
.values(
"first_name",
"last_name",
"assignee_id",
"avatar",
"display_name",
)
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
)
# Label Distribution
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if queryset.start_date and queryset.end_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset,
slug=slug,
project_id=project_id,
cycle_id=pk,
)
return Response(
data,
status=status.HTTP_200_OK,
)
def post(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
if cycle.end_date >= timezone.now().date():
return Response(
{"error": "Only completed cycles can be archived"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle.archived_at = timezone.now()
cycle.save()
return Response(
{"archived_at": str(cycle.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleDateCheckEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
+10 -4
View File
@@ -23,7 +23,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet, WebhookMixin
from .. import BaseViewSet
from plane.app.serializers import (
IssueSerializer,
CycleIssueSerializer,
@@ -38,9 +38,9 @@ from plane.db.models import (
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
@@ -191,6 +191,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, cycle_id):
@@ -249,6 +254,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues:
old_cycle_id = cycle_issue.cycle_id
# Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list
@@ -256,7 +262,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
# Record the update activity
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue.cycle_id),
"old_cycle_id": str(old_cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
+10 -5
View File
@@ -571,14 +571,16 @@ def dashboard_recent_collaborators(self, request, slug):
return self.paginate(
request=request,
queryset=project_members_with_activities,
controller=self.get_results_controller,
controller=lambda qs: self.get_results_controller(qs, slug),
)
class DashboardEndpoint(BaseAPIView):
def get_results_controller(self, project_members_with_activities):
def get_results_controller(self, project_members_with_activities, slug):
user_active_issue_counts = (
User.objects.filter(id__in=project_members_with_activities)
User.objects.filter(
id__in=project_members_with_activities,
)
.annotate(
active_issue_count=Count(
Case(
@@ -587,10 +589,13 @@ class DashboardEndpoint(BaseAPIView):
"unstarted",
"started",
],
then=1,
issue_assignee__issue__workspace__slug=slug,
issue_assignee__issue__project__project_projectmember__is_active=True,
then=F("issue_assignee__issue__id"),
),
output_field=IntegerField(),
)
),
distinct=True,
)
)
.values("active_issue_count", user_id=F("id"))
+33 -12
View File
@@ -251,6 +251,16 @@ class InboxIssueViewSet(BaseViewSet):
)
if serializer.is_valid():
serializer.save()
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
# create an inbox issue
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox_id.id,
project_id=project_id,
issue_id=serializer.data["id"],
source=request.data.get("source", "in-app"),
)
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
@@ -262,16 +272,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
# create an inbox issue
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox_id.id,
project_id=project_id,
issue_id=serializer.data["id"],
source=request.data.get("source", "in-app"),
inbox=str(inbox_issue.id),
)
inbox_issue = (
InboxIssue.objects.select_related("issue")
@@ -339,7 +340,24 @@ class InboxIssueViewSet(BaseViewSet):
# Get issue data
issue_data = request.data.pop("issue", False)
if bool(issue_data):
issue = Issue.objects.get(
issue = Issue.objects.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
).get(
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
@@ -379,6 +397,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
inbox=str(inbox_issue.id),
)
issue_serializer.save()
else:
@@ -444,6 +463,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()),
notification=False,
origin=request.META.get("HTTP_ORIGIN"),
inbox=(inbox_issue.id),
)
inbox_issue = (
@@ -480,7 +500,8 @@ class InboxIssueViewSet(BaseViewSet):
output_field=ArrayField(UUIDField()),
),
),
).first()
)
.first()
)
serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK)
+6 -1
View File
@@ -47,7 +47,7 @@ from plane.db.models import (
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class IssueArchiveViewSet(BaseViewSet):
permission_classes = [
@@ -239,6 +239,11 @@ class IssueArchiveViewSet(BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk=None):
+15 -2
View File
@@ -50,9 +50,10 @@ from plane.db.models import (
Project,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin
from .. import BaseAPIView, BaseViewSet
class IssueListEndpoint(BaseAPIView):
@@ -241,10 +242,14 @@ class IssueListEndpoint(BaseAPIView):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
class IssueViewSet(WebhookMixin, BaseViewSet):
class IssueViewSet(BaseViewSet):
def get_serializer_class(self):
return (
IssueCreateSerializer
@@ -440,6 +445,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
@@ -503,6 +512,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
)
.first()
)
datetime_fields = ["created_at", "updated_at"]
issue = user_timezone_converter(
issue, datetime_fields, request.user.user_timezone
)
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+2 -2
View File
@@ -11,7 +11,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet, WebhookMixin
from .. import BaseViewSet
from plane.app.serializers import (
IssueCommentSerializer,
CommentReactionSerializer,
@@ -25,7 +25,7 @@ from plane.db.models import (
from plane.bgtasks.issue_activites_task import issue_activity
class IssueCommentViewSet(WebhookMixin, BaseViewSet):
class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue_comment"
+7 -3
View File
@@ -1,6 +1,7 @@
# Python imports
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
@@ -19,14 +20,12 @@ from django.db.models import (
When,
)
from django.db.models.functions import Coalesce
# Django imports
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission
@@ -46,6 +45,7 @@ from plane.db.models import (
Project,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
from .. import BaseViewSet
@@ -230,6 +230,10 @@ class IssueDraftViewSet(BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
@@ -31,6 +31,7 @@ from plane.db.models import (
IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.user_timezone_converter import user_timezone_converter
from collections import defaultdict
@@ -132,6 +133,10 @@ class SubIssuesEndpoint(BaseAPIView):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
sub_issues = user_timezone_converter(
sub_issues, datetime_fields, request.user.user_timezone
)
return Response(
{
"sub_issues": sub_issues,
+362
View File
@@ -0,0 +1,362 @@
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
Count,
Exists,
F,
Func,
IntegerField,
OuterRef,
Prefetch,
Q,
Subquery,
UUIDField,
Value,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.app.serializers import (
ModuleDetailSerializer,
)
from plane.db.models import (
Issue,
Module,
ModuleFavorite,
ModuleLink,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
from .. import BaseAPIView
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
favorite_subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
cancelled_issues = (
Issue.issue_objects.filter(
state__group="cancelled",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
completed_issues = (
Issue.issue_objects.filter(
state__group="completed",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
started_issues = (
Issue.issue_objects.filter(
state__group="started",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
unstarted_issues = (
Issue.issue_objects.filter(
state__group="unstarted",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
backlog_issues = (
Issue.issue_objects.filter(
state__group="backlog",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
total_issues = (
Issue.issue_objects.filter(
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
return (
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(archived_at__isnull=False)
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("workspace", "project", "lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related(
"module", "created_by"
),
)
)
.annotate(
completed_issues=Coalesce(
Subquery(completed_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
cancelled_issues=Coalesce(
Subquery(cancelled_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
started_issues=Coalesce(
Subquery(started_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
unstarted_issues=Coalesce(
Subquery(unstarted_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
backlog_issues=Coalesce(
Subquery(backlog_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
total_issues=Coalesce(
Subquery(total_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
member_ids=Coalesce(
ArrayAgg(
"members__id",
distinct=True,
filter=~Q(members__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "-created_at")
)
def get(self, request, slug, project_id, pk=None):
if pk is None:
queryset = self.get_queryset()
modules = queryset.values( # Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
modules = user_timezone_converter(
modules, datetime_fields, request.user.user_timezone
)
return Response(modules, status=status.HTTP_200_OK)
else:
queryset = (
self.get_queryset()
.filter(pk=pk)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_module__module_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
assignee_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(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(
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
)
label_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data = ModuleDetailSerializer(queryset.first()).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
# Fetch the modules
modules = queryset.first()
if modules and modules.start_date and modules.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=modules,
slug=slug,
project_id=project_id,
module_id=pk,
)
return Response(
data,
status=status.HTTP_200_OK,
)
def post(self, request, slug, project_id, module_id):
module = Module.objects.get(
pk=module_id, project_id=project_id, workspace__slug=slug
)
if module.status not in ["completed", "cancelled"]:
return Response(
{
"error": "Only completed or cancelled modules can be archived"
},
status=status.HTTP_400_BAD_REQUEST,
)
module.archived_at = timezone.now()
module.save()
return Response(
{"archived_at": str(module.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, module_id):
module = Module.objects.get(
pk=module_id, project_id=project_id, workspace__slug=slug
)
module.archived_at = None
module.save()
return Response(status=status.HTTP_204_NO_CONTENT)
+45 -326
View File
@@ -1,6 +1,7 @@
# Python imports
import json
# Django Imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
@@ -17,14 +18,14 @@ from django.db.models import (
Value,
)
from django.db.models.functions import Coalesce
# Django Imports
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from rest_framework import status
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
@@ -48,12 +49,12 @@ from plane.db.models import (
Project,
)
from plane.utils.analytics_plot import burndown_plot
# Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.webhook_task import model_activity
from .. import BaseAPIView, BaseViewSet
class ModuleViewSet(WebhookMixin, BaseViewSet):
class ModuleViewSet(BaseViewSet):
model = Module
permission_classes = [
ProjectEntityPermission,
@@ -236,6 +237,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"updated_at",
)
).first()
# Send the model activity
model_activity.delay(
model_name="module",
model_id=str(module["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
datetime_fields = ["created_at", "updated_at"]
module = user_timezone_converter(
module, datetime_fields, request.user.user_timezone
)
return Response(module, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -277,6 +292,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"created_at",
"updated_at",
)
datetime_fields = ["created_at", "updated_at"]
modules = user_timezone_converter(
modules, datetime_fields, request.user.user_timezone
)
return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk):
@@ -418,6 +437,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
{"error": "Archived module cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
current_instance = json.dumps(
ModuleSerializer(module.first()).data, cls=DjangoJSONEncoder
)
serializer = ModuleWriteSerializer(
module.first(), data=request.data, partial=True
)
@@ -454,6 +476,22 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"created_at",
"updated_at",
).first()
# Send the model activity
model_activity.delay(
model_name="module",
model_id=str(module["id"]),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
datetime_fields = ["created_at", "updated_at"]
module = user_timezone_converter(
module, datetime_fields, request.user.user_timezone
)
return Response(module, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -515,325 +553,6 @@ class ModuleLinkViewSet(BaseViewSet):
)
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
favorite_subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
cancelled_issues = (
Issue.issue_objects.filter(
state__group="cancelled",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
completed_issues = (
Issue.issue_objects.filter(
state__group="completed",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
started_issues = (
Issue.issue_objects.filter(
state__group="started",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
unstarted_issues = (
Issue.issue_objects.filter(
state__group="unstarted",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
backlog_issues = (
Issue.issue_objects.filter(
state__group="backlog",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
total_issues = (
Issue.issue_objects.filter(
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
return (
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(archived_at__isnull=False)
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("workspace", "project", "lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related(
"module", "created_by"
),
)
)
.annotate(
completed_issues=Coalesce(
Subquery(completed_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
cancelled_issues=Coalesce(
Subquery(cancelled_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
started_issues=Coalesce(
Subquery(started_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
unstarted_issues=Coalesce(
Subquery(unstarted_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
backlog_issues=Coalesce(
Subquery(backlog_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
total_issues=Coalesce(
Subquery(total_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
member_ids=Coalesce(
ArrayAgg(
"members__id",
distinct=True,
filter=~Q(members__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "-created_at")
)
def get(self, request, slug, project_id, pk=None):
if pk is None:
queryset = self.get_queryset()
modules = queryset.values( # Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
"archived_at",
)
return Response(modules, status=status.HTTP_200_OK)
else:
queryset = (
self.get_queryset()
.filter(pk=pk)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_module__module_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
assignee_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(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(
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
)
label_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data = ModuleDetailSerializer(queryset.first()).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
# Fetch the modules
modules = queryset.first()
if modules and modules.start_date and modules.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=modules,
slug=slug,
project_id=project_id,
module_id=pk,
)
return Response(
data,
status=status.HTTP_200_OK,
)
def post(self, request, slug, project_id, module_id):
module = Module.objects.get(
pk=module_id, project_id=project_id, workspace__slug=slug
)
if module.status not in ["completed", "cancelled"]:
return Response(
{
"error": "Only completed or cancelled modules can be archived"
},
status=status.HTTP_400_BAD_REQUEST,
)
module.archived_at = timezone.now()
module.save()
return Response(
{"archived_at": str(module.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, module_id):
module = Module.objects.get(
pk=module_id, project_id=project_id, workspace__slug=slug
)
module.archived_at = None
module.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleFavoriteViewSet(BaseViewSet):
serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite
+8 -3
View File
@@ -16,7 +16,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet, WebhookMixin
from .. import BaseViewSet
from plane.app.serializers import (
ModuleIssueSerializer,
IssueSerializer,
@@ -31,9 +31,9 @@ from plane.db.models import (
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
@@ -150,6 +150,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
# create multiple issues inside a module
+29 -6
View File
@@ -1,5 +1,6 @@
# Python imports
import boto3
import json
# Django imports
from django.db import IntegrityError
@@ -14,6 +15,7 @@ from django.db.models import (
)
from django.conf import settings
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
@@ -22,7 +24,7 @@ from rest_framework import serializers
from rest_framework.permissions import AllowAny
# Module imports
from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin
from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.serializers import (
ProjectSerializer,
ProjectListSerializer,
@@ -50,9 +52,10 @@ from plane.db.models import (
Issue,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity
class ProjectViewSet(WebhookMixin, BaseViewSet):
class ProjectViewSet(BaseViewSet):
serializer_class = ProjectListSerializer
model = Project
webhook_event = "project"
@@ -185,7 +188,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.annotate(
total_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("pk"),
parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -204,7 +206,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
archived_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
archived_at__isnull=False,
parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -224,7 +225,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
draft_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
is_draft=True,
parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -337,6 +337,17 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.filter(pk=serializer.data["id"])
.first()
)
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
serializer = ProjectListSerializer(project)
return Response(
serializer.data, status=status.HTTP_201_CREATED
@@ -367,7 +378,9 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
current_instance = json.dumps(
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
if project.archived_at:
return Response(
{"error": "Archived projects cannot be updated"},
@@ -405,6 +418,16 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.filter(pk=serializer.data["id"])
.first()
)
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
+6 -1
View File
@@ -49,7 +49,12 @@ class UserEndpoint(BaseViewSet):
{"is_instance_admin": is_admin}, status=status.HTTP_200_OK
)
@invalidate_cache(path="/api/users/me/")
@invalidate_cache(
path="/api/users/me/",
)
@invalidate_cache(
path="/api/users/me/settings/",
)
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
+5 -1
View File
@@ -42,7 +42,7 @@ from plane.db.models import (
IssueAttachment,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class GlobalViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
@@ -255,6 +255,10 @@ class GlobalViewIssuesViewSet(BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
+2 -2
View File
@@ -151,8 +151,8 @@ class WorkSpaceViewSet(BaseViewSet):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(path="/api/workspaces/", user=False)
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True)
@invalidate_cache(path="/api/users/me/settings/", multiple=True)
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True, user=False)
@invalidate_cache(path="/api/users/me/settings/", multiple=True, user=False)
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
+1 -1
View File
@@ -27,7 +27,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.filter(archived_at__isnull=False)
.filter(archived_at__isnull=True)
.annotate(
total_issues=Count(
"issue_cycle",
@@ -30,7 +30,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.filter(archived_at__isnull=False)
.filter(archived_at__isnull=True)
.prefetch_related(
Prefetch(
"link_module",
@@ -21,6 +21,7 @@ class WorkspaceStatesEndpoint(BaseAPIView):
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
is_triage=False,
)
serializer = StateSerializer(states, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
+15
View File
@@ -0,0 +1,15 @@
from django.utils import timezone
from datetime import timedelta
from plane.db.models import APIActivityLog
from celery import shared_task
@shared_task
def delete_api_logs():
# Get the logs older than 30 days to delete
logs_to_delete = APIActivityLog.objects.filter(
created_at__lte=timezone.now() - timedelta(days=30)
)
# Delete the logs
logs_to_delete._raw_delete(logs_to_delete.db)
@@ -152,7 +152,7 @@ def process_mention(mention_component):
soup = BeautifulSoup(mention_component, "html.parser")
mentions = soup.find_all("mention-component")
for mention in mentions:
user_id = mention["id"]
user_id = mention["entity_identifier"]
user = User.objects.get(pk=user_id)
user_name = user.display_name
highlighted_name = f"@{user_name}"
@@ -31,6 +31,7 @@ from plane.db.models import (
)
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
from plane.bgtasks.webhook_task import webhook_activity
# Track Changes in name
@@ -1296,7 +1297,7 @@ def create_issue_vote_activity(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="created",
verb="updated",
old_value=None,
new_value=requested_data.get("vote"),
field="vote",
@@ -1365,7 +1366,7 @@ def create_issue_relation_activity(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="created",
verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field=requested_data.get("relation_type"),
@@ -1380,7 +1381,7 @@ def create_issue_relation_activity(
IssueActivity(
issue_id=related_issue,
actor_id=actor_id,
verb="created",
verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field=(
@@ -1606,6 +1607,7 @@ def issue_activity(
subscriber=True,
notification=False,
origin=None,
inbox=None,
):
try:
issue_activities = []
@@ -1692,6 +1694,41 @@ def issue_activity(
except Exception as e:
log_exception(e)
for activity in issue_activities_created:
webhook_activity.delay(
event=(
"issue_comment"
if activity.field == "comment"
else "inbox_issue" if inbox else "issue"
),
event_id=(
activity.issue_comment_id
if activity.field == "comment"
else inbox if inbox else activity.issue_id
),
verb=activity.verb,
field=(
"description"
if activity.field == "comment"
else activity.field
),
old_value=(
activity.old_value
if activity.old_value != ""
else None
),
new_value=(
activity.new_value
if activity.new_value != ""
else None
),
actor_id=activity.actor_id,
current_site=origin,
slug=activity.workspace.slug,
old_identifier=activity.old_identifier,
new_identifier=activity.new_identifier,
)
if notification:
notifications.delay(
type=type,
+3 -5
View File
@@ -128,7 +128,7 @@ def extract_mentions(issue_instance):
"mention-component", attrs={"target": "users"}
)
mentions = [mention_tag["id"] for mention_tag in mention_tags]
mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
return list(set(mentions))
except Exception:
@@ -144,7 +144,7 @@ def extract_comment_mentions(comment_value):
"mention-component", attrs={"target": "users"}
)
for mention_tag in mentions_tags:
mentions.append(mention_tag["id"])
mentions.append(mention_tag["entity_identifier"])
return list(set(mentions))
except Exception:
return []
@@ -663,9 +663,7 @@ def notifications(
"old_value": str(
last_activity.old_value
),
"activity_time": issue_activity.get(
"created_at"
),
"activity_time": str(last_activity.created_at),
},
},
)
+250 -58
View File
@@ -15,6 +15,7 @@ from django.core.mail import EmailMultiAlternatives, get_connection
from django.core.serializers.json import DjangoJSONEncoder
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.core.exceptions import ObjectDoesNotExist
# Module imports
from plane.api.serializers import (
@@ -25,6 +26,8 @@ from plane.api.serializers import (
ModuleIssueSerializer,
ModuleSerializer,
ProjectSerializer,
UserLiteSerializer,
InboxIssueSerializer,
)
from plane.db.models import (
Cycle,
@@ -37,6 +40,7 @@ from plane.db.models import (
User,
Webhook,
WebhookLog,
InboxIssue,
)
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@@ -49,6 +53,8 @@ SERIALIZER_MAPPER = {
"cycle_issue": CycleIssueSerializer,
"module_issue": ModuleIssueSerializer,
"issue_comment": IssueCommentSerializer,
"user": UserLiteSerializer,
"inbox_issue": InboxIssueSerializer,
}
MODEL_MAPPER = {
@@ -59,6 +65,8 @@ MODEL_MAPPER = {
"cycle_issue": CycleIssue,
"module_issue": ModuleIssue,
"issue_comment": IssueComment,
"user": User,
"inbox_issue": InboxIssue,
}
@@ -179,64 +187,6 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
return
@shared_task()
def send_webhook(event, payload, kw, action, slug, bulk, current_site):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
if event == "project":
webhooks = webhooks.filter(project=True)
if event == "issue":
webhooks = webhooks.filter(issue=True)
if event == "module" or event == "module_issue":
webhooks = webhooks.filter(module=True)
if event == "cycle" or event == "cycle_issue":
webhooks = webhooks.filter(cycle=True)
if event == "issue_comment":
webhooks = webhooks.filter(issue_comment=True)
if webhooks:
if action in ["POST", "PATCH"]:
if bulk and event in ["cycle_issue", "module_issue"]:
return
else:
event_data = [
get_model_data(
event=event,
event_id=(
payload.get("id")
if isinstance(payload, dict)
else kw.get("pk")
),
many=False,
)
]
if action == "DELETE":
event_data = [{"id": kw.get("pk")}]
for webhook in webhooks:
for data in event_data:
webhook_task.delay(
webhook=webhook.id,
slug=slug,
event=event,
event_data=data,
action=action,
current_site=current_site,
)
except Exception as e:
if settings.DEBUG:
print(e)
log_exception(e)
return
@shared_task
def send_webhook_deactivation_email(
webhook_id, receiver_id, current_site, reason
@@ -294,3 +244,245 @@ def send_webhook_deactivation_email(
except Exception as e:
log_exception(e)
return
@shared_task(
bind=True,
autoretry_for=(requests.RequestException,),
retry_backoff=600,
max_retries=5,
retry_jitter=True,
)
def webhook_send_task(
self,
webhook,
slug,
event,
event_data,
action,
current_site,
activity,
):
try:
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
headers = {
"Content-Type": "application/json",
"User-Agent": "Autopilot",
"X-Plane-Delivery": str(uuid.uuid4()),
"X-Plane-Event": event,
}
# # Your secret key
event_data = (
json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
if event_data is not None
else None
)
activity = (
json.loads(json.dumps(activity, cls=DjangoJSONEncoder))
if activity is not None
else None
)
action = {
"POST": "create",
"PATCH": "update",
"PUT": "update",
"DELETE": "delete",
}.get(action, action)
payload = {
"event": event,
"action": action,
"webhook_id": str(webhook.id),
"workspace_id": str(webhook.workspace_id),
"data": event_data,
"activity": activity,
}
# Use HMAC for generating signature
if webhook.secret_key:
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
json.dumps(payload).encode("utf-8"),
hashlib.sha256,
)
signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature
# Send the webhook event
response = requests.post(
webhook.url,
headers=headers,
json=payload,
timeout=30,
)
# Log the webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=str(response.status_code),
response_headers=str(response.headers),
response_body=str(response.text),
retry_count=str(self.request.retries),
)
except requests.RequestException as e:
# Log the failed webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=500,
response_headers="",
response_body=str(e),
retry_count=str(self.request.retries),
)
# Retry logic
if self.request.retries >= self.max_retries:
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
if webhook:
# send email for the deactivation of the webhook
send_webhook_deactivation_email(
webhook_id=webhook.id,
receiver_id=webhook.created_by_id,
reason=str(e),
current_site=current_site,
)
return
raise requests.RequestException()
except Exception as e:
if settings.DEBUG:
print(e)
log_exception(e)
return
@shared_task
def webhook_activity(
event,
verb,
field,
old_value,
new_value,
actor_id,
slug,
current_site,
event_id,
old_identifier,
new_identifier,
):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
if event == "project":
webhooks = webhooks.filter(project=True)
if event == "issue":
webhooks = webhooks.filter(issue=True)
if event == "module" or event == "module_issue":
webhooks = webhooks.filter(module=True)
if event == "cycle" or event == "cycle_issue":
webhooks = webhooks.filter(cycle=True)
if event == "issue_comment":
webhooks = webhooks.filter(issue_comment=True)
for webhook in webhooks:
webhook_send_task.delay(
webhook=webhook.id,
slug=slug,
event=event,
event_data=get_model_data(
event=event,
event_id=event_id,
),
action=verb,
current_site=current_site,
activity={
"field": field,
"new_value": new_value,
"old_value": old_value,
"actor": get_model_data(event="user", event_id=actor_id),
"old_identifier": old_identifier,
"new_identifier": new_identifier,
},
)
return
except Exception as e:
# Return if a does not exist error occurs
if isinstance(e, ObjectDoesNotExist):
return
if settings.DEBUG:
print(e)
log_exception(e)
return
@shared_task
def model_activity(
model_name,
model_id,
requested_data,
current_instance,
actor_id,
slug,
origin=None,
):
"""Function takes in two json and computes differences between keys of both the json"""
if current_instance is None:
webhook_activity.delay(
event=model_name,
verb="created",
field=None,
old_value=None,
new_value=None,
actor_id=actor_id,
slug=slug,
current_site=origin,
event_id=model_id,
old_identifier=None,
new_identifier=None,
)
return
# Load the current instance
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
# Loop through all keys in requested data and check the current value and requested value
for key in requested_data:
# Check if key is present in current instance or not
if key in current_instance:
current_value = current_instance.get(key, None)
requested_value = requested_data.get(key, None)
if current_value != requested_value:
webhook_activity.delay(
event=model_name,
verb="updated",
field=key,
old_value=current_value,
new_value=requested_value,
actor_id=actor_id,
slug=slug,
current_site=origin,
event_id=model_id,
old_identifier=None,
new_identifier=None,
)
return
+4
View File
@@ -32,6 +32,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
"schedule": crontab(minute="*/5"),
},
"check-every-day-to-delete-api-logs": {
"task": "plane.bgtasks.api_logs_task.delete_api_logs",
"schedule": crontab(hour=0, minute=0),
},
}
# Load task modules from all registered Django app configs.
@@ -15,7 +15,7 @@ class Command(BaseCommand):
receiver_email = options.get("to_email")
if not receiver_email:
raise CommandError("Reciever email is required")
raise CommandError("Receiver email is required")
(
EMAIL_HOST,
@@ -54,7 +54,7 @@ class Command(BaseCommand):
connection=connection,
)
msg.send()
self.stdout.write(self.style.SUCCESS("Email succesfully sent"))
self.stdout.write(self.style.SUCCESS("Email successfully sent"))
except Exception as e:
self.stdout.write(
self.style.ERROR(
@@ -46,7 +46,7 @@ class Command(BaseCommand):
}
instance = Instance.objects.create(
instance_name="Plane Free",
instance_name="Plane Community Edition",
instance_id=secrets.token_hex(12),
license_key=None,
api_key=secrets.token_hex(8),
+1
View File
@@ -293,6 +293,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
"plane.bgtasks.api_logs_task",
# management tasks
"plane.bgtasks.dummy_data_task",
)
@@ -0,0 +1,25 @@
import pytz
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
# Check if queryset is a dictionary (single item) or a list of dictionaries
if isinstance(queryset, dict):
queryset_values = [queryset]
else:
queryset_values = list(queryset)
# Iterate over the dictionaries in the list
for item in queryset_values:
# Iterate over the datetime fields
for field in datetime_fields:
# Convert the datetime field to the user's timezone
if field in item and item[field]:
item[field] = item[field].astimezone(user_tz)
# If queryset was a single item, return a single item
if isinstance(queryset, dict):
return queryset_values[0]
else:
return queryset_values
+1 -1
View File
@@ -1,3 +1,3 @@
-r base.txt
gunicorn==21.2.0
gunicorn==22.0.0
+1 -1
View File
@@ -1 +1 @@
python-3.11.8
python-3.11.9
+1 -1
View File
@@ -180,7 +180,7 @@ services:
plane-redis:
container_name: plane-redis
image: redis:6.2.7-alpine
image: redis:7.2.4-alpine
restart: always
volumes:
- redisdata:/data
+1 -1
View File
@@ -10,7 +10,7 @@ volumes:
services:
plane-redis:
image: redis:6.2.7-alpine
image: redis:7.2.4-alpine
restart: unless-stopped
networks:
- dev_env
+1 -1
View File
@@ -90,7 +90,7 @@ services:
plane-redis:
container_name: plane-redis
image: redis:6.2.7-alpine
image: redis:7.2.4-alpine
restart: always
volumes:
- redisdata:/data
+1 -1
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.18.0",
"version": "0.19.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor-core",
"version": "0.18.0",
"version": "0.19.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -34,7 +34,7 @@ interface CustomEditorProps {
suggestions?: () => Promise<IMentionSuggestion[]>;
};
handleEditorReady?: (value: boolean) => void;
placeholder?: string | ((isFocused: boolean) => string);
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
}
@@ -142,11 +142,11 @@ export const useEditor = ({
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName);
if (item) {
if (item.name === "image") {
if (item.key === "image") {
item.command(savedSelection);
} else {
item.command();
@@ -158,7 +158,7 @@ export const useEditor = ({
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName);
return item ? item.isActive() : false;
},
@@ -4,6 +4,11 @@ import { findTableAncestor } from "src/lib/utils";
import { Selection } from "@tiptap/pm/state";
import { UploadImage } from "src/types/upload-image";
export const setText = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
else editor.chain().focus().clearNodes().run();
};
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
else editor.chain().focus().toggleHeading({ level: 1 }).run();
@@ -19,6 +24,21 @@ export const toggleHeadingThree = (editor: Editor, range?: Range) => {
else editor.chain().focus().toggleHeading({ level: 3 }).run();
};
export const toggleHeadingFour = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run();
else editor.chain().focus().toggleHeading({ level: 4 }).run();
};
export const toggleHeadingFive = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run();
else editor.chain().focus().toggleHeading({ level: 5 }).run();
};
export const toggleHeadingSix = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run();
else editor.chain().focus().toggleHeading({ level: 6 }).run();
};
export const toggleBold = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
else editor.chain().focus().toggleBold().run();
+45 -9
View File
@@ -1,3 +1,14 @@
.ProseMirror {
--font-size-h1: 1.5rem;
--font-size-h2: 1.3125rem;
--font-size-h3: 1.125rem;
--font-size-h4: 0.9375rem;
--font-size-h5: 0.8125rem;
--font-size-h6: 0.75rem;
--font-size-regular: 0.9375rem;
--font-size-list: var(--font-size-regular);
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
@@ -56,7 +67,7 @@
/* to-do list */
ul[data-type="taskList"] li {
font-size: 1rem;
font-size: var(--font-size-list);
line-height: 1.5;
}
@@ -162,7 +173,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
cursor: text;
line-height: 1.2;
font-family: inherit;
font-size: 14px;
font-size: var(--font-size-regular);
color: inherit;
-moz-box-sizing: border-box;
box-sizing: border-box;
@@ -310,15 +321,15 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 2rem;
margin-bottom: 4px;
font-size: 1.875rem;
font-weight: 700;
font-size: var(--font-size-h1);
font-weight: 600;
line-height: 1.3;
}
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1.4rem;
margin-bottom: 1px;
font-size: 1.5rem;
font-size: var(--font-size-h2);
font-weight: 600;
line-height: 1.3;
}
@@ -326,21 +337,46 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: 1.25rem;
font-size: var(--font-size-h3);
font-weight: 600;
line-height: 1.3;
}
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: var(--font-size-h4);
font-weight: 600;
line-height: 1.5;
}
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: var(--font-size-h5);
font-weight: 600;
line-height: 1.5;
}
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: var(--font-size-h6);
font-weight: 600;
line-height: 1.5;
}
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 0.25rem;
margin-bottom: 1px;
padding: 3px 2px;
font-size: 1rem;
padding: 3px 0;
font-size: var(--font-size-regular);
line-height: 1.5;
}
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
font-size: 1rem;
font-size: var(--font-size-list);
line-height: 1.5;
}
@@ -43,7 +43,7 @@ type TArguments = {
cancelUploadImage?: () => void;
uploadFile: UploadImage;
};
placeholder?: string | ((isFocused: boolean) => string);
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
};
@@ -147,7 +147,7 @@ export const CoreEditorExtensions = ({
if (placeholder) {
if (typeof placeholder === "string") return placeholder;
else return placeholder(editor.isFocused);
else return placeholder(editor.isFocused, editor.getHTML());
}
return "Press '/' for commands...";
@@ -13,16 +13,24 @@ import {
UnderlineIcon,
StrikethroughIcon,
CodeIcon,
Heading4,
Heading5,
Heading6,
CaseSensitive,
} from "lucide-react";
import { Editor } from "@tiptap/react";
import {
insertImageCommand,
insertTableCommand,
setText,
toggleBlockquote,
toggleBold,
toggleBulletList,
toggleCodeBlock,
toggleHeadingFive,
toggleHeadingFour,
toggleHeadingOne,
toggleHeadingSix,
toggleHeadingThree,
toggleHeadingTwo,
toggleItalic,
@@ -36,15 +44,26 @@ import { UploadImage } from "src/types/upload-image";
import { Selection } from "@tiptap/pm/state";
export interface EditorMenuItem {
key: string;
name: string;
isActive: () => boolean;
command: () => void;
icon: LucideIconType;
}
export const TextItem = (editor: Editor) =>
({
key: "text",
name: "Text",
isActive: () => editor.isActive("paragraph"),
command: () => setText(editor),
icon: CaseSensitive,
}) as const satisfies EditorMenuItem;
export const HeadingOneItem = (editor: Editor) =>
({
name: "H1",
key: "h1",
name: "Heading 1",
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
@@ -52,7 +71,8 @@ export const HeadingOneItem = (editor: Editor) =>
export const HeadingTwoItem = (editor: Editor) =>
({
name: "H2",
key: "h2",
name: "Heading 2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
@@ -60,15 +80,44 @@ export const HeadingTwoItem = (editor: Editor) =>
export const HeadingThreeItem = (editor: Editor) =>
({
name: "H3",
key: "h3",
name: "Heading 3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
}) as const satisfies EditorMenuItem;
export const HeadingFourItem = (editor: Editor) =>
({
key: "h4",
name: "Heading 4",
isActive: () => editor.isActive("heading", { level: 4 }),
command: () => toggleHeadingFour(editor),
icon: Heading4,
}) as const satisfies EditorMenuItem;
export const HeadingFiveItem = (editor: Editor) =>
({
key: "h5",
name: "Heading 5",
isActive: () => editor.isActive("heading", { level: 5 }),
command: () => toggleHeadingFive(editor),
icon: Heading5,
}) as const satisfies EditorMenuItem;
export const HeadingSixItem = (editor: Editor) =>
({
key: "h6",
name: "Heading 6",
isActive: () => editor.isActive("heading", { level: 6 }),
command: () => toggleHeadingSix(editor),
icon: Heading6,
}) as const satisfies EditorMenuItem;
export const BoldItem = (editor: Editor) =>
({
name: "bold",
key: "bold",
name: "Bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
@@ -76,7 +125,8 @@ export const BoldItem = (editor: Editor) =>
export const ItalicItem = (editor: Editor) =>
({
name: "italic",
key: "italic",
name: "Italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
@@ -84,7 +134,8 @@ export const ItalicItem = (editor: Editor) =>
export const UnderLineItem = (editor: Editor) =>
({
name: "underline",
key: "underline",
name: "Underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
@@ -92,7 +143,8 @@ export const UnderLineItem = (editor: Editor) =>
export const StrikeThroughItem = (editor: Editor) =>
({
name: "strike",
key: "strikethrough",
name: "Strikethrough",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
@@ -100,47 +152,53 @@ export const StrikeThroughItem = (editor: Editor) =>
export const BulletListItem = (editor: Editor) =>
({
name: "bullet-list",
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
}) as const satisfies EditorMenuItem;
export const TodoListItem = (editor: Editor) =>
({
name: "To-do List",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
}) as const satisfies EditorMenuItem;
export const CodeItem = (editor: Editor) =>
({
name: "code",
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
}) as const satisfies EditorMenuItem;
export const NumberedListItem = (editor: Editor) =>
({
name: "ordered-list",
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
}) as const satisfies EditorMenuItem;
export const TodoListItem = (editor: Editor) =>
({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
}) as const satisfies EditorMenuItem;
export const QuoteItem = (editor: Editor) =>
({
name: "quote",
key: "quote",
name: "Quote",
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
}) as const satisfies EditorMenuItem;
export const CodeItem = (editor: Editor) =>
({
key: "code",
name: "Code",
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
}) as const satisfies EditorMenuItem;
export const TableItem = (editor: Editor) =>
({
name: "table",
key: "table",
name: "Table",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon,
@@ -148,7 +206,8 @@ export const TableItem = (editor: Editor) =>
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
({
name: "image",
key: "image",
name: "Image",
isActive: () => editor?.isActive("image"),
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
icon: ImageIcon,
@@ -159,9 +218,13 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
return [];
}
return [
TextItem(editor),
HeadingOneItem(editor),
HeadingTwoItem(editor),
HeadingThreeItem(editor),
HeadingFourItem(editor),
HeadingFiveItem(editor),
HeadingSixItem(editor),
BoldItem(editor),
ItalicItem(editor),
UnderLineItem(editor),
@@ -177,7 +240,7 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
}
export type EditorMenuItemNames = ReturnType<typeof getEditorMenuItems> extends (infer U)[]
? U extends { name: infer N }
? U extends { key: infer N }
? N
: never
: never;
@@ -158,7 +158,7 @@ export async function startImageUpload(
const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
view.focus();
if (view.hasFocus()) view.focus();
editor.storage.image.uploadInProgress = false;
} catch (error) {
removePlaceholder(editor, view, id);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/document-editor",
"version": "0.18.0",
"version": "0.19.0",
"description": "Package that powers Plane's Pages Editor",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
@@ -31,7 +31,7 @@ interface IDocumentEditor {
suggestions: () => Promise<IMentionSuggestion[]>;
};
tabIndex?: number;
placeholder?: string | ((isFocused: boolean) => string);
placeholder?: string | ((isFocused: boolean, value: string) => string);
}
const DocumentEditor = (props: IDocumentEditor) => {
@@ -67,11 +67,13 @@ export default function BlockMenu(props: BlockMenuProps) {
popup.current?.hide();
};
document.addEventListener("click", handleClickDragHandle);
document.addEventListener("contextmenu", handleClickDragHandle);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("scroll", handleScroll, true); // Using capture phase
return () => {
document.removeEventListener("click", handleClickDragHandle);
document.removeEventListener("contextmenu", handleClickDragHandle);
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("scroll", handleScroll, true);
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor-extensions",
"version": "0.18.0",
"version": "0.19.0",
"description": "Package that powers Plane's Editor with extensions",
"private": true,
"main": "./dist/index.mjs",
@@ -225,6 +225,9 @@ function DragHandle(options: DragHandleOptions) {
dragHandleElement.addEventListener("click", (e) => {
handleClick(e, view);
});
dragHandleElement.addEventListener("contextmenu", (e) => {
handleClick(e, view);
});
dragHandleElement.addEventListener("drag", (e) => {
hideDragHandle();
@@ -1,6 +1,6 @@
{
"name": "@plane/lite-text-editor",
"version": "0.18.0",
"version": "0.19.0",
"description": "Package that powers Plane's Comment Editor",
"private": true,
"main": "./dist/index.mjs",
@@ -32,7 +32,8 @@ export interface ILiteTextEditor {
suggestions?: () => Promise<IMentionSuggestion[]>;
};
tabIndex?: number;
placeholder?: string | ((isFocused: boolean) => string);
placeholder?: string | ((isFocused: boolean, value: string) => string);
id?: string;
}
const LiteTextEditor = (props: ILiteTextEditor) => {
@@ -48,12 +49,14 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
tabIndex,
mentionHandler,
placeholder = "Add comment...",
id = "",
} = props;
const editor = useEditor({
onChange,
initialValue,
value,
id,
editorClassName,
restoreFile: fileHandler.restore,
uploadFile: fileHandler.upload,
@@ -1,6 +1,6 @@
{
"name": "@plane/rich-text-editor",
"version": "0.18.0",
"version": "0.19.0",
"description": "Rich Text Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -35,7 +35,7 @@ export type IRichTextEditor = {
highlights: () => Promise<IMentionHighlight[]>;
suggestions: () => Promise<IMentionSuggestion[]>;
};
placeholder?: string | ((isFocused: boolean) => string);
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
};
@@ -15,6 +15,7 @@ import {
} from "@plane/editor-core";
export interface BubbleMenuItem {
key: string;
name: string;
isActive: () => boolean;
command: () => void;
@@ -8,9 +8,13 @@ import {
QuoteItem,
CodeItem,
TodoListItem,
TextItem,
HeadingFourItem,
HeadingFiveItem,
HeadingSixItem,
} from "@plane/editor-core";
import { Editor } from "@tiptap/react";
import { Check, ChevronDown, TextIcon } from "lucide-react";
import { Check, ChevronDown } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import { BubbleMenuItem } from ".";
@@ -23,18 +27,16 @@ interface NodeSelectorProps {
export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
const items: BubbleMenuItem[] = [
{
name: "Text",
icon: TextIcon,
command: () => editor.chain().focus().clearNodes().run(),
isActive: () => editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"),
},
TextItem(editor),
HeadingOneItem(editor),
HeadingTwoItem(editor),
HeadingThreeItem(editor),
TodoListItem(editor),
HeadingFourItem(editor),
HeadingFiveItem(editor),
HeadingSixItem(editor),
BulletListItem(editor),
NumberedListItem(editor),
TodoListItem(editor),
QuoteItem(editor),
CodeItem(editor),
];
@@ -58,7 +60,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
{items.map((item) => (
<button
key={item.name}
@@ -69,19 +71,17 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
e.stopPropagation();
}}
className={cn(
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
"flex items-center justify-between rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80",
{
"bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name,
"bg-custom-background-80": activeItem.name === item.name,
}
)}
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border border-custom-border-300 p-1">
<item.icon className="h-3 w-3" />
</div>
<item.icon className="size-3 flex-shrink-0" />
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="h-4 w-4" />}
{activeItem.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
</button>
))}
</section>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "eslint-config-custom",
"private": true,
"version": "0.18.0",
"version": "0.19.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.18.0",
"version": "0.19.0",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tsconfig",
"version": "0.18.0",
"version": "0.19.0",
"private": true,
"files": [
"base.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.18.0",
"version": "0.19.0",
"private": true,
"main": "./src/index.d.ts"
}
+1 -1
View File
@@ -20,7 +20,7 @@ export type TInboxIssueCurrentTab = EInboxIssueCurrentTab;
export type TInboxIssueStatus = EInboxIssueStatus;
// filters
export type TInboxIssueFilterMemberKeys = "assignee" | "created_by";
export type TInboxIssueFilterMemberKeys = "assignees" | "created_by";
export type TInboxIssueFilterDateKeys = "created_at" | "updated_at";
-5
View File
@@ -16,14 +16,9 @@ export type TPage = {
project: string | undefined;
updated_at: Date | undefined;
updated_by: string | undefined;
view_props: TPageViewProps | undefined;
workspace: string | undefined;
};
export type TPageViewProps = {
full_width?: boolean;
};
// page filters
export type TPageNavigationTabs = "public" | "private" | "archived";
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.18.0",
"version": "0.19.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
+9 -4
View File
@@ -1,6 +1,8 @@
import React from "react";
// ui
import { Tooltip } from "../tooltip";
// helpers
import { cn } from "../../helpers";
// types
import { TAvatarSize, getSizeInfo, isAValidNumber } from "./avatar";
@@ -55,7 +57,7 @@ export const AvatarGroup: React.FC<Props> = (props) => {
const sizeInfo = getSizeInfo(size);
return (
<div className={`flex ${sizeInfo.spacing}`}>
<div className={cn("flex", sizeInfo.spacing)}>
{avatarsWithUpdatedProps.map((avatar, index) => (
<div key={index} className="rounded-full ring-1 ring-custom-background-100">
{avatar}
@@ -64,9 +66,12 @@ export const AvatarGroup: React.FC<Props> = (props) => {
{maxAvatarsToRender < totalAvatars && (
<Tooltip tooltipContent={`${totalAvatars} total`} disabled={!showTooltip}>
<div
className={`${
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
} grid place-items-center rounded-full bg-custom-primary-10 text-[9px] text-custom-primary-100 ring-1 ring-custom-background-100`}
className={cn(
"grid place-items-center rounded-full bg-custom-primary-10 text-[9px] text-custom-primary-100 ring-1 ring-custom-background-100",
{
[sizeInfo.avatarSize]: !isAValidNumber(size),
}
)}
style={
isAValidNumber(size)
? {
+12 -7
View File
@@ -1,6 +1,8 @@
import React from "react";
// ui
import { Tooltip } from "../tooltip";
// helpers
import { cn } from "../../helpers";
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
@@ -130,9 +132,9 @@ export const Avatar: React.FC<Props> = (props) => {
return (
<Tooltip tooltipContent={fallbackText ?? name ?? "?"} disabled={!showTooltip}>
<div
className={`${
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
} grid place-items-center overflow-hidden ${getBorderRadius(shape)}`}
className={cn("grid place-items-center overflow-hidden", getBorderRadius(shape), {
[sizeInfo.avatarSize]: !isAValidNumber(size),
})}
style={
isAValidNumber(size)
? {
@@ -144,12 +146,15 @@ export const Avatar: React.FC<Props> = (props) => {
tabIndex={-1}
>
{src ? (
<img src={src} className={`h-full w-full ${getBorderRadius(shape)} ${className}`} alt={name} />
<img src={src} className={cn("h-full w-full", getBorderRadius(shape), className)} alt={name} />
) : (
<div
className={`${sizeInfo.fontSize} grid h-full w-full place-items-center ${getBorderRadius(
shape
)} ${className}`}
className={cn(
sizeInfo.fontSize,
"grid h-full w-full place-items-center",
getBorderRadius(shape),
className
)}
style={{
backgroundColor: fallbackBackgroundColor ?? "rgba(var(--color-primary-500))",
color: fallbackTextColor ?? "#ffffff",
+3 -2
View File
@@ -1,6 +1,7 @@
import * as React from "react";
// helpers
import { getIconStyling, getBadgeStyling, TBadgeVariant, TBadgeSizes } from "./helper";
import { cn } from "../../helpers";
export interface BadgeProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: TBadgeVariant;
@@ -31,7 +32,7 @@ const Badge = React.forwardRef<HTMLButtonElement, BadgeProps>((props, ref) => {
const buttonIconStyle = getIconStyling(size);
return (
<button ref={ref} type={type} className={`${buttonStyle} ${className}`} disabled={disabled || loading} {...rest}>
<button ref={ref} type={type} className={cn(buttonStyle, className)} disabled={disabled || loading} {...rest}>
{prependIcon && <div className={buttonIconStyle}>{React.cloneElement(prependIcon, { strokeWidth: 2 })}</div>}
{children}
{appendIcon && <div className={buttonIconStyle}>{React.cloneElement(appendIcon, { strokeWidth: 2 })}</div>}
+24 -13
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import { Switch } from "@headlessui/react";
// helpers
import { cn } from "../../helpers";
interface IToggleSwitchProps {
value: boolean;
@@ -19,22 +20,32 @@ const ToggleSwitch: React.FC<IToggleSwitchProps> = (props) => {
checked={value}
disabled={disabled}
onChange={onChange}
className={`relative inline-flex flex-shrink-0 ${
size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10"
} flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
value ? "bg-custom-primary-100" : "bg-gray-700"
} ${className || ""} ${disabled ? "cursor-not-allowed" : ""}`}
className={cn(
"relative inline-flex flex-shrink-0 h-6 w-10 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none bg-gray-700",
{
"h-4 w-6": size === "sm",
"h-5 w-8": size === "md",
"bg-custom-primary-100": value,
"cursor-not-allowed": disabled,
},
className
)}
>
<span className="sr-only">{label}</span>
<span
aria-hidden="true"
className={`inline-block self-center ${
size === "sm" ? "h-2 w-2" : size === "md" ? "h-3 w-3" : "h-4 w-4"
} transform rounded-full shadow ring-0 transition duration-200 ease-in-out ${
value
? (size === "sm" ? "translate-x-3" : size === "md" ? "translate-x-4" : "translate-x-5") + " bg-white"
: "translate-x-0.5 bg-custom-background-90"
} ${disabled ? "cursor-not-allowed" : ""}`}
className={cn(
"inline-block self-center h-4 w-4 transform rounded-full shadow ring-0 transition duration-200 ease-in-out",
{
"translate-x-5 bg-white": value,
"h-2 w-2": size === "sm",
"h-3 w-3": size === "md",
"translate-x-3": value && size === "sm",
"translate-x-4": value && size === "md",
"translate-x-0.5 bg-custom-background-90": !value,
"cursor-not-allowed": disabled,
}
)}
/>
</Switch>
);
+14 -4
View File
@@ -6,10 +6,11 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
children: React.ReactNode;
target?: string;
disabled?: boolean;
className?: string;
};
export const ControlLink: React.FC<TControlLink> = (props) => {
const { href, onClick, children, target = "_self", disabled = false, ...rest } = props;
export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((props, ref) => {
const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props;
const LEFT_CLICK_EVENT_CODE = 0;
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
@@ -20,11 +21,20 @@ export const ControlLink: React.FC<TControlLink> = (props) => {
}
};
// if disabled but still has a ref or a className then it has to be rendered without a href
if (disabled && (ref || className))
return (
<a ref={ref} className={className}>
{children}
</a>
);
// else if just disabled return without the parent wrapper
if (disabled) return <>{children}</>;
return (
<a href={href} target={target} onClick={handleOnClick} {...rest}>
<a href={href} target={target} onClick={handleOnClick} {...rest} ref={ref} className={className}>
{children}
</a>
);
};
});
@@ -0,0 +1,2 @@
export * from "./item";
export * from "./root";
@@ -0,0 +1,54 @@
import React from "react";
// helpers
import { cn } from "../../../helpers";
// types
import { TContextMenuItem } from "./root";
type ContextMenuItemProps = {
handleActiveItem: () => void;
handleClose: () => void;
isActive: boolean;
item: TContextMenuItem;
};
export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
const { handleActiveItem, handleClose, isActive, item } = props;
if (item.shouldRender === false) return null;
return (
<button
type="button"
className={cn(
"w-full flex items-center gap-2 px-1 py-1.5 text-left text-custom-text-200 rounded text-xs select-none",
{
"bg-custom-background-90": isActive,
"text-custom-text-400": item.disabled,
},
item.className
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
if (item.closeOnClick !== false) handleClose();
}}
onMouseEnter={handleActiveItem}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</button>
);
};
@@ -0,0 +1,157 @@
import React, { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
// components
import { ContextMenuItem } from "./item";
// helpers
import { cn } from "../../../helpers";
// hooks
import useOutsideClickDetector from "../../hooks/use-outside-click-detector";
export type TContextMenuItem = {
key: string;
title: string;
description?: string;
icon?: React.FC<any>;
action: () => void;
shouldRender?: boolean;
closeOnClick?: boolean;
disabled?: boolean;
className?: string;
iconClassName?: string;
};
type ContextMenuProps = {
parentRef: React.RefObject<HTMLElement>;
items: TContextMenuItem[];
};
const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
const { parentRef, items } = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({
x: 0,
y: 0,
});
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
// refs
const contextMenuRef = useRef<HTMLDivElement>(null);
// derived values
const renderedItems = items.filter((item) => item.shouldRender !== false);
const handleClose = () => {
setIsOpen(false);
setActiveItemIndex(0);
};
// calculate position of context menu
useEffect(() => {
const parentElement = parentRef.current;
const contextMenu = contextMenuRef.current;
if (!parentElement || !contextMenu) return;
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const contextMenuWidth = contextMenu.clientWidth;
const contextMenuHeight = contextMenu.clientHeight;
const clickX = e?.pageX || 0;
const clickY = e?.pageY || 0;
// check if there's enough space at the bottom, otherwise show at the top
let top = clickY;
if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight;
// check if there's enough space on the right, otherwise show on the left
let left = clickX;
if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth;
setPosition({ x: left, y: top });
setIsOpen(true);
};
const hideContextMenu = (e: KeyboardEvent) => {
if (isOpen && e.key === "Escape") handleClose();
};
parentElement.addEventListener("contextmenu", handleContextMenu);
window.addEventListener("keydown", hideContextMenu);
return () => {
parentElement.removeEventListener("contextmenu", handleContextMenu);
window.removeEventListener("keydown", hideContextMenu);
};
}, [contextMenuRef, isOpen, parentRef, setIsOpen, setPosition]);
// handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveItemIndex((prev) => (prev + 1) % renderedItems.length);
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length);
}
if (e.key === "Enter") {
e.preventDefault();
const item = renderedItems[activeItemIndex];
if (!item.disabled) {
renderedItems[activeItemIndex].action();
if (item.closeOnClick !== false) handleClose();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [activeItemIndex, isOpen, renderedItems, setIsOpen]);
// close on clicking outside
useOutsideClickDetector(contextMenuRef, handleClose);
return (
<div
className={cn(
"fixed h-screen w-screen top-0 left-0 cursor-default z-20 opacity-0 pointer-events-none transition-opacity",
{
"opacity-100 pointer-events-auto": isOpen,
}
)}
>
<div
ref={contextMenuRef}
className="fixed border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg rounded-md px-2 py-2.5 max-h-72 min-w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
style={{
top: position.y,
left: position.x,
}}
>
{renderedItems.map((item, index) => (
<ContextMenuItem
key={item.key}
handleActiveItem={() => setActiveItemIndex(index)}
handleClose={handleClose}
isActive={index === activeItemIndex}
item={item}
/>
))}
</div>
</div>
);
};
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
let contextMenu = <ContextMenuWithoutPortal {...props} />;
const portal = document.querySelector("#context-menu-portal");
if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal);
return contextMenu;
};
@@ -66,6 +66,11 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const toggleDropdown = () => {
if (isOpen) closeDropdown();
else openDropdown();
};
return (
<Combobox
as="div"
@@ -90,7 +95,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
onClick={openDropdown}
onClick={toggleDropdown}
>
{customButton}
</button>
@@ -107,7 +112,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={openDropdown}
onClick={toggleDropdown}
>
{label}
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
+7 -2
View File
@@ -47,6 +47,11 @@ const CustomSelect = (props: ICustomSelectProps) => {
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const toggleDropdown = () => {
if (isOpen) closeDropdown();
else openDropdown();
};
return (
<Listbox
as="div"
@@ -67,7 +72,7 @@ const CustomSelect = (props: ICustomSelectProps) => {
className={`flex items-center justify-between gap-1 text-xs ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
onClick={openDropdown}
onClick={toggleDropdown}
>
{customButton}
</button>
@@ -82,7 +87,7 @@ const CustomSelect = (props: ICustomSelectProps) => {
} ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={openDropdown}
onClick={toggleDropdown}
>
{label}
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
+1
View File
@@ -1,3 +1,4 @@
export * from "./context-menu";
export * from "./custom-menu";
export * from "./custom-select";
export * from "./custom-search-select";
+3 -3
View File
@@ -71,7 +71,7 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
)}
<button
type="button"
className={cn("grid place-items-center h-4 w-4 rounded-full border border-transparent", {
className={cn("grid place-items-center h-5 w-5 rounded-full border border-transparent", {
"border-custom-border-400": !showHexInput,
})}
onClick={() => {
@@ -91,7 +91,7 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
<button
key={icon.name}
type="button"
className="h-6 w-6 select-none text-lg grid place-items-center rounded hover:bg-custom-background-80"
className="h-9 w-9 select-none text-lg grid place-items-center rounded hover:bg-custom-background-80"
onClick={() => {
onChange({
name: icon.name,
@@ -99,7 +99,7 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
});
}}
>
<span style={{ color: activeColor }} className="material-symbols-rounded text-base">
<span style={{ color: activeColor }} className="material-symbols-rounded text-[20px]">
{icon.name}
</span>
</button>
+19 -18
View File
@@ -1,4 +1,6 @@
import * as React from "react";
// helpers
import { cn } from "../../helpers";
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
intermediate?: boolean;
@@ -9,32 +11,30 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref)
const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props;
return (
<div className={`relative w-full flex gap-2 ${className}`}>
<div className={cn("relative w-full flex gap-2", className)}>
<input
id={id}
ref={ref}
type="checkbox"
name={name}
checked={checked}
className={`
appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50
${
disabled
? "border-custom-border-200 bg-custom-background-80 cursor-not-allowed"
: `cursor-pointer ${
checked || intermediate
? "border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200"
: "border-custom-border-300 hover:border-custom-border-400 bg-white"
}`
className={cn(
"appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50",
{
"border-custom-border-200 bg-custom-background-80 cursor-not-allowed": disabled,
"cursor-pointer border-custom-border-300 hover:border-custom-border-400 bg-white": !disabled,
"border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200":
!disabled && (checked || intermediate),
}
`}
)}
disabled={disabled}
{...rest}
/>
<svg
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
} ${checked ? "block" : "hidden"}`}
className={cn("absolute w-4 h-4 p-0.5 pointer-events-none outline-none hidden stroke-white", {
block: checked,
"stroke-custom-text-400 opacity-40": disabled,
})}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@@ -46,9 +46,10 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref)
<polyline points="20 6 9 17 4 12" />
</svg>
<svg
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
} ${intermediate && !checked ? "block" : "hidden"}`}
className={cn("absolute w-4 h-4 p-0.5 pointer-events-none outline-none stroke-white hidden", {
"stroke-custom-text-400 opacity-40": disabled,
block: intermediate && !checked,
})}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
fill="none"
@@ -5,6 +5,8 @@ import { ColorResult, SketchPicker } from "react-color";
import { Input } from "./input";
import { usePopper } from "react-popper";
import { Button } from "../button";
// helpers
import { cn } from "../../helpers";
export interface InputColorPickerProps {
hasError: boolean;
@@ -45,7 +47,7 @@ export const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
onChange={handleInputChange}
hasError={hasError}
placeholder={placeholder}
className={`border-[0.5px] border-custom-border-200 ${className}`}
className={cn("border-[0.5px] border-custom-border-200", className)}
style={style}
/>
+10 -11
View File
@@ -19,17 +19,16 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
type={type}
name={name}
className={cn(
`block rounded-md bg-transparent text-sm placeholder-custom-text-400 focus:outline-none ${
mode === "primary"
? "rounded-md border-[0.5px] border-custom-border-200"
: mode === "transparent"
? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary"
: mode === "true-transparent"
? "rounded border-none bg-transparent ring-0"
: ""
} ${hasError ? "border-red-500" : ""} ${hasError && mode === "primary" ? "bg-red-500/20" : ""} ${
inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : ""
}`,
"block rounded-md bg-transparent text-sm placeholder-custom-text-400 focus:outline-none",
{
"rounded-md border-[0.5px] border-custom-border-200": mode === "primary",
"rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary":
mode === "transparent",
"rounded border-none bg-transparent ring-0": mode === "true-transparent",
"border-red-500": hasError,
"px-3 py-2": inputSize === "sm",
"p-3": inputSize === "md",
},
className
)}
{...rest}
+2 -14
View File
@@ -11,21 +11,11 @@ export interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextArea
}
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, ref) => {
const {
id,
name,
value = "",
rows = 1,
cols = 1,
mode = "primary",
hasError = false,
className = "",
...rest
} = props;
const { id, name, value = "", mode = "primary", hasError = false, className = "", ...rest } = props;
// refs
const textAreaRef = useRef<any>(ref);
// auto re-size
useAutoResizeTextArea(textAreaRef);
useAutoResizeTextArea(textAreaRef, value);
return (
<textarea
@@ -33,8 +23,6 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, re
name={name}
ref={textAreaRef}
value={value}
rows={rows}
cols={cols}
className={cn(
"no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none",
{
@@ -1,24 +1,16 @@
import { useEffect } from "react";
import { useLayoutEffect } from "react";
export const useAutoResizeTextArea = (textAreaRef: React.RefObject<HTMLTextAreaElement>) => {
useEffect(() => {
export const useAutoResizeTextArea = (
textAreaRef: React.RefObject<HTMLTextAreaElement>,
value: string | number | readonly string[]
) => {
useLayoutEffect(() => {
const textArea = textAreaRef.current;
if (!textArea) return;
const resizeTextArea = () => {
textArea.style.height = "auto";
const computedHeight = textArea.scrollHeight + "px";
textArea.style.height = computedHeight;
};
const handleInput = () => resizeTextArea();
// resize on mount
resizeTextArea();
textArea.addEventListener("input", handleInput);
return () => {
textArea.removeEventListener("input", handleInput);
};
}, [textAreaRef]);
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
textArea.style.height = "0px";
const scrollHeight = textArea.scrollHeight;
textArea.style.height = scrollHeight + "px";
}, [textAreaRef, value]);
};
@@ -1,26 +0,0 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const AdminProfileIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 24 24"
className={`${className} stroke-2`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" strokeLinecap="round" strokeLinejoin="round" />
<path
d="M8 19V18C8 16.9391 8.42143 15.9217 9.17157 15.1716C9.92172 14.4214 10.9391 14 12 14C13.0609 14 14.0783 14.4214 14.8284 15.1716C15.5786 15.9217 16 16.9391 16 18V19"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 14C13.6569 14 15 12.6569 15 11C15 9.34315 13.6569 8 12 8C10.3431 8 9 9.34315 9 11C9 12.6569 10.3431 14 12 14Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
-25
View File
@@ -1,25 +0,0 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const CopyIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 24 24"
className={`${className} stroke-2`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<path
d="M20 8H10C8.89543 8 8 8.89543 8 10V20C8 21.1046 8.89543 22 10 22H20C21.1046 22 22 21.1046 22 20V10C22 8.89543 21.1046 8 20 8Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 16C2.9 16 2 15.1 2 14V4C2 2.9 2.9 2 4 2H14C15.1 2 16 2.9 16 4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
@@ -1,22 +0,0 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const ExternalLinkIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 24 24"
className={`${className} stroke-[1.5]`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<path
d="M18 13V19C18 19.5304 17.7893 20.0391 17.4142 20.4142C17.0391 20.7893 16.5304 21 16 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V8C3 7.46957 3.21071 6.96086 3.58579 6.58579C3.96086 6.21071 4.46957 6 5 6H11"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M15 3H21V9" strokeLinecap="round" strokeLinejoin="round" />
<path d="M10 14L21 3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);

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