Compare commits

...

111 Commits

Author SHA1 Message Date
rahulramesha
48acacfc64 add individual call for each group 2024-07-25 20:17:44 +05:30
rahulramesha
1c155f6cbe fix view layout in space app (#5225) 2024-07-25 15:17:20 +05:30
rahulramesha
1707f4f282 Add live button on views (#5227) 2024-07-25 15:16:14 +05:30
rahulramesha
c2c2ad0d7a fix project issue loader and error handling (#5223) 2024-07-25 14:15:16 +05:30
Akshita Goyal
1bf8f82ccb fix: flicker issue (#5210) 2024-07-25 13:55:29 +05:30
Anmol Singh Bhatia
3bdd91e577 [WEB-2053] fix: my work page scroll (#5224)
* fix: my work page scroll

* chore: profile sidebar shadow removed
2024-07-25 13:54:51 +05:30
rahulramesha
1f9c7a4b67 fix issue reactions in space app (#5222) 2024-07-24 20:34:03 +05:30
Akshita Goyal
d1828c9496 [WEB-2040] fix: text updates (#5221)
* fix: text updates

* fix: page title

* fix: icon color

* fix: Page title changes
2024-07-24 20:30:52 +05:30
rahulramesha
3f87d8b99d fix gantt layout in project views (#5218) 2024-07-24 19:26:54 +05:30
rahulramesha
aba6e603a3 fix view update button if no filters are applied (#5220) 2024-07-24 18:52:30 +05:30
Aaryan Khandelwal
b4f2176ffa fix: issue parent type (#5219) 2024-07-24 18:34:07 +05:30
Anmol Singh Bhatia
4d978c1a8c [WEB-2025] chore: profile page enhancements (#5209)
* chore: user layout and header updated

* chore: user page sidebar improvement

* fix: your work redirection

* fix: profile section mobile navigation dropdown

* chore: profile layout improvement

* chore: profile header improvement

* fix: profile section header improvement

* fix: app sidebar your work active indicator

* chore: profile sidebar improvement

* chore: user menu code refactor

* chore: header code refactor

* chore: user menu code refactor

* fix: build error
2024-07-24 17:52:12 +05:30
Akshita Goyal
58f203dd38 fix: active cycle filter (#5217) 2024-07-24 16:53:09 +05:30
Akshita Goyal
ca088a464f [WEB-1955] fix: data types and css fixes added (#5216)
* fix: data types and css fixes for bulk ops

* fix: TBulkIssueProperties keys
2024-07-24 15:13:14 +05:30
sriram veeraghanta
0d6e581789 Merge branch 'preview' of github.com:makeplane/plane into preview 2024-07-24 15:06:19 +05:30
sriram veeraghanta
c92129ef41 fix: upgrading the turbo repo 2024-07-24 15:06:02 +05:30
Akshita Goyal
d22b633d50 [WEB-1966] fix: export button handled based on role (#5198)
* fix: export button handled based on role

* fix: formatting

* fix: import optimization

* fix: border fix for cycles page

* fix: import optimization
2024-07-24 12:02:01 +05:30
M. Palanikannan
a8b2bcc838 feat: added created_at field to be writable and added those changes to (#5142)
the activity
2024-07-23 20:50:51 +05:30
Manish Gupta
78481d45d4 chore: selfhost backup restore (#5188)
* chore: Data restore script added

* readme updated

* coderabbit suggestion implemented

* updated messages and readme

* updated readme

* updated readme

* self host readme fix
2024-07-23 19:37:31 +05:30
Henit Chobisa
3a6d3d4e82 feat: added external api endpoints for creating users and adding attachments to issues (#5193)
* feat: added external id and external source for issue attachments

* feat: added endpoint for creating users

* feat: added issue attachment endpoint

* fix: converted user to workspace member

* chore: removed code blocking adding issues when the cycle has been completed

* chore: update models

* chore: added user recent visited table

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-07-23 19:20:50 +05:30
Akshita Goyal
66c2cbe7d6 [WEB-1913] fix: handled error message for duplicate label (#5199)
* fix: duplicate label error message

* fix: text change
2024-07-23 17:55:36 +05:30
guru_sainath
f5027f4268 chore: optimised issue activity and updated the popover component in issue detail and peek overview (#5208) 2024-07-23 17:54:26 +05:30
Satish Gandham
31fe9a1a02 [WEB-2007] fix: cycles loading optimization (#5207)
* fix: cycles loading optimization

* fix: ts error

* fix: types added along with apis

* fix: formatting

* fix: removed bottom border

* fix: fixed loading state for cycle-stats

---------

Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
2024-07-23 17:04:31 +05:30
guru_sainath
2978593c63 [WEB-1747] fix: switching between intake sorting and filters are persisting same in all the project intakes (#5196)
* fix: switching between intake sorting and filters are persisting same in all the project intakes

* chore: typos and commented the methods in intake store
2024-07-23 16:18:19 +05:30
guru_sainath
8a05cd442c fix: mutating the issues count in the archived issues header when we restore the issues (#5192) 2024-07-23 16:04:03 +05:30
Aaryan Khandelwal
c6cdc12165 fix: headings 4, 5 and 6 triggering heading 3 (#5206) 2024-07-23 15:12:21 +05:30
Aaryan Khandelwal
7b6a2343cb fix: bold text color (#5197) 2024-07-23 13:22:04 +05:30
Anmol Singh Bhatia
66aedafe8a fix: add project button alignment (#5204) 2024-07-23 13:13:29 +05:30
Anmol Singh Bhatia
7af9c7bc33 fix: archived issue detail widget validation (#5205) 2024-07-23 13:10:26 +05:30
Anmol Singh Bhatia
0839666d81 [WEB-2023] chore: sidebar content update (#5195)
* chore: sidebar content update

* chore: code refactor
2024-07-22 19:23:31 +05:30
Anmol Singh Bhatia
68a211d00e fix: calendar layout mutation and code refactor (#5189) 2024-07-22 19:12:52 +05:30
guru_sainath
3545d94025 fix: mutating the inbox count on the sidebar and inbox tab when we click mark all as read (#5191) 2024-07-22 17:49:30 +05:30
Bavisetti Narayan
17e46c812a [WEB-2011] chore: export history filters (#5179)
* chore: time tracking filters

* chore: changed the filter key
2024-07-22 17:45:28 +05:30
guru_sainath
73455c8040 fix: rendering existing cycle and module issue properties when we reload the page in the inbox (#5190) 2024-07-22 17:44:32 +05:30
Bavisetti Narayan
9c1c0ed166 [WEB-2020] chore: display cross project issue relations (#5186)
* chore: display cross project issue relations

* chore: removed the slug
2024-07-22 16:51:43 +05:30
Bavisetti Narayan
ae45ff158a [WEB-1983] fix: intake cycle and module operation and intake api updated (#5155)
* chore: added assignees and labels in the inbox api

* fix: intake issue cycle and module add operation

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-07-22 16:47:16 +05:30
Bavisetti Narayan
c6909604b1 chore: advance views queryset change (#5182) 2024-07-22 16:45:46 +05:30
Aaryan Khandelwal
b95d7716e2 fix: editor focus after mentioning (#5187) 2024-07-22 16:45:09 +05:30
rahulramesha
8577a56068 [WEB-1255] chore: Required Spaces refactor (#5177)
* Changes required to enable Publish Views

* default views to not found page

* refactor exports

* remove uncessary view service

* fix review comments
2024-07-22 16:01:46 +05:30
Aaryan Khandelwal
2ee6cd20d8 chore: add missing headings to the rich text editor (#5135) 2024-07-22 15:17:24 +05:30
Anmol Singh Bhatia
8771c80c9b chore: issue load more text color updated (#5174) 2024-07-22 15:17:11 +05:30
Anmol Singh Bhatia
2ad1047323 [WEB-1982] chore: sidebar navigation item refactor (#5184)
* chore: sidebar navigation item refactor

* chore: module and cycle sidebar padding adjustment
2024-07-22 15:16:23 +05:30
Anmol Singh Bhatia
1956da2b90 fix: leave project mutation (#5175) 2024-07-22 15:06:10 +05:30
guru_sainath
eca79f33b6 chore: handled error in activityIdsByIssueId in store and added new filed in the project types and handled the default active filters in constants in activity constants (#5185) 2024-07-22 13:57:17 +05:30
sriram veeraghanta
8f9b568a65 fix: adding new validation to change page is available before proceeding with update (#5176) 2024-07-19 17:44:45 +05:30
sriram veeraghanta
a6d111f66d fix: setry profiling default value to zero 2024-07-19 17:30:58 +05:30
guru_sainath
f1f7fa907a [WEB-1883] chore: moving issue activity store to respective folder (#5169)
* chore: issue activity store

* chore: updated issue activity store and handled workspace settings order

* chore: added paramenter on the issue worklog component

* chore: hanlded popover close from prop
2024-07-19 16:11:25 +05:30
Bavisetti Narayan
b4feaf973a chore: added details in cycle detail endpoint (#5132) 2024-07-19 15:56:54 +05:30
Bavisetti Narayan
39a607ac0a [WEB-1985] chore: page access control (#5154)
* chore: page access control

* chore: page access update endpoint updated

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-07-19 15:43:01 +05:30
Bavisetti Narayan
d3c3d3c5ab chore: changed the naming convention (#5171) 2024-07-19 15:40:53 +05:30
Nikhil
065c9779bb [WEB - 1998] fix: profile creation on user signup (#5168)
* fix: profile creation while sign in up

* dev: destructure tupple for get or create
2024-07-19 15:35:28 +05:30
Aaryan Khandelwal
cb21dcbcef fix: disable editor history conditionally (#5133) 2024-07-19 15:31:22 +05:30
Akshita Goyal
e7948eabf2 [WEB-1956] fix: Keyboard shortcuts (#5134)
* fix: shortcuts

* fix: naming

* fix: structure optimization
2024-07-19 15:28:48 +05:30
Anmol Singh Bhatia
c2b5464e40 fix: empty issue title indicator (#5173) 2024-07-19 15:12:59 +05:30
Anmol Singh Bhatia
e055abb711 fix: issue link edit modal and mutation fix (#5172) 2024-07-19 13:56:36 +05:30
Prateek Shourya
44a0ff5c67 [WEB-1995] fix: searched page redirection from command palette. (#5170)
* [WEB-1995] fix: searched page redirection from command palette.

* chore: update redirect logic.
2024-07-19 13:56:16 +05:30
dependabot[bot]
075b8efa99 chore(deps): bump sentry-sdk in /apiserver/requirements (#5165)
Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 2.0.1 to 2.8.0.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.0.1...2.8.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  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-07-19 01:52:16 +05:30
Akshita Goyal
f27c25821c [WEB-1959]: Fix/member page revamp (#5163)
* fix: css issue + no pending issues handled

* fix: type issues

* fix: css changes
2024-07-18 20:50:25 +05:30
Anmol Singh Bhatia
aade07b37a fix: project intake disabled validation (#5161) 2024-07-18 18:19:11 +05:30
guru_sainath
8107045d8c fix: issue activity render enum keys update (#5162) 2024-07-18 18:18:32 +05:30
Akshita Goyal
4ce255a872 [WEB-1918]: Fix/sidebar button issue (#5160)
* fix: sidebar collapsed on smaller screen by default

* fix: linting

* fix: export issue

* fix: button action fixed
2024-07-18 17:00:33 +05:30
Akshita Goyal
a8c1b8cdef fix: inbox dependency array fix (#5159) 2024-07-18 16:00:16 +05:30
Akshita Goyal
78dd15a801 [WEB-1918]: Fix/sidebar collapse issue (#5157)
* fix: sidebar collapsed on smaller screen by default

* fix: linting

* fix: export issue
2024-07-18 15:52:48 +05:30
Aaryan Khandelwal
2d434f0b9c fix: disable selection if no issues are present (#5158) 2024-07-18 15:50:22 +05:30
Aaryan Khandelwal
209b700fd9 fix: page breadcrumb tooltip persistence (#5137) 2024-07-18 15:49:25 +05:30
Anmol Singh Bhatia
39e3c28ad8 [WEB-1981] chore: project view icon and empty state (#5153)
* chore: view icon updated

* chore: view asset updated

* chore: project view empty state updated
2024-07-18 15:46:16 +05:30
Prateek Shourya
cfc70622d6 [WEB-1960]: chore: upgrade to plane paid plans modal. (#5149) 2024-07-18 15:45:37 +05:30
Prateek Shourya
281948c1ce [WEB-1984] fix: code block padding and margin in pages. (#5152) 2024-07-18 15:28:40 +05:30
guru_sainath
2554110397 fix: build errors ee (#5156) 2024-07-18 14:59:28 +05:30
guru_sainath
482b363045 [WEB-1883] chore: moved workspace settings to respective folders for CE and EE (#5151)
* chore: moved workspace settings to respective folders for ce and ee

* chore: updated imports

* chore: updated imports for ee

* chore: resolved import error

* chore: resolved import error

* chore: ee imports in the issue sidebar

* chore: updated file structure

* chore: table UI

* chore: resolved build errors

* chore: added worklog on issue peekoverview
2024-07-18 14:45:30 +05:30
Akshita Goyal
fff27c60e4 [WEB-1959]: Chore/settings member page (#5144)
* chore: implemented table component in ui library

* chore: added export in the UI package

* chore/member-page-revamp

* fix: added custom popover className

* fix: updated ui for projects

* fix: hide pending invites for members

* fix: added ee component

* removed unwanted logging

* fix: seperated components

* fix: used collapsible instead of disclosure

* fix: removed commented code

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-07-18 13:02:22 +05:30
Akshita Goyal
474d7ef3c0 [WEB-1948] fix: priority icons (#5146)
* fix: priority icons

* fix: icon size specified
2024-07-18 13:01:48 +05:30
Anmol Singh Bhatia
a7ecfade98 [WEB-1920] dev: app sidebar revamp (#5150)
* chore: user activity icon added

* dev: sidebar navigation component added

* chore: dashboard constant file updated

* chore: unread notification indicator position

* chore: app sidebar project section

* chore: app sidebar User and Workspace section updated

* chore: notification to inbox transition

* chore: code refactor

* chore: code refactor
2024-07-18 12:56:33 +05:30
Akshita Goyal
996192b9bf fix: showing first issue as default inbox state (#5147) 2024-07-17 18:46:40 +05:30
sriram veeraghanta
4cb02a9270 fix: removing swr refresh intervel from the global config 2024-07-17 13:55:06 +05:30
Anmol Singh Bhatia
85719b9a12 chore: project intake toast and activity message updated (#5143) 2024-07-16 18:44:32 +05:30
Anmol Singh Bhatia
0b1f9f0e5b fix: spreadsheet layout quick action event propagation (#5141) 2024-07-16 16:06:19 +05:30
Anmol Singh Bhatia
d042dac042 fix: issue export project select dropdown width and truncate fix (#5138) 2024-07-16 15:58:55 +05:30
Anmol Singh Bhatia
f2733ab4df fix: issue detail widget user role permission added (#5131) 2024-07-16 15:57:14 +05:30
Anmol Singh Bhatia
5464e62a03 [WEB-1962] fix: disabled custom menu (#5130)
* fix: custom menu disabled button

* Add constants package to package.json

* Freeze hook form version

---------

Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
2024-07-16 15:22:12 +05:30
Anmol Singh Bhatia
e4d6e5e1af [WEB-1730] chore: project intake (#5140)
* chore: intake icon added

* chore: project inbox updated to intake in app sidebar and feature settings

* chore: intake icon added

* chore: project intake

* chore: project intake empty state asset updated
2024-07-16 15:21:03 +05:30
guru_sainath
cd85a9fe09 chore: handled the auto form submit for all authenticators (#5139) 2024-07-16 14:16:10 +05:30
Nikhil
6ade86f89d dev: rename user display configuration model (#5119)
* dev: rename model

* dev: add fields to project and issue types
2024-07-16 13:51:28 +05:30
Prateek Shourya
65caaa14cd [WEB-1957] fix: exception error on label creation for unauthorized users. (#5127) 2024-07-15 20:29:20 +05:30
dependabot[bot]
0e92cae05f chore(deps): bump django in /apiserver/requirements (#5128)
Bumps [django](https://github.com/django/django) from 4.2.11 to 4.2.14.
- [Commits](https://github.com/django/django/compare/4.2.11...4.2.14)

---
updated-dependencies:
- dependency-name: django
  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-07-15 20:24:53 +05:30
Anmol Singh Bhatia
9523799f34 fix: isssue widgets collapsible ui (#5126) 2024-07-15 19:51:59 +05:30
guru_sainath
f5f3c4915f [WEB-1846] chore: integrated project other features enabled/disabled feature on project settings and updated the pro icon as a component (#5071)
* chore: integrated time traking enabled/disabled feature on project settings and updated the pro icon as a component

* chore: Showing the toggle and disabled to make any operations on project features

* chore: default exports in constants

* chore: seperated isEnabled and isPro

* chore: updated time traking key

* chore: updated UI in project feature settings
2024-07-15 19:48:27 +05:30
rahulramesha
08d9e95a86 [WEB-1255] chore: Refactor existing Space app for project publish (#5107)
* chore: paginated the issues in space app

* chore: storing query using filters

* chore: added filters for priority

* chore: issue view model save function

* chore: votes and reactions added in issues endpoint

* chore: added filters in the public endpoint

* chore: issue detail endpoint

* chore: added labels, modules and assignees

* refactor existing project publish in space app

* fix clear all filters in space App

* chore: removed the extra serialier

* remove optional chaining and fallback to an empty array

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-07-15 18:35:45 +05:30
guru_sainath
22671ec8a7 [WEB-1954] chore: implemented table component in ui library (#5125)
* chore: implemented table component in ui library

* chore: added export in the UI package
2024-07-15 18:34:16 +05:30
Prateek Shourya
56331a7b55 [WEB-1949] chore: delete workspace components restructuring. (#5123)
* [WEB-1949] chore: delete workspace components restructuring.

* chore: update delete workspace section.
2024-07-15 16:55:26 +05:30
Akshita Goyal
33d6a8d233 [WEB-1820]: Fix/project selection dropdown (#5122)
* fix: Truncated project name under custom analytics's project selection dropdown

* fix: project name truncated

* fix: removed static width

* fix: hardcoded width

* fix: css

* fix: handled the custom search button

* Freeze hookform version

* Revert yarn lock

---------

Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
2024-07-15 16:47:40 +05:30
Akshita Goyal
4c353b6eeb [WEB-1557] fix: create issue disabled for guests (#5102)
* fix: create issue disabled for guests

* fix: workspace role type

* fix: create modal issue

* fix: removed the create action in guest mode

* Remove unused imports

---------

Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
2024-07-15 14:18:57 +05:30
Bavisetti Narayan
0cc5a5357b fix: added atomic transactions (#5109) 2024-07-15 14:06:50 +05:30
Anmol Singh Bhatia
e758e08785 chore: space app issue comment placeholder updated (#5121) 2024-07-15 13:35:06 +05:30
Anmol Singh Bhatia
890888a274 chore: export issues validation added (#5118) 2024-07-15 13:27:20 +05:30
Anmol Singh Bhatia
f7de9a3497 fix: module and cycle sidebar scroll (#5113) 2024-07-15 13:26:28 +05:30
Anmol Singh Bhatia
830d1c0b5a fix: issue label activity truncate fix and chip component added (#5120) 2024-07-15 13:13:20 +05:30
rahulramesha
4b0946e093 fix sort order for workspace views (#5112) 2024-07-12 19:53:11 +05:30
Anmol Singh Bhatia
1a26768291 [WEB-1902] fix: last draft issue properties (#5110)
* fix: handleFormChange added to start and due date properties

* fix: useEffect added to update localStorage when isDirty changes
2024-07-12 19:49:23 +05:30
Anmol Singh Bhatia
c93b826c48 chore: updated the order of issue detail widgets in the peek overview (#5111) 2024-07-12 19:46:00 +05:30
Prateek Shourya
ce89c7dcff [WEB-1929] chore: improve finishOnboarding logic to handle case where user profile setup is done and user already has a workspace. (#5105) 2024-07-12 17:17:58 +05:30
Akshita Goyal
f06095f120 [WEB-1759] fix: project dropdown action (#5088)
* fix: project dropdown action

* chore: added redirection for collapsed sidebar

* fix: disclosure panel close issue

* fix: removed redundancy

* fix: truncate issue
2024-07-11 20:20:32 +05:30
Anmol Singh Bhatia
dd3b0f6a3f [WEB-1921] fix: issue widgets modal and code refactor (#5106)
* fix: celery fix

* chore: issue relationkey and issueCrudOperation state added to issueDetail store

* chore: moved issue detail widget modal to root

* chore: code refactor

* chore: default open widget updated
2024-07-11 20:12:09 +05:30
Bavisetti Narayan
24973c1386 [WEB-1909] chore: removed duplication of assignee and label activity (#5095)
* chore: removed duplication of assignee and label activity

* chore: removed the print statement

* chore: updated the queryset
2024-07-11 14:43:02 +05:30
Anmol Singh Bhatia
15b0a448ee [WEB-1925] dev: issue detail widget enhancement (#5101)
* chore: collapsible button border color updated

* chore: TIssueDetailWidget type added

* chore: issue link modal onClose updated

* chore: issue detail widgets collapse state added to store

* chore: issue detail widget interaction added

* chore: issue detail widget interaction added
2024-07-11 14:34:56 +05:30
Nikhil
4d484577b5 dev: fix page versioning task (#5104) 2024-07-11 13:47:32 +05:30
Akshita Goyal
2d78f6fd22 fix: empty state for view page fixed (#5090) 2024-07-11 13:37:40 +05:30
Akshita Goyal
77694ee8ba [WEB-1876] fix: "Show sub-issues" checkbox checked by default under Archives (#5091)
* fix: "Show sub-issues" checkbox checked by default under Archives

* fix: default value set
2024-07-11 13:37:07 +05:30
Akshita Goyal
ac8e588ac3 [WEB-1820] fix: analytics truncate project name (#5089)
* fix: Truncated project name under custom analytics's project selection dropdown

* fix: project name truncated

* fix: removed static width

* fix: hardcoded width

* fix: css
2024-07-11 13:36:34 +05:30
guru_sainath
2136872351 [WEB-1916] ui: updated the empty state design in workspace notifications and ui changes (#5093)
* ui: updated the empty state design in workspace notifications and ui changes

* chore: updated the popover custom components

* ui: updated the badge ui on the sidrbar options

* ui: broken down the menu components
2024-07-11 13:19:07 +05:30
Anmol Singh Bhatia
a90724516b chore: auth screen layout padding (#5087) 2024-07-11 13:18:06 +05:30
461 changed files with 10000 additions and 3633 deletions

View File

@@ -14,6 +14,7 @@
"@headlessui/react": "^1.7.19",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/constants": "*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -46,4 +47,4 @@
"tsconfig": "*",
"typescript": "^5.4.2"
}
}
}

View File

@@ -55,7 +55,6 @@ class IssueSerializer(BaseSerializer):
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
exclude = [

View File

@@ -4,6 +4,7 @@ from .issue import urlpatterns as issue_patterns
from .cycle import urlpatterns as cycle_patterns
from .module import urlpatterns as module_patterns
from .inbox import urlpatterns as inbox_patterns
from .member import urlpatterns as member_patterns
urlpatterns = [
*project_patterns,
@@ -12,4 +13,5 @@ urlpatterns = [
*cycle_patterns,
*module_patterns,
*inbox_patterns,
*member_patterns,
]

View File

@@ -7,6 +7,7 @@ from plane.api.views import (
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
WorkspaceIssueAPIEndpoint,
IssueAttachmentEndpoint,
)
urlpatterns = [
@@ -65,4 +66,9 @@ urlpatterns = [
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentEndpoint.as_view(),
name="attachment",
),
]

View File

@@ -0,0 +1,13 @@
from django.urls import path
from plane.api.views import (
WorkspaceMemberAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/members/",
WorkspaceMemberAPIEndpoint.as_view(),
name="users",
),
]

View File

@@ -9,6 +9,7 @@ from .issue import (
IssueLinkAPIEndpoint,
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
IssueAttachmentEndpoint,
)
from .cycle import (
@@ -24,4 +25,6 @@ from .module import (
ModuleArchiveUnarchiveAPIEndpoint,
)
from .member import WorkspaceMemberAPIEndpoint
from .inbox import InboxIssueAPIEndpoint

View File

@@ -393,7 +393,6 @@ class CycleAPIEndpoint(BaseAPIView):
class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@@ -647,17 +646,6 @@ class CycleIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
):
return Response(
{
"error": "The Cycle has already been completed so no new issues can be added"
},
status=status.HTTP_400_BAD_REQUEST,
)
issues = Issue.objects.filter(
pk__in=issues, workspace__slug=slug, project_id=project_id
).values_list("id", flat=True)

View File

@@ -3,8 +3,11 @@ import json
# Django improts
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Q
from django.utils import timezone
from django.db.models import Q, Value, UUIDField
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Third party imports
from rest_framework import status
@@ -224,8 +227,27 @@ class InboxIssueAPIEndpoint(BaseAPIView):
issue_data = request.data.pop("issue", False)
if bool(issue_data):
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
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=issue_id,
workspace__slug=slug,
project_id=project_id,
)
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:

View File

@@ -22,9 +22,11 @@ from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from plane.api.serializers import (
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssueLinkSerializer,
@@ -307,6 +309,11 @@ class IssueAPIEndpoint(BaseAPIView):
)
serializer.save()
# Refetch the issue
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=serializer.data["id"]).first()
issue.created_at = request.data.get("created_at")
issue.save(update_fields=["created_at"])
# Track the issue
issue_activity.delay(
@@ -874,3 +881,83 @@ class IssueActivityAPIEndpoint(BaseAPIView):
expand=self.expand,
).data,
)
class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
permission_classes = [
ProjectEntityPermission,
]
model = IssueAttachment
parser_classes = (MultiPartParser, FormParser)
def post(self, request, slug, project_id, issue_id):
serializer = IssueAttachmentSerializer(data=request.data)
if (
request.data.get("external_id")
and request.data.get("external_source")
and IssueAttachment.objects.filter(
project_id=project_id,
workspace__slug=slug,
issue_id=issue_id,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
issue_attachment = IssueAttachment.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "Issue attachment with the same external id and external source already exists",
"id": str(issue_attachment.id),
},
status=status.HTTP_409_CONFLICT,
)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
serializer.data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = IssueAttachment.objects.get(pk=pk)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
def get(self, request, slug, project_id, issue_id):
issue_attachments = IssueAttachment.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,147 @@
# Python imports
import uuid
# Django imports
from django.contrib.auth.hashers import make_password
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer
from plane.db.models import (
User,
Workspace,
Project,
WorkspaceMember,
ProjectMember,
)
# API endpoint to get and insert users inside the workspace
class WorkspaceMemberAPIEndpoint(BaseAPIView):
# Get all the users that are present inside the workspace
def get(self, request, slug):
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"error": "Provided workspace does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace members that are present inside the workspace
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug
)
# Get all the users that are present inside the workspace
users = UserLiteSerializer(
User.objects.filter(
id__in=workspace_members.values_list("member_id", flat=True)
),
many=True,
).data
return Response(users, status=status.HTTP_200_OK)
# Insert a new user inside the workspace, and assign the user to the project
def post(self, request, slug):
# Check if user with email already exists, and send bad request if it's
# not present, check for workspace and valid project mandat
# ------------------- Validation -------------------
if (
request.data.get("email") is None
or request.data.get("display_name") is None
or request.data.get("project_id") is None
):
return Response(
{
"error": "Expected email, display_name, workspace_slug, project_id, one or more of the fields are missing."
},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email")
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Invalid email provided"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.filter(slug=slug).first()
project = Project.objects.filter(
pk=request.data.get("project_id")
).first()
if not all([workspace, project]):
return Response(
{"error": "Provided workspace or project does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if user exists
user = User.objects.filter(email=email).first()
workspace_member = None
project_member = None
if user:
# Check if user is part of the workspace
workspace_member = WorkspaceMember.objects.filter(
workspace=workspace, member=user
).first()
if workspace_member:
# Check if user is part of the project
project_member = ProjectMember.objects.filter(
project=project, member=user
).first()
if project_member:
return Response(
{
"error": "User is already part of the workspace and project"
},
status=status.HTTP_400_BAD_REQUEST,
)
# If user does not exist, create the user
if not user:
user = User.objects.create(
email=email,
display_name=request.data.get("display_name"),
first_name=request.data.get("first_name", ""),
last_name=request.data.get("last_name", ""),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
is_active=False,
)
user.save()
# Create a workspace member for the user if not already a member
if not workspace_member:
workspace_member = WorkspaceMember.objects.create(
workspace=workspace,
member=user,
role=request.data.get("role", 10),
)
workspace_member.save()
# Create a project member for the user if not already a member
if not project_member:
project_member = ProjectMember.objects.create(
project=project,
member=user,
role=request.data.get("role", 10),
)
project_member.save()
# Serialize the user and return the response
user_data = UserLiteSerializer(user).data
return Response(user_data, status=status.HTTP_201_CREATED)

View File

@@ -19,7 +19,7 @@ from plane.app.permissions import ProjectBasePermission
from plane.db.models import (
Cycle,
Inbox,
IssueProperty,
IssueUserProperty,
Module,
Project,
DeployBoard,
@@ -165,7 +165,7 @@ class ProjectAPIEndpoint(BaseAPIView):
role=20,
)
# Also create the issue property for the user
_ = IssueProperty.objects.create(
_ = IssueUserProperty.objects.create(
project_id=serializer.data["id"],
user=request.user,
)
@@ -179,7 +179,7 @@ class ProjectAPIEndpoint(BaseAPIView):
role=20,
)
# Also create the issue property for the user
IssueProperty.objects.create(
IssueUserProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)

View File

@@ -50,7 +50,7 @@ from .issue import (
IssueCreateSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssuePropertySerializer,
IssueUserPropertySerializer,
IssueAssigneeSerializer,
LabelSerializer,
IssueSerializer,

View File

@@ -17,7 +17,7 @@ from plane.db.models import (
Issue,
IssueActivity,
IssueComment,
IssueProperty,
IssueUserProperty,
IssueAssignee,
IssueSubscriber,
IssueLabel,
@@ -252,9 +252,9 @@ class IssueActivitySerializer(BaseSerializer):
fields = "__all__"
class IssuePropertySerializer(BaseSerializer):
class IssueUserPropertySerializer(BaseSerializer):
class Meta:
model = IssueProperty
model = IssueUserProperty
fields = "__all__"
read_only_fields = [
"user",

View File

@@ -23,7 +23,7 @@ class IssueViewSerializer(DynamicBaseSerializer):
]
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
query_params = validated_data.get("filters", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
@@ -31,7 +31,7 @@ class IssueViewSerializer(DynamicBaseSerializer):
return IssueView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
query_params = validated_data.get("filters", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:

View File

@@ -233,13 +233,13 @@ urlpatterns = [
name="project-issue-comment-reactions",
),
## End Comment Reactions
## IssueProperty
## IssueUserProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-properties/",
IssueUserDisplayPropertyEndpoint.as_view(),
name="project-issue-display-properties",
),
## IssueProperty End
## IssueUserProperty End
## Issue Archives
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/",

View File

@@ -66,6 +66,16 @@ urlpatterns = [
),
name="project-pages-lock-unlock",
),
# private and public page
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/access/",
PageViewSet.as_view(
{
"post": "access",
}
),
name="project-pages-access",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
PageLogEndpoint.as_view(),

View File

@@ -645,6 +645,8 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"cancelled_issues",
"total_issues",
@@ -654,6 +656,7 @@ class CycleViewSet(BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
"created_by",
)
.first()
)
@@ -739,6 +742,8 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
@@ -748,6 +753,7 @@ class CycleViewSet(BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
"created_by",
).first()
# Send the model activity
@@ -800,6 +806,8 @@ class CycleViewSet(BaseViewSet):
"sub_issues",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",

View File

@@ -41,6 +41,7 @@ class ExportIssuesEndpoint(BaseAPIView):
project=project_ids,
initiated_by=request.user,
provider=provider,
type="issue_exports",
)
issue_export_task.delay(
@@ -65,7 +66,8 @@ class ExportIssuesEndpoint(BaseAPIView):
def get(self, request, slug):
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug
workspace__slug=slug,
type="issue_exports",
).select_related("workspace", "initiated_by")
if request.GET.get("per_page", False) and request.GET.get(

View File

@@ -32,7 +32,7 @@ from plane.app.permissions import (
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssuePropertySerializer,
IssueUserPropertySerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -40,7 +40,7 @@ from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
IssueProperty,
IssueUserProperty,
IssueReaction,
IssueSubscriber,
Project,
@@ -481,7 +481,38 @@ class IssueViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first()
issue = (
self.get_queryset()
.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)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.filter(pk=pk)
.first()
)
if not issue:
return Response(
@@ -539,7 +570,7 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
]
def patch(self, request, slug, project_id):
issue_property = IssueProperty.objects.get(
issue_property = IssueUserProperty.objects.get(
user=request.user,
project_id=project_id,
)
@@ -554,14 +585,14 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
"display_properties", issue_property.display_properties
)
issue_property.save()
serializer = IssuePropertySerializer(issue_property)
serializer = IssueUserPropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, project_id):
issue_property, _ = IssueProperty.objects.get_or_create(
issue_property, _ = IssueUserProperty.objects.get_or_create(
user=request.user, project_id=project_id
)
serializer = IssuePropertySerializer(issue_property)
serializer = IssueUserPropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -37,24 +37,6 @@ class IssueRelationViewSet(BaseViewSet):
ProjectEntityPermission,
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
def list(self, request, slug, project_id, issue_id):
issue_relations = (
IssueRelation.objects.filter(
@@ -98,11 +80,8 @@ class IssueRelationViewSet(BaseViewSet):
).values_list("issue_id", flat=True)
queryset = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.filter(workspace__slug=self.kwargs.get("slug"))
Issue.issue_objects
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))

View File

@@ -245,6 +245,28 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0)
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
).first()
# Only update access if the page owner is the requesting user
if (
page.access != request.data.get("access", page.access)
and page.owned_by_id != request.user.id
):
return Response(
{
"error": "Access cannot be updated since this page is owned by someone else"
},
status=status.HTTP_400_BAD_REQUEST,
)
page.access = access
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
pages = PageSerializer(queryset, many=True).data
@@ -470,6 +492,12 @@ class PagesDescriptionViewSet(BaseViewSet):
.first()
)
if page is None:
return Response(
{"error": "Page not found"},
status=404,
)
if page.is_locked:
return Response(
{"error": "Page is locked"},

View File

@@ -39,7 +39,7 @@ from plane.db.models import (
Cycle,
Inbox,
DeployBoard,
IssueProperty,
IssueUserProperty,
Issue,
Module,
Project,
@@ -266,7 +266,7 @@ class ProjectViewSet(BaseViewSet):
role=20,
)
# Also create the issue property for the user
_ = IssueProperty.objects.create(
_ = IssueUserProperty.objects.create(
project_id=serializer.data["id"],
user=request.user,
)
@@ -280,7 +280,7 @@ class ProjectViewSet(BaseViewSet):
role=20,
)
# Also create the issue property for the user
IssueProperty.objects.create(
IssueUserProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)

View File

@@ -25,7 +25,7 @@ from plane.db.models import (
ProjectMemberInvite,
User,
WorkspaceMember,
IssueProperty,
IssueUserProperty,
)
@@ -179,9 +179,9 @@ class UserProjectInvitationsViewset(BaseViewSet):
ignore_conflicts=True,
)
IssueProperty.objects.bulk_create(
IssueUserProperty.objects.bulk_create(
[
IssueProperty(
IssueUserProperty(
project_id=project_id,
user=request.user,
workspace=workspace,

View File

@@ -22,7 +22,7 @@ from plane.db.models import (
ProjectMember,
Workspace,
TeamMember,
IssueProperty,
IssueUserProperty,
)
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
@@ -136,7 +136,7 @@ class ProjectMemberViewSet(BaseViewSet):
)
# Create a new issue property
bulk_issue_props.append(
IssueProperty(
IssueUserProperty(
user_id=member.get("member_id"),
project_id=project_id,
workspace_id=project.workspace_id,
@@ -150,7 +150,7 @@ class ProjectMemberViewSet(BaseViewSet):
ignore_conflicts=True,
)
_ = IssueProperty.objects.bulk_create(
_ = IssueUserProperty.objects.bulk_create(
bulk_issue_props, batch_size=10, ignore_conflicts=True
)
@@ -323,7 +323,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
)
)
issue_props.append(
IssueProperty(
IssueUserProperty(
project_id=project_id,
user_id=member,
workspace=workspace,
@@ -335,7 +335,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
project_members, batch_size=10, ignore_conflicts=True
)
_ = IssueProperty.objects.bulk_create(
_ = IssueUserProperty.objects.bulk_create(
issue_props, batch_size=10, ignore_conflicts=True
)

View File

@@ -3,7 +3,7 @@ from plane.db.models import Profile, Workspace, WorkspaceMemberInvite
def get_redirection_path(user):
# Handle redirections
profile = Profile.objects.get(user=user)
profile, _ = Profile.objects.get_or_create(user=user)
# Redirect to onboarding if the user is not onboarded yet
if not profile.is_onboarded:

View File

@@ -120,7 +120,7 @@ class MagicSignInEndpoint(View):
callback=post_user_auth_workflow,
)
user = provider.authenticate()
profile = Profile.objects.get(user=user)
profile, _ = Profile.objects.get_or_create(user=user)
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
if user.is_password_autoset and profile.is_onboarded:

View File

@@ -582,17 +582,18 @@ def create_issue_activity(
issue_activities,
epoch,
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="created the issue",
verb="created",
actor_id=actor_id,
epoch=epoch,
)
issue = Issue.objects.get(pk=issue_id)
issue_activity = IssueActivity.objects.create(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="created the issue",
verb="created",
actor_id=actor_id,
epoch=epoch,
)
issue_activity.created_at = issue.created_at
issue_activity.save(update_fields=["created_at"])
requested_data = (
json.loads(requested_data) if requested_data is not None else None
)
@@ -1391,6 +1392,7 @@ def create_issue_relation_activity(
workspace_id=workspace_id,
comment=f"added {requested_data.get('relation_type')} relation",
old_identifier=related_issue,
epoch=epoch,
)
)
issue = Issue.objects.get(pk=issue_id)
@@ -1716,12 +1718,16 @@ def issue_activity(
event=(
"issue_comment"
if activity.field == "comment"
else "inbox_issue" if inbox else "issue"
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
else inbox
if inbox
else activity.issue_id
),
verb=activity.verb,
field=(

View File

@@ -30,7 +30,7 @@ def page_version(
workspace_id=page.workspace_id,
description_html=page.description_html,
description_binary=page.description_binary,
ownned_by_id=user_id,
owned_by_id=user_id,
last_saved_at=page.updated_at,
)

View File

@@ -161,7 +161,7 @@ class Migration(migrations.Migration):
options={
"verbose_name": "Workspace User Property",
"verbose_name_plural": "Workspace User Property",
"db_table": "Workspace_user_properties",
"db_table": "workspace_user_properties",
"ordering": ("-created_at",),
"unique_together": {("workspace", "user")},
},

View File

@@ -0,0 +1,44 @@
# Generated by Django 4.2.11 on 2024-07-15 06:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0070_apitoken_is_service_exporterhistory_filters_and_more"),
]
operations = [
migrations.RenameModel(
old_name="IssueProperty",
new_name="IssueUserProperty",
),
migrations.AlterModelOptions(
name="issueuserproperty",
options={
"ordering": ("-created_at",),
"verbose_name": "Issue User Property",
"verbose_name_plural": "Issue User Properties",
},
),
migrations.AlterModelTable(
name="issueuserproperty",
table="issue_user_properties",
),
migrations.AddField(
model_name="issuetype",
name="is_active",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="project",
name="is_issue_type_enabled",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="issuetype",
name="is_default",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,145 @@
# Generated by Django 4.2.14 on 2024-07-22 13:22
from django.db import migrations, models
from django.conf import settings
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0071_rename_issueproperty_issueuserproperty_and_more"),
]
operations = [
migrations.AddField(
model_name="issueattachment",
name="external_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="issueattachment",
name="external_source",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.CreateModel(
name="UserRecentVisit",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("entity_identifier", models.UUIDField(null=True)),
(
"entity_name",
models.CharField(
choices=[
("VIEW", "View"),
("PAGE", "Page"),
("ISSUE", "Issue"),
("CYCLE", "Cycle"),
("MODULE", "Module"),
("PROJECT", "Project"),
],
max_length=30,
),
),
("visited_at", models.DateTimeField(auto_now=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_recent_visit",
to=settings.AUTH_USER_MODEL,
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "User Recent Visit",
"verbose_name_plural": "User Recent Visits",
"db_table": "user_recent_visits",
"ordering": ("-created_at",),
},
),
migrations.RemoveField(
model_name="project",
name="start_date",
),
migrations.RemoveField(
model_name="project",
name="target_date",
),
migrations.AlterField(
model_name="issuesequence",
name="sequence",
field=models.PositiveBigIntegerField(db_index=True, default=1),
),
migrations.AlterField(
model_name="project",
name="identifier",
field=models.CharField(
db_index=True, max_length=12, verbose_name="Project Identifier"
),
),
migrations.AlterField(
model_name="projectidentifier",
name="name",
field=models.CharField(db_index=True, max_length=12),
),
]

View File

@@ -29,7 +29,7 @@ from .issue import (
IssueLabel,
IssueLink,
IssueMention,
IssueProperty,
IssueUserProperty,
IssueReaction,
IssueRelation,
IssueSequence,
@@ -110,3 +110,5 @@ from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite
from .issue_type import IssueType
from .recent_visit import UserRecentVisit

View File

@@ -6,9 +6,7 @@ from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.db import models, transaction
from django.utils import timezone
# Module imports
@@ -182,7 +180,6 @@ class Issue(ProjectBaseModel):
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# This means that the model isn't saved to the database yet
if self.state is None:
try:
from plane.db.models import State
@@ -192,7 +189,6 @@ class Issue(ProjectBaseModel):
project=self.project,
default=True,
).first()
# if there is no default state assign any random state
if default_state is None:
random_state = State.objects.filter(
~models.Q(is_triage=True), project=self.project
@@ -206,7 +202,6 @@ class Issue(ProjectBaseModel):
try:
from plane.db.models import State
# Check if the current issue state group is completed or not
if self.state.group == "completed":
self.completed_at = timezone.now()
else:
@@ -215,30 +210,44 @@ class Issue(ProjectBaseModel):
pass
if self._state.adding:
# Get the maximum display_id value from the database
last_id = IssueSequence.objects.filter(
project=self.project
).aggregate(largest=models.Max("sequence"))["largest"]
# aggregate can return None! Check it first.
# If it isn't none, just use the last ID specified (which should be the greatest) and add one to it
if last_id:
self.sequence_id = last_id + 1
else:
self.sequence_id = 1
with transaction.atomic():
last_sequence = (
IssueSequence.objects.filter(project=self.project)
.select_for_update()
.aggregate(largest=models.Max("sequence"))["largest"]
)
self.sequence_id = last_sequence + 1 if last_sequence else 1
# Strip the html tags using html parser
self.description_stripped = (
None
if (
self.description_html == ""
or self.description_html is None
)
else strip_tags(self.description_html)
)
largest_sort_order = Issue.objects.filter(
project=self.project, state=self.state
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
largest_sort_order = Issue.objects.filter(
project=self.project, state=self.state
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(Issue, self).save(*args, **kwargs)
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
super(Issue, self).save(*args, **kwargs)
IssueSequence.objects.create(
issue=self, sequence=self.sequence_id, project=self.project
)
else:
# Strip the html tags using html parser
self.description_stripped = (
None
if (
self.description_html == ""
or self.description_html is None
)
else strip_tags(self.description_html)
)
super(Issue, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the issue"""
@@ -375,6 +384,8 @@ class IssueAttachment(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_attachment"
)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
class Meta:
verbose_name = "Issue Attachment"
@@ -482,7 +493,7 @@ class IssueComment(ProjectBaseModel):
return str(self.issue)
class IssueProperty(ProjectBaseModel):
class IssueUserProperty(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
@@ -495,9 +506,9 @@ class IssueProperty(ProjectBaseModel):
)
class Meta:
verbose_name = "Issue Property"
verbose_name_plural = "Issue Properties"
db_table = "issue_properties"
verbose_name = "Issue User Property"
verbose_name_plural = "Issue User Properties"
db_table = "issue_user_properties"
ordering = ("-created_at",)
unique_together = ["user", "project"]
@@ -567,9 +578,9 @@ class IssueSequence(ProjectBaseModel):
Issue,
on_delete=models.SET_NULL,
related_name="issue_sequence",
null=True,
null=True, # This is set to null because we want to keep the sequence even if the issue is deleted
)
sequence = models.PositiveBigIntegerField(default=1)
sequence = models.PositiveBigIntegerField(default=1, db_index=True)
deleted = models.BooleanField(default=False)
class Meta:
@@ -675,14 +686,3 @@ class IssueVote(ProjectBaseModel):
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
# TODO: Find a better method to save the model
@receiver(post_save, sender=Issue)
def create_issue_sequence(sender, instance, created, **kwargs):
if created:
IssueSequence.objects.create(
issue=instance,
sequence=instance.sequence_id,
project=instance.project,
)

View File

@@ -10,8 +10,9 @@ class IssueType(WorkspaceBaseModel):
description = models.TextField(blank=True)
logo_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
is_default = models.BooleanField(default=True)
is_default = models.BooleanField(default=False)
weight = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
unique_together = ["project", "name"]

View File

@@ -72,6 +72,7 @@ class Project(BaseModel):
identifier = models.CharField(
max_length=12,
verbose_name="Project Identifier",
db_index=True,
)
default_assignee = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -95,6 +96,7 @@ class Project(BaseModel):
page_view = models.BooleanField(default=True)
inbox_view = models.BooleanField(default=False)
is_time_tracking_enabled = models.BooleanField(default=False)
is_issue_type_enabled = models.BooleanField(default=False)
cover_image = models.URLField(blank=True, null=True, max_length=800)
estimate = models.ForeignKey(
"db.Estimate",
@@ -116,9 +118,6 @@ class Project(BaseModel):
related_name="default_state",
)
archived_at = models.DateTimeField(null=True)
# Project start and target date
start_date = models.DateTimeField(null=True, blank=True)
target_date = models.DateTimeField(null=True, blank=True)
def __str__(self):
"""Return name of the project"""
@@ -221,7 +220,7 @@ class ProjectIdentifier(AuditModel):
project = models.OneToOneField(
Project, on_delete=models.CASCADE, related_name="project_identifier"
)
name = models.CharField(max_length=12)
name = models.CharField(max_length=12, db_index=True)
class Meta:
unique_together = ["name", "workspace"]

View File

@@ -0,0 +1,38 @@
# Django imports
from django.db import models
from django.conf import settings
# Module imports
from .workspace import WorkspaceBaseModel
class EntityNameEnum(models.TextChoices):
VIEW = "VIEW", "View"
PAGE = "PAGE", "Page"
ISSUE = "ISSUE", "Issue"
CYCLE = "CYCLE", "Cycle"
MODULE = "MODULE", "Module"
PROJECT = "PROJECT", "Project"
class UserRecentVisit(WorkspaceBaseModel):
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(
max_length=30,
choices=EntityNameEnum.choices,
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="user_recent_visit",
)
visited_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "User Recent Visit"
verbose_name_plural = "User Recent Visits"
db_table = "user_recent_visits"
ordering = ("-created_at",)
def __str__(self):
return f"{self.entity_name} {self.user.email}"

View File

@@ -6,6 +6,7 @@ from django.db import models
from .base import BaseModel
from .project import ProjectBaseModel
from .workspace import WorkspaceBaseModel
from plane.utils.issue_filters import issue_filters
def get_default_filters():
@@ -116,6 +117,26 @@ class IssueView(WorkspaceBaseModel):
db_table = "issue_views"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
query_params = self.filters
self.query = (
issue_filters(query_params, "POST") if query_params else {}
)
if self._state.adding:
if self.project:
largest_sort_order = IssueView.objects.filter(
project=self.project
).aggregate(largest=models.Max("sort_order"))["largest"]
else:
largest_sort_order = IssueView.objects.filter(
workspace=self.workspace, project__isnull=True
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(IssueView, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.project.name}>"

View File

@@ -276,8 +276,6 @@ CELERY_IMPORTS = (
"plane.bgtasks.api_logs_task",
# management tasks
"plane.bgtasks.dummy_data_task",
# backfill tasks
"plane.db.backfills.backfill_0070_page_versions",
)
# Sentry Settings
@@ -296,7 +294,7 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
send_default_pii=True,
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
profiles_sample_rate=float(
os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0.5)
os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0)
),
)

View File

@@ -1,5 +1,9 @@
from .user import UserLiteSerializer
from .issue import LabelLiteSerializer, StateLiteSerializer
from .issue import (
LabelLiteSerializer,
StateLiteSerializer,
IssuePublicSerializer,
)
from .state import StateSerializer, StateLiteSerializer

View File

@@ -188,11 +188,16 @@ class IssueAttachmentSerializer(BaseSerializer):
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = "__all__"
fields = [
"issue",
"reaction",
"workspace",
"project",
"actor",
]
read_only_fields = [
"workspace",
"project",
@@ -454,20 +459,6 @@ class IssueCreateSerializer(BaseSerializer):
return super().update(instance, validated_data)
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"actor",
]
class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
@@ -476,7 +467,6 @@ class CommentReactionSerializer(BaseSerializer):
class IssueVoteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueVote
@@ -486,35 +476,45 @@ class IssueVoteSerializer(BaseSerializer):
"workspace",
"project",
"actor",
"actor_detail",
]
read_only_fields = fields
class IssuePublicSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
reactions = IssueReactionSerializer(
read_only=True, many=True, source="issue_reactions"
)
votes = IssueVoteSerializer(read_only=True, many=True)
module_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
label_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
assignee_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
class Meta:
model = Issue
fields = [
"id",
"name",
"description_html",
"sequence_id",
"state",
"state_detail",
"project",
"project_detail",
"workspace",
"priority",
"target_date",
"reactions",
"votes",
"module_ids",
"created_by",
"label_ids",
"assignee_ids",
]
read_only_fields = fields

View File

@@ -3,7 +3,6 @@ from django.urls import path
from plane.space.views import (
InboxIssuePublicViewSet,
IssueVotePublicViewSet,
WorkspaceProjectDeployBoardEndpoint,
)
@@ -30,17 +29,6 @@ urlpatterns = [
),
name="inbox-issue",
),
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="issue-vote-project-board",
),
path(
"workspaces/<str:slug>/project-boards/",
WorkspaceProjectDeployBoardEndpoint.as_view(),

View File

@@ -6,6 +6,7 @@ from plane.space.views import (
IssueCommentPublicViewSet,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
)
urlpatterns = [
@@ -73,4 +74,15 @@ urlpatterns = [
),
name="comment-reactions-project-board",
),
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="issue-vote-project-board",
),
]

View File

@@ -5,6 +5,11 @@ from plane.space.views import (
ProjectDeployBoardPublicSettingsEndpoint,
ProjectIssuesPublicEndpoint,
WorkspaceProjectAnchorEndpoint,
ProjectCyclesEndpoint,
ProjectModulesEndpoint,
ProjectStatesEndpoint,
ProjectLabelsEndpoint,
ProjectMembersEndpoint,
)
urlpatterns = [
@@ -23,4 +28,29 @@ urlpatterns = [
WorkspaceProjectAnchorEndpoint.as_view(),
name="project-deploy-board",
),
path(
"anchor/<str:anchor>/cycles/",
ProjectCyclesEndpoint.as_view(),
name="project-cycles",
),
path(
"anchor/<str:anchor>/modules/",
ProjectModulesEndpoint.as_view(),
name="project-modules",
),
path(
"anchor/<str:anchor>/states/",
ProjectStatesEndpoint.as_view(),
name="project-states",
),
path(
"anchor/<str:anchor>/labels/",
ProjectLabelsEndpoint.as_view(),
name="project-labels",
),
path(
"anchor/<str:anchor>/members/",
ProjectMembersEndpoint.as_view(),
name="project-members",
),
]

View File

@@ -0,0 +1,248 @@
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField
from django.db.models.functions import Coalesce, JSONObject
# Module imports
from plane.db.models import (
Cycle,
Issue,
Label,
Module,
Project,
ProjectMember,
State,
WorkspaceMember,
)
def issue_queryset_grouper(queryset, group_by, sub_group_by):
FIELD_MAPPER = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
"module_ids": "issue_module__module_id",
}
annotations_map = {
"assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)),
"label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
"module_ids": (
"issue_module__module_id",
~Q(issue_module__module_id__isnull=True),
),
}
default_annotations = {
key: Coalesce(
ArrayAgg(
field,
distinct=True,
filter=condition,
),
Value([], output_field=ArrayField(UUIDField())),
)
for key, (field, condition) in annotations_map.items()
if FIELD_MAPPER.get(key) != group_by
or FIELD_MAPPER.get(key) != sub_group_by
}
return queryset.annotate(**default_annotations)
def issue_on_results(issues, group_by, sub_group_by):
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"issue_module__module_id": "module_ids",
}
original_list = ["assignee_ids", "label_ids", "module_ids"]
required_fields = [
"id",
"name",
"state_id",
"sort_order",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_by",
"state__group",
]
if group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[group_by])
original_list.append(group_by)
if sub_group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[sub_group_by])
original_list.append(sub_group_by)
required_fields.extend(original_list)
issues = issues.annotate(
vote_items=ArrayAgg(
Case(
When(
votes__isnull=False,
then=JSONObject(
vote=F("votes__vote"),
actor_details=JSONObject(
id=F("votes__actor__id"),
first_name=F("votes__actor__first_name"),
last_name=F("votes__actor__last_name"),
avatar=F("votes__actor__avatar"),
display_name=F("votes__actor__display_name"),
)
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(votes__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
reaction_items=ArrayAgg(
Case(
When(
issue_reactions__isnull=False,
then=JSONObject(
reaction=F("issue_reactions__reaction"),
actor_details=JSONObject(
id=F("issue_reactions__actor__id"),
first_name=F("issue_reactions__actor__first_name"),
last_name=F("issue_reactions__actor__last_name"),
avatar=F("issue_reactions__actor__avatar"),
display_name=F("issue_reactions__actor__display_name"),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(issue_reactions__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
).values(*required_fields, "vote_items", "reaction_items")
return issues
def issue_group_values(field, slug, project_id=None, filters=dict):
if field == "state_id":
queryset = State.objects.filter(
is_triage=False,
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "labels__id":
queryset = Label.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "assignees__id":
if project_id:
return ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
is_active=True,
).values_list("member_id", flat=True)
else:
return list(
WorkspaceMember.objects.filter(
workspace__slug=slug, is_active=True
).values_list("member_id", flat=True)
)
if field == "issue_module__module_id":
queryset = Module.objects.filter(
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "cycle_id":
queryset = Cycle.objects.filter(
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "project_id":
queryset = Project.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
return list(queryset)
if field == "priority":
return [
"low",
"medium",
"high",
"urgent",
"none",
]
if field == "state__group":
return [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
if field == "target_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("target_date", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "start_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("start_date", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "created_by":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("created_by", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
return []

View File

@@ -2,6 +2,7 @@ from .project import (
ProjectDeployBoardPublicSettingsEndpoint,
WorkspaceProjectDeployBoardEndpoint,
WorkspaceProjectAnchorEndpoint,
ProjectMembersEndpoint,
)
from .issue import (
@@ -14,3 +15,11 @@ from .issue import (
)
from .inbox import InboxIssuePublicViewSet
from .cycle import ProjectCyclesEndpoint
from .module import ProjectModulesEndpoint
from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint

View File

@@ -0,0 +1,35 @@
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
Cycle,
)
class ProjectCyclesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
cycles = Cycle.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name")
return Response(
cycles,
status=status.HTTP_200_OK,
)

View File

@@ -1,34 +1,51 @@
# Python imports
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce, JSONObject
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.db.models import (
Exists,
F,
Func,
OuterRef,
Q,
Prefetch,
UUIDField,
Case,
When,
CharField,
IntegerField,
JSONField,
Value,
Max,
OuterRef,
Func
)
# Django imports
from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
# Module imports
from .base import BaseAPIView, BaseViewSet
# fetch the space app grouper function separately
from plane.space.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from plane.app.serializers import (
CommentReactionSerializer,
IssueCommentSerializer,
IssuePublicSerializer,
IssueReactionSerializer,
IssueVoteSerializer,
)
@@ -36,21 +53,183 @@ from plane.db.models import (
Issue,
IssueComment,
IssueLink,
IssueAttachment,
ProjectMember,
IssueReaction,
ProjectMember,
CommentReaction,
DeployBoard,
IssueVote,
ProjectPublicMember,
State,
Label,
IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
# Module imports
from .base import BaseAPIView, BaseViewSet
class ProjectIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
if not deploy_board:
return Response(
{"error": "Project is not published"},
status=status.HTTP_404_NOT_FOUND,
)
project_id = deploy_board.entity_identifier
slug = deploy_board.workspace.slug
issue_queryset = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.prefetch_related(
Prefetch(
"votes",
queryset=IssueVote.objects.select_related("actor"),
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
issue_queryset = issue_queryset.filter(**filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
)
class IssueCommentPublicViewSet(BaseViewSet):
@@ -503,67 +682,50 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
]
def get(self, request, anchor, issue_id):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
issue = Issue.objects.get(
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
pk=issue_id,
)
serializer = IssuePublicSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
if not deploy_board:
return Response(
{"error": "Project is not published"},
status=status.HTTP_404_NOT_FOUND,
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
project_id = deploy_board.entity_identifier
slug = deploy_board.workspace.slug
deploy_board = DeployBoard.objects.get(anchor=anchor)
issue_queryset = (
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")
Issue.issue_objects.filter(
pk=issue_id,
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.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)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
@@ -572,124 +734,91 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
queryset=IssueVote.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssuePublicSerializer(issue_queryset, many=True).data
state_group_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=slug,
project_id=project_id,
)
.annotate(
custom_order=Case(
*[
When(group=value, then=Value(index))
for index, value in enumerate(state_group_order)
],
default=Value(len(state_group_order)),
output_field=IntegerField(),
vote_items=ArrayAgg(
Case(
When(
votes__isnull=False,
then=JSONObject(
vote=F("votes__vote"),
actor_details=JSONObject(
id=F("votes__actor__id"),
first_name=F("votes__actor__first_name"),
last_name=F("votes__actor__last_name"),
avatar=F("votes__actor__avatar"),
display_name=F(
"votes__actor__display_name"
),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(votes__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
reaction_items=ArrayAgg(
Case(
When(
issue_reactions__isnull=False,
then=JSONObject(
reaction=F("issue_reactions__reaction"),
actor_details=JSONObject(
id=F("issue_reactions__actor__id"),
first_name=F(
"issue_reactions__actor__first_name"
),
last_name=F(
"issue_reactions__actor__last_name"
),
avatar=F("issue_reactions__actor__avatar"),
display_name=F(
"issue_reactions__actor__display_name"
),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(issue_reactions__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
)
.values("name", "group", "color", "id")
.order_by("custom_order", "sequence")
)
.values(
"id",
"name",
"state_id",
"sort_order",
"description",
"description_html",
"description_stripped",
"description_binary",
"module_ids",
"label_ids",
"assignee_ids",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_by",
"state__group",
"vote_items",
"reaction_items",
)
).first()
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
return Response(issue_queryset, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,35 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
Label,
)
class ProjectLabelsEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
labels = Label.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name", "color", "parent")
return Response(
labels,
status=status.HTTP_200_OK,
)

View File

@@ -0,0 +1,35 @@
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
Module,
)
class ProjectModulesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
modules = Module.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name")
return Response(
modules,
status=status.HTTP_200_OK,
)

View File

@@ -12,10 +12,7 @@ from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseAPIView
from plane.app.serializers import DeployBoardSerializer
from plane.db.models import (
Project,
DeployBoard,
)
from plane.db.models import Project, DeployBoard, ProjectMember
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
@@ -76,3 +73,27 @@ class WorkspaceProjectAnchorEndpoint(BaseAPIView):
)
serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectMembersEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
members = ProjectMember.objects.filter(
project=deploy_board.project,
workspace=deploy_board.workspace,
is_active=True,
).values(
"id",
"member",
"member__first_name",
"member__last_name",
"member__display_name",
"project",
"workspace",
)
return Response(members, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,42 @@
# Django imports
from django.db.models import Q
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
State,
)
class ProjectStatesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
)
.values("name", "group", "color", "id")
)
return Response(
states,
status=status.HTTP_200_OK,
)

View File

@@ -23,42 +23,42 @@ def filter_valid_uuids(uuid_list):
# Get the 2_weeks, 3_months
def string_date_filter(
filter, duration, subsequent, term, date_filter, offset
issue_filter, duration, subsequent, term, date_filter, offset
):
now = timezone.now().date()
if term == "months":
if subsequent == "after":
if offset == "fromnow":
filter[f"{date_filter}__gte"] = now + timedelta(
issue_filter[f"{date_filter}__gte"] = now + timedelta(
days=duration * 30
)
else:
filter[f"{date_filter}__gte"] = now - timedelta(
issue_filter[f"{date_filter}__gte"] = now - timedelta(
days=duration * 30
)
else:
if offset == "fromnow":
filter[f"{date_filter}__lte"] = now + timedelta(
issue_filter[f"{date_filter}__lte"] = now + timedelta(
days=duration * 30
)
else:
filter[f"{date_filter}__lte"] = now - timedelta(
issue_filter[f"{date_filter}__lte"] = now - timedelta(
days=duration * 30
)
if term == "weeks":
if subsequent == "after":
if offset == "fromnow":
filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration)
issue_filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration)
else:
filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
issue_filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
else:
if offset == "fromnow":
filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration)
issue_filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration)
else:
filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration)
issue_filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration)
def date_filter(filter, date_term, queries):
def date_filter(issue_filter, date_term, queries):
"""
Handle all date filters
"""
@@ -71,7 +71,7 @@ def date_filter(filter, date_term, queries):
if len(date_query) == 3:
digit, term = date_query[0].split("_")
string_date_filter(
filter=filter,
issue_filter=issue_filter,
duration=int(digit),
subsequent=date_query[1],
term=term,
@@ -80,32 +80,32 @@ def date_filter(filter, date_term, queries):
)
else:
if "after" in date_query:
filter[f"{date_term}__gte"] = date_query[0]
issue_filter[f"{date_term}__gte"] = date_query[0]
else:
filter[f"{date_term}__lte"] = date_query[0]
issue_filter[f"{date_term}__lte"] = date_query[0]
else:
filter[f"{date_term}__contains"] = date_query[0]
issue_filter[f"{date_term}__contains"] = date_query[0]
def filter_state(params, filter, method, prefix=""):
def filter_state(params, issue_filter, method, prefix=""):
if method == "GET":
states = [
item for item in params.get("state").split(",") if item != "null"
]
states = filter_valid_uuids(states)
if len(states) and "" not in states:
filter[f"{prefix}state__in"] = states
issue_filter[f"{prefix}state__in"] = states
else:
if (
params.get("state", None)
and len(params.get("state"))
and params.get("state") != "null"
):
filter[f"{prefix}state__in"] = params.get("state")
return filter
issue_filter[f"{prefix}state__in"] = params.get("state")
return issue_filter
def filter_state_group(params, filter, method, prefix=""):
def filter_state_group(params, issue_filter, method, prefix=""):
if method == "GET":
state_group = [
item
@@ -113,18 +113,18 @@ def filter_state_group(params, filter, method, prefix=""):
if item != "null"
]
if len(state_group) and "" not in state_group:
filter[f"{prefix}state__group__in"] = state_group
issue_filter[f"{prefix}state__group__in"] = state_group
else:
if (
params.get("state_group", None)
and len(params.get("state_group"))
and params.get("state_group") != "null"
):
filter[f"{prefix}state__group__in"] = params.get("state_group")
return filter
issue_filter[f"{prefix}state__group__in"] = params.get("state_group")
return issue_filter
def filter_estimate_point(params, filter, method, prefix=""):
def filter_estimate_point(params, issue_filter, method, prefix=""):
if method == "GET":
estimate_points = [
item
@@ -132,20 +132,20 @@ def filter_estimate_point(params, filter, method, prefix=""):
if item != "null"
]
if len(estimate_points) and "" not in estimate_points:
filter[f"{prefix}estimate_point__in"] = estimate_points
issue_filter[f"{prefix}estimate_point__in"] = estimate_points
else:
if (
params.get("estimate_point", None)
and len(params.get("estimate_point"))
and params.get("estimate_point") != "null"
):
filter[f"{prefix}estimate_point__in"] = params.get(
issue_filter[f"{prefix}estimate_point__in"] = params.get(
"estimate_point"
)
return filter
return issue_filter
def filter_priority(params, filter, method, prefix=""):
def filter_priority(params, issue_filter, method, prefix=""):
if method == "GET":
priorities = [
item
@@ -153,51 +153,58 @@ def filter_priority(params, filter, method, prefix=""):
if item != "null"
]
if len(priorities) and "" not in priorities:
filter[f"{prefix}priority__in"] = priorities
return filter
issue_filter[f"{prefix}priority__in"] = priorities
else:
if (
params.get("priority", None)
and len(params.get("priority"))
and params.get("priority") != "null"
):
issue_filter[f"{prefix}priority__in"] = params.get("priority")
return issue_filter
def filter_parent(params, filter, method, prefix=""):
def filter_parent(params, issue_filter, method, prefix=""):
if method == "GET":
parents = [
item for item in params.get("parent").split(",") if item != "null"
]
if "None" in parents:
filter[f"{prefix}parent__isnull"] = True
issue_filter[f"{prefix}parent__isnull"] = True
parents = filter_valid_uuids(parents)
if len(parents) and "" not in parents:
filter[f"{prefix}parent__in"] = parents
issue_filter[f"{prefix}parent__in"] = parents
else:
if (
params.get("parent", None)
and len(params.get("parent"))
and params.get("parent") != "null"
):
filter[f"{prefix}parent__in"] = params.get("parent")
return filter
issue_filter[f"{prefix}parent__in"] = params.get("parent")
return issue_filter
def filter_labels(params, filter, method, prefix=""):
def filter_labels(params, issue_filter, method, prefix=""):
if method == "GET":
labels = [
item for item in params.get("labels").split(",") if item != "null"
]
if "None" in labels:
filter[f"{prefix}labels__isnull"] = True
issue_filter[f"{prefix}labels__isnull"] = True
labels = filter_valid_uuids(labels)
if len(labels) and "" not in labels:
filter[f"{prefix}labels__in"] = labels
issue_filter[f"{prefix}labels__in"] = labels
else:
if (
params.get("labels", None)
and len(params.get("labels"))
and params.get("labels") != "null"
):
filter[f"{prefix}labels__in"] = params.get("labels")
return filter
issue_filter[f"{prefix}labels__in"] = params.get("labels")
return issue_filter
def filter_assignees(params, filter, method, prefix=""):
def filter_assignees(params, issue_filter, method, prefix=""):
if method == "GET":
assignees = [
item
@@ -205,21 +212,21 @@ def filter_assignees(params, filter, method, prefix=""):
if item != "null"
]
if "None" in assignees:
filter[f"{prefix}assignees__isnull"] = True
issue_filter[f"{prefix}assignees__isnull"] = True
assignees = filter_valid_uuids(assignees)
if len(assignees) and "" not in assignees:
filter[f"{prefix}assignees__in"] = assignees
issue_filter[f"{prefix}assignees__in"] = assignees
else:
if (
params.get("assignees", None)
and len(params.get("assignees"))
and params.get("assignees") != "null"
):
filter[f"{prefix}assignees__in"] = params.get("assignees")
return filter
issue_filter[f"{prefix}assignees__in"] = params.get("assignees")
return issue_filter
def filter_mentions(params, filter, method, prefix=""):
def filter_mentions(params, issue_filter, method, prefix=""):
if method == "GET":
mentions = [
item
@@ -228,20 +235,20 @@ def filter_mentions(params, filter, method, prefix=""):
]
mentions = filter_valid_uuids(mentions)
if len(mentions) and "" not in mentions:
filter[f"{prefix}issue_mention__mention__id__in"] = mentions
issue_filter[f"{prefix}issue_mention__mention__id__in"] = mentions
else:
if (
params.get("mentions", None)
and len(params.get("mentions"))
and params.get("mentions") != "null"
):
filter[f"{prefix}issue_mention__mention__id__in"] = params.get(
issue_filter[f"{prefix}issue_mention__mention__id__in"] = params.get(
"mentions"
)
return filter
return issue_filter
def filter_created_by(params, filter, method, prefix=""):
def filter_created_by(params, issue_filter, method, prefix=""):
if method == "GET":
created_bys = [
item
@@ -249,100 +256,100 @@ def filter_created_by(params, filter, method, prefix=""):
if item != "null"
]
if "None" in created_bys:
filter[f"{prefix}created_by__isnull"] = True
issue_filter[f"{prefix}created_by__isnull"] = True
created_bys = filter_valid_uuids(created_bys)
if len(created_bys) and "" not in created_bys:
filter[f"{prefix}created_by__in"] = created_bys
issue_filter[f"{prefix}created_by__in"] = created_bys
else:
if (
params.get("created_by", None)
and len(params.get("created_by"))
and params.get("created_by") != "null"
):
filter[f"{prefix}created_by__in"] = params.get("created_by")
return filter
issue_filter[f"{prefix}created_by__in"] = params.get("created_by")
return issue_filter
def filter_name(params, filter, method, prefix=""):
def filter_name(params, issue_filter, method, prefix=""):
if params.get("name", "") != "":
filter[f"{prefix}name__icontains"] = params.get("name")
return filter
issue_filter[f"{prefix}name__icontains"] = params.get("name")
return issue_filter
def filter_created_at(params, filter, method, prefix=""):
def filter_created_at(params, issue_filter, method, prefix=""):
if method == "GET":
created_ats = params.get("created_at").split(",")
if len(created_ats) and "" not in created_ats:
date_filter(
filter=filter,
issue_filter=issue_filter,
date_term=f"{prefix}created_at__date",
queries=created_ats,
)
else:
if params.get("created_at", None) and len(params.get("created_at")):
date_filter(
filter=filter,
issue_filter=issue_filter,
date_term=f"{prefix}created_at__date",
queries=params.get("created_at", []),
)
return filter
return issue_filter
def filter_updated_at(params, filter, method, prefix=""):
def filter_updated_at(params, issue_filter, method, prefix=""):
if method == "GET":
updated_ats = params.get("updated_at").split(",")
if len(updated_ats) and "" not in updated_ats:
date_filter(
filter=filter,
issue_filter=issue_filter,
date_term=f"{prefix}created_at__date",
queries=updated_ats,
)
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
date_filter(
filter=filter,
issue_filter=issue_filter,
date_term=f"{prefix}created_at__date",
queries=params.get("updated_at", []),
)
return filter
return issue_filter
def filter_start_date(params, filter, method, prefix=""):
def filter_start_date(params, issue_filter, method, prefix=""):
if method == "GET":
start_dates = params.get("start_date").split(",")
if len(start_dates) and "" not in start_dates:
date_filter(
filter=filter,
issue_filter=issue_filter,
date_term=f"{prefix}start_date",
queries=start_dates,
)
else:
if params.get("start_date", None) and len(params.get("start_date")):
filter[f"{prefix}start_date"] = params.get("start_date")
return filter
issue_filter[f"{prefix}start_date"] = params.get("start_date")
return issue_filter
def filter_target_date(params, filter, method, prefix=""):
def filter_target_date(params, issue_filter, method, prefix=""):
if method == "GET":
target_dates = params.get("target_date").split(",")
if len(target_dates) and "" not in target_dates:
date_filter(
filter=filter,
issue_filter=issue_filter,
date_term=f"{prefix}target_date",
queries=target_dates,
)
else:
if params.get("target_date", None) and len(params.get("target_date")):
filter[f"{prefix}target_date"] = params.get("target_date")
return filter
issue_filter[f"{prefix}target_date"] = params.get("target_date")
return issue_filter
def filter_completed_at(params, filter, method, prefix=""):
def filter_completed_at(params, issue_filter, method, prefix=""):
if method == "GET":
completed_ats = params.get("completed_at").split(",")
if len(completed_ats) and "" not in completed_ats:
date_filter(
filter=filter,
issue_filter=issue_filter,
date_term=f"{prefix}completed_at__date",
queries=completed_ats,
)
@@ -351,14 +358,14 @@ def filter_completed_at(params, filter, method, prefix=""):
params.get("completed_at")
):
date_filter(
filter=filter,
issue_filter=issue_filter,
date_term=f"{prefix}completed_at__date",
queries=params.get("completed_at", []),
)
return filter
return issue_filter
def filter_issue_state_type(params, filter, method, prefix=""):
def filter_issue_state_type(params, issue_filter, method, prefix=""):
type = params.get("type", "all")
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
if type == "backlog":
@@ -366,71 +373,71 @@ def filter_issue_state_type(params, filter, method, prefix=""):
if type == "active":
group = ["unstarted", "started"]
filter[f"{prefix}state__group__in"] = group
return filter
issue_filter[f"{prefix}state__group__in"] = group
return issue_filter
def filter_project(params, filter, method, prefix=""):
def filter_project(params, issue_filter, method, prefix=""):
if method == "GET":
projects = [
item for item in params.get("project").split(",") if item != "null"
]
projects = filter_valid_uuids(projects)
if len(projects) and "" not in projects:
filter[f"{prefix}project__in"] = projects
issue_filter[f"{prefix}project__in"] = projects
else:
if (
params.get("project", None)
and len(params.get("project"))
and params.get("project") != "null"
):
filter[f"{prefix}project__in"] = params.get("project")
return filter
issue_filter[f"{prefix}project__in"] = params.get("project")
return issue_filter
def filter_cycle(params, filter, method, prefix=""):
def filter_cycle(params, issue_filter, method, prefix=""):
if method == "GET":
cycles = [
item for item in params.get("cycle").split(",") if item != "null"
]
if "None" in cycles:
filter[f"{prefix}issue_cycle__cycle_id__isnull"] = True
issue_filter[f"{prefix}issue_cycle__cycle_id__isnull"] = True
cycles = filter_valid_uuids(cycles)
if len(cycles) and "" not in cycles:
filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles
issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles
else:
if (
params.get("cycle", None)
and len(params.get("cycle"))
and params.get("cycle") != "null"
):
filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle")
return filter
issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle")
return issue_filter
def filter_module(params, filter, method, prefix=""):
def filter_module(params, issue_filter, method, prefix=""):
if method == "GET":
modules = [
item for item in params.get("module").split(",") if item != "null"
]
if "None" in modules:
filter[f"{prefix}issue_module__module_id__isnull"] = True
issue_filter[f"{prefix}issue_module__module_id__isnull"] = True
modules = filter_valid_uuids(modules)
if len(modules) and "" not in modules:
filter[f"{prefix}issue_module__module_id__in"] = modules
issue_filter[f"{prefix}issue_module__module_id__in"] = modules
else:
if (
params.get("module", None)
and len(params.get("module"))
and params.get("module") != "null"
):
filter[f"{prefix}issue_module__module_id__in"] = params.get(
issue_filter[f"{prefix}issue_module__module_id__in"] = params.get(
"module"
)
return filter
return issue_filter
def filter_inbox_status(params, filter, method, prefix=""):
def filter_inbox_status(params, issue_filter, method, prefix=""):
if method == "GET":
status = [
item
@@ -438,32 +445,32 @@ def filter_inbox_status(params, filter, method, prefix=""):
if item != "null"
]
if len(status) and "" not in status:
filter[f"{prefix}issue_inbox__status__in"] = status
issue_filter[f"{prefix}issue_inbox__status__in"] = status
else:
if (
params.get("inbox_status", None)
and len(params.get("inbox_status"))
and params.get("inbox_status") != "null"
):
filter[f"{prefix}issue_inbox__status__in"] = params.get(
issue_filter[f"{prefix}issue_inbox__status__in"] = params.get(
"inbox_status"
)
return filter
return issue_filter
def filter_sub_issue_toggle(params, filter, method, prefix=""):
def filter_sub_issue_toggle(params, issue_filter, method, prefix=""):
if method == "GET":
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
filter[f"{prefix}parent__isnull"] = True
issue_filter[f"{prefix}parent__isnull"] = True
else:
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
filter[f"{prefix}parent__isnull"] = True
return filter
issue_filter[f"{prefix}parent__isnull"] = True
return issue_filter
def filter_subscribed_issues(params, filter, method, prefix=""):
def filter_subscribed_issues(params, issue_filter, method, prefix=""):
if method == "GET":
subscribers = [
item
@@ -472,7 +479,7 @@ def filter_subscribed_issues(params, filter, method, prefix=""):
]
subscribers = filter_valid_uuids(subscribers)
if len(subscribers) and "" not in subscribers:
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
subscribers
)
else:
@@ -481,22 +488,44 @@ def filter_subscribed_issues(params, filter, method, prefix=""):
and len(params.get("subscriber"))
and params.get("subscriber") != "null"
):
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
params.get("subscriber")
)
return filter
return issue_filter
def filter_start_target_date_issues(params, filter, method, prefix=""):
def filter_start_target_date_issues(params, issue_filter, method, prefix=""):
start_target_date = params.get("start_target_date", "false")
if start_target_date == "true":
filter[f"{prefix}target_date__isnull"] = False
filter[f"{prefix}start_date__isnull"] = False
return filter
issue_filter[f"{prefix}target_date__isnull"] = False
issue_filter[f"{prefix}start_date__isnull"] = False
return issue_filter
def filter_logged_by(params, issue_filter, method, prefix=""):
if method == "GET":
logged_bys = [
item
for item in params.get("logged_by").split(",")
if item != "null"
]
if "None" in logged_bys:
issue_filter[f"{prefix}logged_by__isnull"] = True
logged_bys = filter_valid_uuids(logged_bys)
if len(logged_bys) and "" not in logged_bys:
issue_filter[f"{prefix}logged_by__in"] = logged_bys
else:
if (
params.get("logged_by", None)
and len(params.get("logged_by"))
and params.get("logged_by") != "null"
):
issue_filter[f"{prefix}logged_by__in"] = params.get("logged_by")
return issue_filter
def issue_filters(query_params, method, prefix=""):
filter = {}
issue_filter = {}
ISSUE_FILTER = {
"state": filter_state,
@@ -508,6 +537,7 @@ def issue_filters(query_params, method, prefix=""):
"assignees": filter_assignees,
"mentions": filter_mentions,
"created_by": filter_created_by,
"logged_by": filter_logged_by,
"name": filter_name,
"created_at": filter_created_at,
"updated_at": filter_updated_at,
@@ -527,5 +557,5 @@ def issue_filters(query_params, method, prefix=""):
for key, value in ISSUE_FILTER.items():
if key in query_params:
func = value
func(query_params, filter, method, prefix)
return filter
func(query_params, issue_filter, method, prefix)
return issue_filter

View File

@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.11
Django==4.2.14
# rest framework
djangorestframework==3.15.2
# postgres
@@ -26,7 +26,7 @@ django-filter==24.2
# json model
jsonmodels==2.7.0
# sentry
sentry-sdk==2.0.1
sentry-sdk==2.8.0
# storage
django-storages==1.14.2
# user management

View File

@@ -372,8 +372,60 @@ Backup completed successfully. Backup files are stored in /....../plane-app/back
---
### Restore Data
## Upgrading from v0.13.2 to v0.14.x
When you want to restore the previously backed-up data, follow the instructions below.
1. Make sure that Plane-CE is installed, started, and then stopped. This ensures that the Docker volumes are created.
1. Download the restore script using the command below. We suggest downloading it in the same folder as `setup.sh`.
```bash
curl -fsSL -o restore.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/restore.sh
chmod +x restore.sh
```
1. Execute the command below to restore your data.
```bash
./restore.sh <path to backup folder containing *.tar.gz files>
```
As an example, for a backup folder `/opt/plane-selfhost/plane-app/backup/20240722-0914`, expect the response below:
```bash
--------------------------------------------
____ _ /////////
| _ \| | __ _ _ __ ___ /////////
| |_) | |/ _` | '_ \ / _ \ ///// /////
| __/| | (_| | | | | __/ ///// /////
|_| |_|\__,_|_| |_|\___| ////
////
--------------------------------------------
Project management tool from the future
--------------------------------------------
Found /opt/plane-selfhost/plane-app/backup/20240722-0914/pgdata.tar.gz
.....Restoring plane-app_pgdata
.....Successfully restored volume plane-app_pgdata from pgdata.tar.gz
Found /opt/plane-selfhost/plane-app/backup/20240722-0914/redisdata.tar.gz
.....Restoring plane-app_redisdata
.....Successfully restored volume plane-app_redisdata from redisdata.tar.gz
Found /opt/plane-selfhost/plane-app/backup/20240722-0914/uploads.tar.gz
.....Restoring plane-app_uploads
.....Successfully restored volume plane-app_uploads from uploads.tar.gz
Restore completed successfully.
```
1. Start the Plane instance using `./setup.sh start`.
---
<details>
<summary><h2>Upgrading from v0.13.2 to v0.14.x</h2></summary>
This is one time activity for users who are upgrading from v0.13.2 to v0.14.0
@@ -445,3 +497,4 @@ In case the suffixes are wrong or the mentioned volumes are not found, you will
In case of successful migration, it will be a silent exit without error.
Now its time to restart v0.14.0 setup.
</details>

121
deploy/selfhost/restore.sh Executable file
View File

@@ -0,0 +1,121 @@
#!/bin/bash
function print_header() {
clear
cat <<"EOF"
--------------------------------------------
____ _ /////////
| _ \| | __ _ _ __ ___ /////////
| |_) | |/ _` | '_ \ / _ \ ///// /////
| __/| | (_| | | | | __/ ///// /////
|_| |_|\__,_|_| |_|\___| ////
////
--------------------------------------------
Project management tool from the future
--------------------------------------------
EOF
}
function restoreSingleVolume() {
selectedVolume=$1
backupFolder=$2
restoreFile=$3
docker volume rm "$selectedVolume" > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "Error: Failed to remove volume $selectedVolume"
echo ""
return 1
fi
docker volume create "$selectedVolume" > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "Error: Failed to create volume $selectedVolume"
echo ""
return 1
fi
docker run --rm \
-e TAR_NAME="$restoreFile" \
-v "$selectedVolume":"/vol" \
-v "$backupFolder":/backup \
busybox sh -c 'mkdir -p /restore && tar -xzf "/backup/${TAR_NAME}.tar.gz" -C /restore && mv /restore/${TAR_NAME}/* /vol'
if [ $? -ne 0 ]; then
echo "Error: Failed to restore volume ${selectedVolume} from ${restoreFile}.tar.gz"
echo ""
return 1
fi
echo ".....Successfully restored volume $selectedVolume from ${restoreFile}.tar.gz"
echo ""
}
function restoreData() {
print_header
local BACKUP_FOLDER=${1:-$PWD}
local dockerServiceStatus
dockerServiceStatus=$($COMPOSE_CMD ls --filter name=plane-app --format=json | jq -r .[0].Status)
local dockerServicePrefix
dockerServicePrefix="running"
if [[ $dockerServiceStatus == $dockerServicePrefix* ]]; then
echo "Plane App is running. Please STOP the Plane App before restoring data."
exit 1
fi
local volumes
volumes=$(docker volume ls -f "name=plane-app" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
# Check if there are any matching volumes
if [ -z "$volumes" ]; then
echo ".....No volumes found starting with 'plane-app'"
exit 1
fi
for BACKUP_FILE in $BACKUP_FOLDER/*.tar.gz; do
if [ -e "$BACKUP_FILE" ]; then
local restoreFileName
restoreFileName=$(basename "$BACKUP_FILE")
restoreFileName="${restoreFileName%.tar.gz}"
local restoreVolName
restoreVolName="plane-app_${restoreFileName}"
echo "Found $BACKUP_FILE"
local docVol
docVol=$(docker volume ls -f "name=$restoreVolName" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
if [ -z "$docVol" ]; then
echo "Skipping: No volume found with name $restoreVolName"
else
echo ".....Restoring $docVol"
restoreSingleVolume "$docVol" "$BACKUP_FOLDER" "$restoreFileName"
fi
else
echo "No .tar.gz files found in the current directory."
echo ""
echo "Please provide the path to the backup file."
echo ""
echo "Usage: ./restore.sh /path/to/backup"
exit 1
fi
done
echo ""
echo "Restore completed successfully."
echo ""
}
# if docker-compose is installed
if command -v docker-compose &> /dev/null
then
COMPOSE_CMD="docker-compose"
else
COMPOSE_CMD="docker compose"
fi
restoreData "$@"

View File

@@ -34,7 +34,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^2.0.6"
"turbo": "^2.0.9"
},
"resolutions": {
"@types/react": "18.2.48"

View File

@@ -1 +1,2 @@
export * from "./auth";
export * from "./issue";

View File

@@ -0,0 +1,27 @@
export const ALL_ISSUES = "All Issues";
export enum EIssueGroupByToServerOptions {
"state" = "state_id",
"priority" = "priority",
"labels" = "labels__id",
"state_detail.group" = "state__group",
"assignees" = "assignees__id",
"cycle" = "cycle_id",
"module" = "issue_module__module_id",
"target_date" = "target_date",
"project" = "project_id",
"created_by" = "created_by",
}
export enum EServerGroupByToFilterOptions {
"state_id" = "state",
"priority" = "priority",
"labels__id" = "labels",
"state__group" = "state_group",
"assignees__id" = "assignees",
"cycle_id" = "cycle",
"issue_module__module_id" = "module",
"target_date" = "target_date",
"project_id" = "project",
"created_by" = "created_by",
}

View File

@@ -34,6 +34,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
const editor = useEditor({
editorClassName,
enableHistory: true,
extensions,
fileHandler,
forwardedRef,

View File

@@ -1,3 +1,5 @@
import { Selection } from "@tiptap/pm/state";
import { Editor } from "@tiptap/react";
import {
BoldIcon,
Heading1,
@@ -19,8 +21,6 @@ import {
CaseSensitive,
LucideIcon,
} from "lucide-react";
import { Selection } from "@tiptap/pm/state";
import { Editor } from "@tiptap/react";
// helpers
import {
insertImageCommand,
@@ -43,168 +43,151 @@ import {
toggleUnderline,
} from "@/helpers/editor-commands";
// types
import { UploadImage } from "@/types";
import { TEditorCommands, UploadImage } from "@/types";
export interface EditorMenuItem {
key: string;
key: TEditorCommands;
name: string;
isActive: () => boolean;
command: () => void;
icon: LucideIcon;
}
export const TextItem = (editor: Editor) =>
({
key: "text",
name: "Text",
isActive: () => editor.isActive("paragraph"),
command: () => setText(editor),
icon: CaseSensitive,
}) as const satisfies EditorMenuItem;
export const TextItem = (editor: Editor): EditorMenuItem => ({
key: "text",
name: "Text",
isActive: () => editor.isActive("paragraph"),
command: () => setText(editor),
icon: CaseSensitive,
});
export const HeadingOneItem = (editor: Editor) =>
({
key: "h1",
name: "Heading 1",
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
}) as const satisfies EditorMenuItem;
export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
key: "h1",
name: "Heading 1",
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
});
export const HeadingTwoItem = (editor: Editor) =>
({
key: "h2",
name: "Heading 2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
}) as const satisfies EditorMenuItem;
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
key: "h2",
name: "Heading 2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
});
export const HeadingThreeItem = (editor: Editor) =>
({
key: "h3",
name: "Heading 3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
}) as const satisfies EditorMenuItem;
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
key: "h3",
name: "Heading 3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
});
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 HeadingFourItem = (editor: Editor): EditorMenuItem => ({
key: "h4",
name: "Heading 4",
isActive: () => editor.isActive("heading", { level: 4 }),
command: () => toggleHeadingFour(editor),
icon: Heading4,
});
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 HeadingFiveItem = (editor: Editor): EditorMenuItem => ({
key: "h5",
name: "Heading 5",
isActive: () => editor.isActive("heading", { level: 5 }),
command: () => toggleHeadingFive(editor),
icon: Heading5,
});
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 HeadingSixItem = (editor: Editor): EditorMenuItem => ({
key: "h6",
name: "Heading 6",
isActive: () => editor.isActive("heading", { level: 6 }),
command: () => toggleHeadingSix(editor),
icon: Heading6,
});
export const BoldItem = (editor: Editor) =>
({
key: "bold",
name: "Bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
}) as const satisfies EditorMenuItem;
export const BoldItem = (editor: Editor): EditorMenuItem => ({
key: "bold",
name: "Bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
});
export const ItalicItem = (editor: Editor) =>
({
key: "italic",
name: "Italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
}) as const satisfies EditorMenuItem;
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
key: "italic",
name: "Italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
});
export const UnderLineItem = (editor: Editor) =>
({
key: "underline",
name: "Underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
}) as const satisfies EditorMenuItem;
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
key: "underline",
name: "Underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
});
export const StrikeThroughItem = (editor: Editor) =>
({
key: "strikethrough",
name: "Strikethrough",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
}) as const satisfies EditorMenuItem;
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
key: "strikethrough",
name: "Strikethrough",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
});
export const BulletListItem = (editor: Editor) =>
({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
}) as const satisfies EditorMenuItem;
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
});
export const NumberedListItem = (editor: Editor) =>
({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
}) as const satisfies EditorMenuItem;
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
});
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 TodoListItem = (editor: Editor): EditorMenuItem => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
});
export const QuoteItem = (editor: Editor) =>
({
key: "quote",
name: "Quote",
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
}) as const satisfies EditorMenuItem;
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
key: "quote",
name: "Quote",
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
});
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 CodeItem = (editor: Editor): EditorMenuItem => ({
key: "code",
name: "Code",
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
});
export const TableItem = (editor: Editor) =>
({
key: "table",
name: "Table",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon,
}) as const satisfies EditorMenuItem;
export const TableItem = (editor: Editor): EditorMenuItem => ({
key: "table",
name: "Table",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon,
});
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
({
@@ -240,6 +223,3 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
ImageItem(editor, uploadFile),
];
}
export type EditorMenuItemNames =
ReturnType<typeof getEditorMenuItems> extends (infer U)[] ? (U extends { key: infer N } ? N : never) : never;

View File

@@ -33,7 +33,7 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
return {
HTMLAttributes: {
class:
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
"rounded bg-custom-background-80 px-[6px] py-[1.5px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
spellcheck: "false",
},
};

View File

@@ -40,9 +40,9 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
<button
type="button"
className={cn(
"group/button hidden group-hover/code:flex items-center justify-center absolute top-2 right-2 z-10 size-8 rounded-md bg-custom-background-80 border border-custom-border-200 transition duration-150 ease-in-out",
"group/button hidden group-hover/code:flex items-center justify-center absolute top-2 right-2 z-10 size-8 rounded-md bg-custom-background-80 border border-custom-border-200 transition duration-150 ease-in-out backdrop-blur-sm",
{
"bg-green-500/10 hover:bg-green-500/10 active:bg-green-500/10": copied,
"bg-green-500/30 hover:bg-green-500/30 active:bg-green-500/30": copied,
}
)}
onClick={copyToClipboard}
@@ -55,7 +55,7 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
</button>
</Tooltip>
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-8 pl-9 pr-4">
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
<NodeViewContent as="code" className="whitespace-[pre-wrap]" />
</pre>
</NodeViewWrapper>

View File

@@ -30,23 +30,25 @@ import { isValidHttpUrl } from "@/helpers/common";
import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
type TArguments = {
mentionConfig: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
};
enableHistory: boolean;
fileConfig: {
deleteFile: DeleteImage;
restoreFile: RestoreImage;
cancelUploadImage?: () => void;
uploadFile: UploadImage;
};
mentionConfig: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
};
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
};
export const CoreEditorExtensions = ({
mentionConfig,
enableHistory,
fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile },
mentionConfig,
placeholder,
tabIndex,
}: TArguments) => [
@@ -70,11 +72,11 @@ export const CoreEditorExtensions = ({
codeBlock: false,
horizontalRule: false,
blockquote: false,
history: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 1,
},
...(enableHistory ? {} : { history: false }),
}),
CustomQuoteExtension,
DropHandlerExtension(uploadFile),

View File

@@ -146,10 +146,11 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
<div className="text-center text-custom-text-400">Loading...</div>
) : items.length ? (
items.map((item, index) => (
<div
<button
key={item.id}
type="button"
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
"w-full text-left flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
{
"bg-custom-background-80": index === selectedIndex,
}
@@ -158,7 +159,7 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
>
<Avatar name={item?.title} src={item?.avatar} />
<span className="flex-grow truncate">{item.title}</span>
</div>
</button>
))
) : (
<div className="text-center text-custom-text-400">No results</div>

View File

@@ -9,6 +9,9 @@ import {
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
ImageIcon,
List,
ListOrdered,
@@ -29,6 +32,9 @@ import {
toggleHeadingOne,
toggleHeadingTwo,
toggleHeadingThree,
toggleHeadingFour,
toggleHeadingFive,
toggleHeadingSix,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem, UploadImage } from "@/types";
@@ -91,7 +97,7 @@ const getSuggestionItems =
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <CaseSensitive className="h-3.5 w-3.5" />,
icon: <CaseSensitive className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
if (range) {
editor.chain().focus().deleteRange(range).clearNodes().run();
@@ -100,61 +106,91 @@ const getSuggestionItems =
},
},
{
key: "heading_1",
key: "h1",
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 className="h-3.5 w-3.5" />,
icon: <Heading1 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingOne(editor, range);
},
},
{
key: "heading_2",
key: "h2",
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 className="h-3.5 w-3.5" />,
icon: <Heading2 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingTwo(editor, range);
},
},
{
key: "heading_3",
key: "h3",
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 className="h-3.5 w-3.5" />,
icon: <Heading3 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingThree(editor, range);
},
},
{
key: "todo_list",
key: "h4",
title: "Heading 4",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading4 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingFour(editor, range);
},
},
{
key: "h5",
title: "Heading 5",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading5 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingFive(editor, range);
},
},
{
key: "h6",
title: "Heading 6",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading6 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingSix(editor, range);
},
},
{
key: "to-do-list",
title: "To do",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <ListTodo className="h-3.5 w-3.5" />,
icon: <ListTodo className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleTaskList(editor, range);
},
},
{
key: "bullet_list",
key: "bulleted-list",
title: "Bullet list",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List className="h-3.5 w-3.5" />,
icon: <List className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleBulletList(editor, range);
},
},
{
key: "numbered_list",
key: "numbered-list",
title: "Numbered list",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered className="h-3.5 w-3.5" />,
icon: <ListOrdered className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleOrderedList(editor, range);
},
@@ -164,25 +200,25 @@ const getSuggestionItems =
title: "Table",
description: "Create a table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table className="h-3.5 w-3.5" />,
icon: <Table className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
insertTableCommand(editor, range);
},
},
{
key: "quote_block",
key: "quote",
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <Quote className="h-3.5 w-3.5" />,
icon: <Quote className="size-3.5" />,
command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range),
},
{
key: "code_block",
key: "code",
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code2 className="h-3.5 w-3.5" />,
icon: <Code2 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
@@ -190,7 +226,7 @@ const getSuggestionItems =
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["img", "photo", "picture", "media"],
icon: <ImageIcon className="h-3.5 w-3.5" />,
icon: <ImageIcon className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
insertImageCommand(editor, uploadFile, null, range);
},
@@ -200,7 +236,7 @@ const getSuggestionItems =
title: "Divider",
description: "Visually divide blocks.",
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare className="h-3.5 w-3.5" />,
icon: <MinusSquare className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},

View File

@@ -87,6 +87,7 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
id,
editorProps,
editorClassName,
enableHistory: false,
fileHandler,
handleEditorReady,
forwardedRef,

View File

@@ -3,7 +3,7 @@ import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
// components
import { EditorMenuItemNames, getEditorMenuItems } from "@/components/menus";
import { getEditorMenuItems } from "@/components/menus";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
@@ -14,7 +14,15 @@ import { CollaborationProvider } from "@/plane-editor/providers";
// props
import { CoreEditorProps } from "@/props";
// types
import { DeleteImage, EditorRefApi, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
import {
DeleteImage,
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
RestoreImage,
TEditorCommands,
UploadImage,
} from "@/types";
export type TFileHandler = {
cancel: () => void;
@@ -24,42 +32,44 @@ export type TFileHandler = {
};
export interface CustomEditorProps {
id?: string;
fileHandler: TFileHandler;
initialValue?: string;
editorClassName: string;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value?: string | null | undefined;
provider?: CollaborationProvider;
onChange?: (json: object, html: string) => void;
extensions?: any;
editorProps?: EditorProps;
enableHistory: boolean;
extensions?: any;
fileHandler: TFileHandler;
forwardedRef?: MutableRefObject<EditorRefApi | null>;
handleEditorReady?: (value: boolean) => void;
id?: string;
initialValue?: string;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
handleEditorReady?: (value: boolean) => void;
onChange?: (json: object, html: string) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
provider?: CollaborationProvider;
tabIndex?: number;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value?: string | null | undefined;
}
export const useEditor = ({
id = "",
editorProps = {},
initialValue,
editorClassName,
value,
editorProps = {},
enableHistory,
extensions = [],
fileHandler,
onChange,
forwardedRef,
tabIndex,
handleEditorReady,
provider,
id = "",
initialValue,
mentionHandler,
onChange,
placeholder,
provider,
tabIndex,
value,
}: CustomEditorProps) => {
const editor = useCustomEditor({
editorProps: {
@@ -68,16 +78,17 @@ export const useEditor = ({
},
extensions: [
...CoreEditorExtensions({
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights ?? [],
},
enableHistory,
fileConfig: {
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
restoreFile: fileHandler.restore,
cancelUploadImage: fileHandler.cancel,
},
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights ?? [],
},
placeholder,
tabIndex,
}),
@@ -144,12 +155,12 @@ export const useEditor = ({
insertContentAtSavedSelection(editorRef, content, savedSelection);
}
},
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
executeMenuItemCommand: (itemKey: TEditorCommands) => {
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemName);
const item = getEditorMenuItem(itemKey);
if (item) {
if (item.key === "image") {
item.command(savedSelectionRef.current);
@@ -157,13 +168,13 @@ export const useEditor = ({
item.command();
}
} else {
console.warn(`No command found for item: ${itemName}`);
console.warn(`No command found for item: ${itemKey}`);
}
},
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
isMenuItemActive: (itemName: TEditorCommands): boolean => {
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName);
return item ? item.isActive() : false;
},

View File

@@ -1,11 +1,9 @@
// components
import { EditorMenuItemNames } from "@/components/menus";
// helpers
import { IMarking } from "@/helpers/scroll-to-node";
// hooks
import { TFileHandler } from "@/hooks/use-editor";
// types
import { IMentionHighlight, IMentionSuggestion } from "@/types";
import { IMentionHighlight, IMentionSuggestion, TEditorCommands } from "@/types";
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
@@ -17,8 +15,8 @@ export type EditorReadOnlyRefApi = {
export interface EditorRefApi extends EditorReadOnlyRefApi {
setEditorValueAtCursorPosition: (content: string) => void;
executeMenuItemCommand: (itemName: EditorMenuItemNames) => void;
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
executeMenuItemCommand: (itemKey: TEditorCommands) => void;
isMenuItemActive: (itemKey: TEditorCommands) => boolean;
onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void;
isEditorReadyToDiscard: () => boolean;

View File

@@ -1,13 +1,34 @@
import { ReactNode } from "react";
import { Editor, Range } from "@tiptap/core";
export type TEditorCommands =
| "text"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "bold"
| "italic"
| "underline"
| "strikethrough"
| "bulleted-list"
| "numbered-list"
| "to-do-list"
| "quote"
| "code"
| "table"
| "image"
| "divider";
export type CommandProps = {
editor: Editor;
range: Range;
};
export type ISlashCommandItem = {
key: string;
key: TEditorCommands;
title: string;
description: string;
searchTerms: string[];

View File

@@ -270,7 +270,7 @@ module.exports = {
"--tw-prose-headings": convertToRGB("--color-text-100"),
"--tw-prose-lead": convertToRGB("--color-text-100"),
"--tw-prose-links": convertToRGB("--color-primary-100"),
"--tw-prose-bold": convertToRGB("--color-text-100"),
"--tw-prose-bold": "inherit",
"--tw-prose-counters": convertToRGB("--color-text-100"),
"--tw-prose-bullets": convertToRGB("--color-text-100"),
"--tw-prose-hr": convertToRGB("--color-text-100"),

View File

@@ -1,4 +1,4 @@
import type { TIssue, IIssueFilterOptions } from "@plane/types";
import type {TIssue, IIssueFilterOptions} from "@plane/types";
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
@@ -61,6 +61,10 @@ export type TProgressSnapshot = {
estimate_distribution?: TCycleEstimateDistribution;
};
export interface IProjectDetails {
id: string;
}
export interface ICycle extends TProgressSnapshot {
progress_snapshot: TProgressSnapshot | undefined;
@@ -85,6 +89,7 @@ export interface ICycle extends TProgressSnapshot {
filters: IIssueFilterOptions;
};
workspace_id: string;
project_detail: IProjectDetails;
}
export interface CycleIssueResponse {
@@ -102,7 +107,7 @@ export interface CycleIssueResponse {
}
export type SelectCycleType =
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
| (ICycle & {actionType: "edit" | "delete" | "create-issue"})
| undefined;
export type CycleDateCheckData = {

View File

@@ -55,4 +55,9 @@ export type TIssueActivityComment =
id: string;
activity_type: "ACTIVITY";
created_at?: string;
}
| {
id: string;
activity_type: "WORKLOG";
created_at?: string;
};

View File

@@ -42,13 +42,10 @@ export type TBaseIssue = {
export type TIssue = TBaseIssue & {
description_html?: string;
is_subscribed?: boolean;
parent?: partial<TIssue>;
parent?: Partial<TBaseIssue>;
issue_reactions?: TIssueReaction[];
issue_attachment?: TIssueAttachment[];
issue_link?: TIssueLink[];
// tempId is used for optimistic updates. It is not a part of the API response.
tempId?: string;
};
@@ -84,7 +81,7 @@ export type TIssuesResponse = {
total_pages: number;
extra_stats: null;
results: TIssueResponseResults;
}
};
export type TBulkIssueProperties = Pick<
TIssue,
@@ -94,9 +91,18 @@ export type TBulkIssueProperties = Pick<
| "assignee_ids"
| "start_date"
| "target_date"
| "module_ids"
| "cycle_id"
| "estimate_point"
>;
export type TBulkOperationsPayload = {
issue_ids: string[];
properties: Partial<TBulkIssueProperties>;
};
export type TIssueDetailWidget =
| "sub-issues"
| "relations"
| "links"
| "attachments";

View File

@@ -35,6 +35,7 @@ export interface IProject {
anchor: string | null;
is_favorite: boolean;
is_member: boolean;
is_time_tracking_enabled: boolean;
logo_props: TLogoProps;
member_role: EUserProjectRoles | null;
members: IProjectMemberLite[];

View File

@@ -202,4 +202,6 @@ export interface IssuePaginationOptions {
before?: string;
after?: string;
groupedBy?: TIssueGroupByOptions;
subGroupedBy?: TIssueGroupByOptions;
orderBy?: TIssueOrderByOptions;
}

View File

@@ -25,9 +25,21 @@ export interface IProjectView {
workspace: string;
logo_props: TLogoProps | undefined;
is_locked: boolean;
anchor?: string;
owned_by: string;
}
export type TPublishViewSettings = {
is_comments_enabled: boolean;
is_reactions_enabled: boolean;
is_votes_enabled: boolean;
};
export type TPublishViewDetails = TPublishViewSettings & {
id: string;
anchor: string;
};
export type TViewFiltersSortKey = "name" | "created_at" | "updated_at";
export type TViewFiltersSortBy = "asc" | "desc";

View File

@@ -1,5 +1,6 @@
import { EUserWorkspaceRoles } from "@/constants/workspace";
import {EUserWorkspaceRoles} from "@/constants/workspace";
import type {
ICycle,
IProjectMember,
IUser,
IUserLite,
@@ -46,7 +47,7 @@ export interface IWorkspaceMemberInvitation {
}
export interface IWorkspaceBulkInviteFormData {
emails: { email: string; role: EUserWorkspaceRoles }[];
emails: {email: string; role: EUserWorkspaceRoles}[];
}
export type Properties = {
@@ -69,6 +70,13 @@ export interface IWorkspaceMember {
id: string;
member: IUserLite;
role: EUserWorkspaceRoles;
created_at?: string;
avatar?: string;
email?: string;
first_name?: string;
last_name?: string;
joining_date?: string;
display_name?: string;
}
export interface IWorkspaceMemberMe {
@@ -190,3 +198,25 @@ export interface IProductUpdateResponse {
eyes: number;
};
}
export interface IWorkspaceActiveCyclesResponse {
count: number;
extra_stats: null;
next_cursor: string;
next_page_results: boolean;
prev_cursor: string;
prev_page_results: boolean;
results: ICycle[];
total_pages: number;
}
export interface IWorkspaceProgressResponse {
completed_issues: number;
total_issues: number;
started_issues: number;
cancelled_issues: number;
unstarted_issues: number;
}
export interface IWorkspaceAnalyticsResponse {
completion_chart: any;
}

View File

@@ -26,7 +26,7 @@ const ToggleSwitch: React.FC<IToggleSwitchProps> = (props) => {
"h-4 w-6": size === "sm",
"h-5 w-8": size === "md",
"bg-custom-primary-100": value,
"cursor-not-allowed": disabled,
"cursor-not-allowed bg-custom-background-80": disabled,
},
className
)}
@@ -43,7 +43,7 @@ const ToggleSwitch: React.FC<IToggleSwitchProps> = (props) => {
"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,
"cursor-not-allowed bg-custom-background-90": disabled,
}
)}
/>

View File

@@ -13,7 +13,7 @@ type Props = {
export const CollapsibleButton: FC<Props> = (props) => {
const { isOpen, title, hideChevron = false, indicatorElement, actionItemElement } = props;
return (
<div className="flex items-center justify-between gap-3 h-12 px-2.5 py-3 border-b border-custom-border-100">
<div className="flex items-center justify-between gap-3 h-12 px-2.5 py-3 border-b border-custom-border-200">
<div className="flex items-center gap-3.5">
<div className="flex items-center gap-3">
{!hideChevron && (

View File

@@ -4,6 +4,7 @@ import { Disclosure, Transition } from "@headlessui/react";
export type TCollapsibleProps = {
title: string | React.ReactNode;
children: React.ReactNode;
className?: string;
buttonClassName?: string;
isOpen?: boolean;
onToggle?: () => void;
@@ -11,7 +12,7 @@ export type TCollapsibleProps = {
};
export const Collapsible: FC<TCollapsibleProps> = (props) => {
const { title, children, buttonClassName, isOpen, onToggle, defaultOpen } = props;
const { title, children, className, buttonClassName, isOpen, onToggle, defaultOpen } = props;
// state
const [localIsOpen, setLocalIsOpen] = useState<boolean>(isOpen || defaultOpen ? true : false);
@@ -31,7 +32,7 @@ export const Collapsible: FC<TCollapsibleProps> = (props) => {
}, [isOpen, onToggle]);
return (
<Disclosure>
<Disclosure as="div" className={className}>
<Disclosure.Button className={buttonClassName} onClick={handleOnClick}>
{title}
</Disclosure.Button>

View File

@@ -137,6 +137,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
onClick={handleMenuButtonClick}
className={customButtonClassName}
tabIndex={customButtonTabIndex}
disabled={disabled}
>
{customButton}
</button>
@@ -172,6 +173,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
} ${buttonClassName}`}
onClick={handleMenuButtonClick}
tabIndex={customButtonTabIndex}
disabled={disabled}
>
{label}
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}

View File

@@ -115,7 +115,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
onClick={toggleDropdown}
>
{label}
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
{!noChevron && !disabled && <ChevronDown className="h-3 w-3 flex-shrink-0" aria-hidden="true" />}
</button>
</Combobox.Button>
)}

View File

@@ -82,11 +82,16 @@ const CustomSelect = (props: ICustomSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 ${
input ? "px-3 py-2 text-sm" : "px-2 py-1 text-xs"
} ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
className={cn(
"flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300",
{
"px-3 py-2 text-sm": input,
"px-2 py-1 text-xs": !input,
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer hover:bg-custom-background-80": !disabled,
},
buttonClassName
)}
onClick={toggleDropdown}
>
{label}

View File

@@ -22,3 +22,5 @@ export * from "./side-panel-icon";
export * from "./transfer-icon";
export * from "./info-icon";
export * from "./dropdown-icon";
export * from "./intake";
export * from "./user-activity-icon";

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const Intake: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 16 16"
className={`${className}`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
{...rest}
>
<path d="M12.1599 3.59961V9.60688L8.04358 12.0796V6.04325L12.1599 3.59961Z" />
<path d="M5.98547 10.8657V4.82938L10.1018 2.38574" />
<path d="M3.89087 9.60695V3.57059L8.00723 1.12695" />
<path d="M1.06909 8.77051V13.3887C1.06909 14.1814 1.71636 14.8287 2.50909 14.8287H13.4909C14.2836 14.8287 14.9309 14.1814 14.9309 13.3887V8.77051" />
</svg>
);

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const UserActivityIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 24 24"
className={`${className} stroke-2`}
stroke="currentColor"
fill="none"
strokeWidth="1.5"
xmlns="http://www.w3.org/2000/svg"
strokeLinecap="round"
strokeLinejoin="round"
{...rest}
>
<path d="M10.5 13C12.9853 13 15 10.9853 15 8.5C15 6.01472 12.9853 4 10.5 4C8.01472 4 6 6.01472 6 8.5C6 10.9853 8.01472 13 10.5 13Z" />
<path d="M13.9062 13.5903C12.8368 13.1001 11.661 12.8876 10.4877 12.9725C9.31437 13.0574 8.18144 13.437 7.19379 14.0761C6.20613 14.7152 5.39567 15.5931 4.83744 16.6286C4.2792 17.6641 3.99124 18.8237 4.0002 20" />
<path d="M21 16.5H19.6L18.2 20L16.8 13L15.4 16.5H14" />
</svg>
);

View File

@@ -22,3 +22,4 @@ export * from "./favorite-star";
export * from "./loader";
export * from "./collapsible";
export * from "./popovers";
export * from "./tables";

View File

@@ -13,7 +13,7 @@ export type TModalVariant = "danger" | "primary";
type Props = {
content: React.ReactNode | string;
handleClose: () => void;
handleSubmit: () => Promise<void>;
handleSubmit: () => void;
hideIcon?: boolean;
isSubmitting: boolean;
isOpen: boolean;

View File

@@ -8,4 +8,6 @@ export enum EModalWidth {
XXL = "sm:max-w-2xl",
XXXL = "sm:max-w-3xl",
XXXXL = "sm:max-w-4xl",
VXL = "sm:max-w-5xl",
VIXL = "sm:max-w-6xl",
}

View File

@@ -11,9 +11,17 @@ type Props = {
isOpen: boolean;
position?: EModalPosition;
width?: EModalWidth;
className?: string;
};
export const ModalCore: React.FC<Props> = (props) => {
const { children, handleClose, isOpen, position = EModalPosition.CENTER, width = EModalWidth.XXL } = props;
const {
children,
handleClose,
isOpen,
position = EModalPosition.CENTER,
width = EModalWidth.XXL,
className = "",
} = props;
return (
<Transition.Root show={isOpen} as={Fragment}>
@@ -44,7 +52,8 @@ export const ModalCore: React.FC<Props> = (props) => {
<Dialog.Panel
className={cn(
"relative transform rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all w-full",
width
width,
className
)}
>
{children}

View File

@@ -12,10 +12,13 @@ export const PopoverMenu = <T,>(props: TPopoverMenu<T>) => {
popperPadding = 0,
buttonClassName = "",
button,
disabled,
panelClassName = "",
data,
popoverClassName = "",
keyExtractor,
render,
popoverButtonRef,
} = props;
return (
@@ -24,10 +27,13 @@ export const PopoverMenu = <T,>(props: TPopoverMenu<T>) => {
popperPadding={popperPadding}
buttonClassName={buttonClassName}
button={button}
disabled={disabled}
panelClassName={cn(
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2 text-xs shadow-custom-shadow-rg focus:outline-none",
panelClassName
)}
popoverClassName={popoverClassName}
popoverButtonRef={popoverButtonRef}
>
<Fragment>
{data.map((item, index) => (

View File

@@ -1,4 +1,4 @@
import React, { Fragment, useState } from "react";
import React, { Fragment, Ref, useState } from "react";
import { usePopper } from "react-popper";
import { Popover as HeadlessReactPopover, Transition } from "@headlessui/react";
// helpers
@@ -12,12 +12,15 @@ export const Popover = (props: TPopover) => {
popperPosition = "bottom-end",
popperPadding = 0,
buttonClassName = "",
popoverClassName = "",
button,
disabled = false,
panelClassName = "",
children,
popoverButtonRef,
} = props;
// states
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// react-popper derived values
@@ -34,21 +37,22 @@ export const Popover = (props: TPopover) => {
});
return (
<HeadlessReactPopover className="relative flex h-full w-full items-center justify-center">
<HeadlessReactPopover.Button ref={setReferenceElement} className="flex justify-center items-center">
{button ? (
button
) : (
<div
className={cn(
"flex justify-center items-center text-base h-6 w-6 rounded transition-all bg-custom-background-90 hover:bg-custom-background-80",
buttonClassName
)}
>
<EllipsisVertical className="h-3 w-3" />
</div>
)}
</HeadlessReactPopover.Button>
<HeadlessReactPopover className={cn("relative flex h-full w-full items-center justify-center", popoverClassName)}>
<div ref={setReferenceElement} className="w-full">
<HeadlessReactPopover.Button
ref={popoverButtonRef as Ref<HTMLButtonElement>}
className={cn(
{
"flex justify-center items-center text-base h-6 w-6 rounded transition-all bg-custom-background-90 hover:bg-custom-background-80":
!button,
},
buttonClassName
)}
disabled={disabled}
>
{button ? button : <EllipsisVertical className="h-3 w-3" />}
</HeadlessReactPopover.Button>
</div>
<Transition
as={Fragment}

View File

@@ -1,10 +1,11 @@
import { ReactNode } from "react";
import { MutableRefObject, ReactNode } from "react";
import { Placement } from "@popperjs/core";
export type TPopoverButtonDefaultOptions = {
// button and button styling
button?: ReactNode;
buttonClassName?: string;
disabled?: boolean;
};
export type TPopoverDefaultOptions = TPopoverButtonDefaultOptions & {
@@ -13,6 +14,8 @@ export type TPopoverDefaultOptions = TPopoverButtonDefaultOptions & {
popperPadding?: number | undefined;
// panel styling
panelClassName?: string;
popoverClassName?: string;
popoverButtonRef?: MutableRefObject<HTMLButtonElement | null>;
};
export type TPopover = TPopoverDefaultOptions & {

View File

@@ -0,0 +1 @@
export * from "./table";

View File

@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Table } from "./table";
const meta: Meta<typeof Table> = {
title: "Table",
component: Table,
};
export default meta;
// types
type TTableData = {
id: string;
name: string;
age: number;
};
type Story = StoryObj<typeof Table<TTableData>>;
// data
const tableData: TTableData[] = [
{ id: "1", name: "Ernest", age: 25 },
{ id: "2", name: "Ann", age: 30 },
{ id: "3", name: "Russell", age: 35 },
{ id: "4", name: "Verna", age: 40 },
];
const tableColumns = [
{
key: "id",
content: "Id",
tdRender: (rowData: TTableData) => <span>{rowData.id}</span>,
},
{
key: "name",
content: "Name",
tdRender: (rowData: TTableData) => <span>{rowData.name}</span>,
},
{
key: "age",
content: "Age",
tdRender: (rowData: TTableData) => <span>{rowData.age}</span>,
},
];
// stories
export const Default: Story = {
args: {
data: tableData,
columns: tableColumns,
keyExtractor: (rowData) => rowData.id,
tableClassName: "bg-gray-100",
tHeadClassName: "bg-gray-200",
tHeadTrClassName: "text-gray-600 text-left text-sm font-medium",
thClassName: "font-medium",
tBodyClassName: "bg-gray-100",
tBodyTrClassName: "text-gray-600",
tdClassName: "font-medium",
},
};

View File

@@ -0,0 +1,48 @@
import React from "react";
// helpers
import { cn } from "../../helpers";
// types
import { TTableData } from "./types";
export const Table = <T,>(props: TTableData<T>) => {
const {
data,
columns,
keyExtractor,
tableClassName = "",
tHeadClassName = "",
tHeadTrClassName = "",
thClassName = "",
tBodyClassName = "",
tBodyTrClassName = "",
tdClassName = "",
} = props;
return (
<table className={cn("table-auto w-full overflow-hidden whitespace-nowrap", tableClassName)}>
<thead className={cn("divide-y divide-custom-border-200", tHeadClassName)}>
<tr className={cn("divide-x divide-custom-border-200 text-sm text-custom-text-100", tHeadTrClassName)}>
{columns.map((column) => (
<th key={column.key} className={cn("px-2.5 py-2", thClassName)}>
{(column?.thRender && column?.thRender()) || column.content}
</th>
))}
</tr>
</thead>
<tbody className={cn("divide-y divide-custom-border-200", tBodyClassName)}>
{data.map((item) => (
<tr
key={keyExtractor(item)}
className={cn("divide-x divide-custom-border-200 text-sm text-custom-text-200", tBodyTrClassName)}
>
{columns.map((column) => (
<td key={`${column.key}-${keyExtractor(item)}`} className={cn("px-2.5 py-2", tdClassName)}>
{column.tdRender(item)}
</td>
))}
</tr>
))}
</tbody>
</table>
);
};

View File

@@ -0,0 +1,20 @@
export type TTableColumn<T> = {
key: string;
content: string;
thRender?: () => JSX.Element;
tdRender: (rowData: T) => JSX.Element;
};
export type TTableData<T> = {
data: T[];
columns: TTableColumn<T>[];
keyExtractor: (rowData: T) => string;
// classNames
tableClassName?: string;
tHeadClassName?: string;
tHeadTrClassName?: string;
thClassName?: string;
tBodyClassName?: string;
tBodyTrClassName?: string;
tdClassName?: string;
};

View File

@@ -6,6 +6,7 @@ import useSWR from "swr";
// components
import { LogoSpinner } from "@/components/common";
import { IssuesNavbarRoot } from "@/components/issues";
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
// hooks
import { useIssueFilter, usePublish, usePublishList } from "@/hooks/store";
// assets
@@ -27,7 +28,7 @@ const IssuesLayout = observer((props: Props) => {
const publishSettings = usePublish(anchor);
const { updateLayoutOptions } = useIssueFilter();
// fetch publish settings
useSWR(
const { error } = useSWR(
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
anchor
? async () => {
@@ -45,7 +46,9 @@ const IssuesLayout = observer((props: Props) => {
: null
);
if (!publishSettings) return <LogoSpinner />;
if (!publishSettings && !error) return <LogoSpinner />;
if (error) return <SomethingWentWrongError />;
return (
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">

View File

@@ -2,10 +2,11 @@
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import useSWR from "swr";
// components
import { IssuesLayoutsRoot } from "@/components/issues";
// hooks
import { usePublish } from "@/hooks/store";
import { usePublish, useLabel, useStates } from "@/hooks/store";
type Props = {
params: {
@@ -19,6 +20,12 @@ const IssuesPage = observer((props: Props) => {
// params
const searchParams = useSearchParams();
const peekId = searchParams.get("peekId") || undefined;
// store
const { fetchStates } = useStates();
const { fetchLabels } = useLabel();
useSWR(anchor ? `PUBLIC_STATES_${anchor}` : null, anchor ? () => fetchStates(anchor) : null);
useSWR(anchor ? `PUBLIC_LABELS_${anchor}` : null, anchor ? () => fetchLabels(anchor) : null);
const publishSettings = usePublish(anchor);

View File

@@ -0,0 +1,73 @@
"use client";
import { observer } from "mobx-react";
import Image from "next/image";
import useSWR from "swr";
// components
import { LogoSpinner } from "@/components/common";
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
// hooks
import { usePublish, usePublishList } from "@/hooks/store";
// Plane web
import { ViewNavbarRoot } from "@/plane-web/components/navbar";
import { useView } from "@/plane-web/hooks/store";
// assets
import planeLogo from "@/public/plane-logo.svg";
type Props = {
children: React.ReactNode;
params: {
anchor: string;
};
};
const IssuesLayout = observer((props: Props) => {
const { children, params } = props;
// params
const { anchor } = params;
// store hooks
const { fetchPublishSettings } = usePublishList();
const { viewData, fetchViewDetails } = useView();
const publishSettings = usePublish(anchor);
// fetch publish settings && view details
const { error } = useSWR(
anchor ? `PUBLISHED_VIEW_SETTINGS_${anchor}` : null,
anchor
? async () => {
const promises = [];
promises.push(fetchPublishSettings(anchor));
promises.push(fetchViewDetails(anchor));
await Promise.all(promises);
}
: null
);
if (error) return <SomethingWentWrongError />;
if (!publishSettings || !viewData) return <LogoSpinner />;
return (
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
<ViewNavbarRoot publishSettings={publishSettings} />
</div>
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
<a
href="https://plane.so"
className="fixed bottom-2.5 right-5 !z-[999999] flex items-center gap-1 rounded border border-custom-border-200 bg-custom-background-100 px-2 py-1 shadow-custom-shadow-2xs"
target="_blank"
rel="noreferrer noopener"
>
<div className="relative grid h-6 w-6 place-items-center">
<Image src={planeLogo} alt="Plane logo" className="h-6 w-6" height="24" width="24" />
</div>
<div className="text-xs">
Powered by <span className="font-semibold">Plane Publish</span>
</div>
</a>
</div>
);
});
export default IssuesLayout;

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