Compare commits

..
Author SHA1 Message Date
Henit ChobisaandGitHub cd9cebc7af chore: modified dockerfiles for space and web (#1987) 2023-08-28 13:37:45 +05:30
sriramveeraghanta b77ddf9c95 eslint and tsconfig fixes 2023-08-23 16:10:36 +05:30
sriram veeraghanta 4133798a81 docker fixes 2023-08-23 15:30:18 +05:30
sriram veeraghanta 761aefa73b merge conflicts resolved 2023-08-23 14:43:20 +05:30
561fb9815b chore: update export services icons (#1946)
Co-authored-by: Aaryan Khandelwal <aaryan610@Aaryans-MacBook-Pro.local>
2023-08-23 12:29:59 +05:30
Bavisetti NarayanandGitHub 2cc67f6498 fix: date validation in cycle and module (#1945) 2023-08-23 12:17:20 +05:30
sriram veeraghanta fa403ef4ae dev: remove turbo configs and setup independent packages 2023-08-22 20:15:46 +05:30
NikhilandGitHub eee6658cc2 dev: deploy docker containers (#1939) 2023-08-22 19:33:29 +05:30
68b438ab1a fix: aws region changed for exporter (#1933)
Co-authored-by: Bavisetti Narayan <narayan@Bavisettis-MacBook-Pro.local>
2023-08-22 13:18:15 +05:30
sriram veeraghanta 3ce0aa8ebc restructring 2023-08-22 13:00:23 +05:30
b406a70e72 fix: access environment variables is changed in services (#1930)
Co-authored-by: Sainath <sainath@Sainaths-MacBook-Pro.local>
2023-08-22 01:13:51 +05:30
Aaryan KhandelwalandGitHub b02417120b chore: hide new issue button from my subscribed issues page (#1927) 2023-08-21 20:50:17 +05:30
Aaryan KhandelwalandGitHub d040394826 fix: create project button not appearing on the sidebar (#1926) 2023-08-21 20:44:51 +05:30
NikhilandGitHub f7682c57ba fix: plane space start up command (#1925) 2023-08-21 20:25:12 +05:30
guru_sainathandGitHub 9bb6254515 chore: updated default api base_url (#1922) 2023-08-21 18:17:32 +05:30
Aaryan KhandelwalandGitHub ae052f1890 chore: update restricted workspace slugs (#1920) 2023-08-21 18:13:08 +05:30
cfc7049343 Dockerrizing space project (#1921)
* chore: Added Dockerfile for Space Project

* fix: next js config to standalone mode

* fix: workedaround build error with rename 404 page

* chore: modified dockerfile with new conventions

* chore: modified dockercompose file for new plane-deploy

* fix: handled ts errors with possibly undefined states

* chore: updated main dockerfile with plane-deploy

* feat: included space project to start.sh

* chore: modified space project port while running in production

* chore: restored changes inside space project

* chore: added ngnix config for space project running :4000

* fix: Updated docker-compose files

* chore: added space url for ngnix config

* chore: Updated ngnix template

* chore: updated space url in compose hub file

* dev: updated dockerfile.space and start and replace script

* dev: equate hub and build docker files

* dev: revert workspace space page

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-08-21 18:12:41 +05:30
guru_sainathandGitHub 41e55dff85 fix: build error for 404 and search params null check (#1919) 2023-08-21 12:43:41 +05:30
Aaryan KhandelwalandGitHub 0bccb63a9f fix: module start and target date validations (#1914) 2023-08-21 11:46:02 +05:30
Aaryan KhandelwalandGitHub 2eb956e97e refactor: project and workspace delete modals (#1915) 2023-08-21 11:44:59 +05:30
M. PalanikannanandGitHub d470adf262 fix: Image resize, Link selector in Modals, Delete/ sync images and so much more (#1896)
* added image-resizing support

* link form removed

* updated image upload logic and 35% default width on upload

* removed shadow, added alert if not saved and ts errors

* prevent enter key on Link selector to trigger modal submit

* added workspace slug to all tiptap instances

* added delete plugin with loading indicator

* added better syncing of "Saved" state of editor and Image uploads

* removed redundant description_html check
2023-08-19 18:58:54 +05:30
Aaryan KhandelwalandGitHub cebc8bdc8d fix: context menu dynamic positioning, multiple context menus opening issue (#1913)
* fix: context menu positioning

* fix: close already opened context menu on outside click
2023-08-19 18:50:12 +05:30
Anmol Singh BhatiaandGitHub 64b5ba196f style: responsive empty state for profile stats (#1911) 2023-08-18 20:18:03 +05:30
Anmol Singh BhatiaandGitHub 8d5018318d chore: add favorite project from sidebar (#1909) 2023-08-18 18:42:50 +05:30
Anmol Singh BhatiaandGitHub 0fbdc0b157 style: consistent ui for create update issue modal (#1907) 2023-08-18 18:42:04 +05:30
Bavisetti NarayanandGitHub 2f39181eb7 fix: priority ordering (#1908) 2023-08-18 18:27:29 +05:30
Dakshesh JainandGitHub d825dc5579 style: showing first name for bot profile (#1894) 2023-08-18 17:15:20 +05:30
Aaryan KhandelwalandGitHub 1f8117c987 fix: dashboard upcoming issues list (#1904) 2023-08-18 17:10:12 +05:30
Bavisetti NarayanandGitHub 125e9090ea chore: module link model (#1905)
* chore: module link model

* chore: added migration
2023-08-18 15:50:48 +05:30
Bavisetti NarayanandGitHub 02ac4cee22 chore: renamed target date to start date (#1902) 2023-08-18 15:25:42 +05:30
Anmol Singh BhatiaandGitHub 93164755e2 style: analytics stats empty state (#1903) 2023-08-18 15:15:33 +05:30
Aaryan KhandelwalandGitHub 6344f6f562 chore: set order by to manual on gantt chart (#1886) 2023-08-18 15:12:12 +05:30
Aaryan KhandelwalandGitHub b67e30fd9c chore: analytics start date property for x-axis and group (#1888) 2023-08-18 15:11:25 +05:30
Anmol Singh BhatiaandGitHub 93fec2c678 fix: completed cycle validation , style: assignee count alignment fix (#1901)
* style: assignee count alignment fix

* fix: completed cycle validation
2023-08-18 14:23:13 +05:30
Anmol Singh BhatiaandGitHub c3c6ba9e34 chore: link edit functionality (#1895) 2023-08-18 12:03:31 +05:30
Anmol Singh BhatiaandGitHub d74ec7bda9 fix: ui improvement and bug fixes (#1883) 2023-08-18 12:01:51 +05:30
Henit ChobisaandGitHub e593a8d4bd chore: Edited Setup Script to take TipTap Auth Token and Generate .npmrc (#1897) 2023-08-18 11:47:58 +05:30
guru_sainathandGitHub abb8782c44 fix: handled default view on plane deploy (#1893)
* fix: handled default view on plane deploy

* fix: handled default view on refresh
2023-08-17 14:24:33 +05:30
guru_sainathandGitHub 0afd72db95 fix: updated theming in workspace preferences (#1890) 2023-08-16 20:35:17 +05:30
guru_sainathandGitHub 65295f6c6f chore: updating the theme using MobX from command k (#1879)
* chore: updating the theme using mobx from command k

* feat: Showing the project published status in the app header

* dev: updated validation and redirection the project publish modal and added redirection on the app header
2023-08-16 18:26:36 +05:30
Aaryan KhandelwalandGitHub 5b6b43fb83 fix: quick action buttons: (#1884) 2023-08-16 18:25:11 +05:30
Dakshesh JainandGitHub f8497125db style: fixed display name coming twice on profile page (#1889) 2023-08-16 18:18:57 +05:30
Bavisetti NarayanandGitHub b24622e5ef fix: validation for issue activity description (#1887) 2023-08-16 17:12:09 +05:30
guru_sainathandGitHub 10dface85d chore: updated error pages 404 and project-not-found in plane deploy (#1885)
* dev: custom error messages.

* dev: updated next version in yarn.lock

* dev: updated project-not-published icon
2023-08-16 17:05:40 +05:30
Aditi PatelandGitHub 2b6debaa3e Updated setup document to include tiptap pro install (#1871) 2023-08-16 14:42:37 +05:30
1750ba344b fix: my issue duplication (#1882)
Co-authored-by: Plane Team <planeteam@srirams-Mac-mini.local>
2023-08-16 14:40:10 +05:30
NikhilandGitHub 550473bb02 Merge pull request #1876 from makeplane/stage/merge-fixes
Promote: Develop to Stage Release
2023-08-16 13:18:26 +05:30
sriramveeraghanta fde978861c Merge branch 'develop' of github.com:makeplane/plane into stage/merge-fixes 2023-08-16 13:17:21 +05:30
guru_sainathandGitHub f44d142f2c chore: tip-tap editor update in workspace user activity (#1877) 2023-08-16 13:16:20 +05:30
guru_sainathandGitHub 1ded8f486f chore: updated meta tags for project issues (#1875)
* dev: updated meta tags for project issues

* dev: updated project description in meta tags in plane deploy.
2023-08-16 13:15:57 +05:30
2c43a15515 chore: added new filed in serializer (#1874)
Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-16 13:10:03 +05:30
Dakshesh JainandGitHub 0979acc1a4 fix: notification card not redirecting to archive issue detail for archived issue (#1861) 2023-08-16 13:09:56 +05:30
sriramveeraghanta 9003c58d89 merge conflicts resolved 2023-08-16 13:00:58 +05:30
NikhilandGitHub 55e2f00ffe fix: members list filtering for workspace and projects (#1872) 2023-08-16 12:21:56 +05:30
NikhilandGitHub 08382f88b4 chore: updated migration files for 0.11 (#1851) 2023-08-16 10:37:22 +05:30
72419447ec fix: added slug in filename for export issues (#1870)
* chore: file name changed for exported issues

* fix: added slug in filename for export issues

---------

Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-16 09:39:17 +05:30
NikhilandGitHub b554087b1f chore: deploy board status for project (#1866) 2023-08-16 01:00:22 +05:30
07717e9a93 chore: file name changed for exported issues (#1865)
Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-15 19:49:17 +05:30
NikhilandGitHub df46a45afc fix: analytics export (#1862)
* fix: analytics export

* dev: export analytics assignee indexing

* dev: total counts
2023-08-15 15:48:22 +05:30
e1ae0d3b56 feat : Tiptap integration (#1832)
* remirror instances commented out to avoid prosemirror conflicts

* styles migrated for remirror to tiptap transition

* added bubblemenu support with extensions

* fixed css for task lists and code with syntax highlighting

* added support for slash command

* fixed bubble menu to match styles and added better seperation in UI

* saving with debounce logic added and it's stored in backend

* added migration support by updating to html

* Image uploads done

* improved file structure and delete image function implemented

* Integrated tiptap with Issue Modal

* added additional props and Tiptap Integration with Comments

* added tiptap integration with user activity feeds

* added ref control support and bubble menu support for readonly editor

* added tiptap support for plane pages

* added tiptap support to gpt assistant modal (yet to be tested)

* removed remirror instances and cleaned up code

* improved code structure for extracting props in Tiptap

* fixing ts errors for next build

* fixing node ts error for Horizontal Rule

* added ts fix for node types

* temp fix

* temp fix

* added min height for issue description in modal

* added resolutions to prosemirror-model version

* trying pnpm overrides

* explicitly added prosemirror deps

* bugfixes

* removed extra gap at the top and moved saved indicator to the bottom

* fix: slash command scroll position

* chore: update custom css variables

* matched theme colours

* fixed gpt-assistant modal

* updated yarn lock

* added debounced updates for the title and removed saved state after timeout

* added css animations for saved state

* build fixes and remove remirror instances

* minor commenting fixes

---------

Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-08-15 15:04:46 +05:30
guru_sainathandGitHub daa8f7d79b feat / public deploy settings workflow (#1863)
* Feat: Implemented project publish settings

* dev: updated the env dependancy in turbo and enabling the publish access to admin
2023-08-14 19:18:38 +05:30
NikhilandGitHub d2cdaaccb9 fix: slack queryset (#1860) 2023-08-14 18:53:09 +05:30
Anmol Singh BhatiaandGitHub 6774eddb66 style: sidebar select date width (#1859) 2023-08-14 18:10:21 +05:30
Dakshesh JainandGitHub 8ccc1b3fcc fix: gray background on png image (#1856) 2023-08-14 16:23:10 +05:30
Anmol Singh BhatiaandGitHub 5e76e03a55 style: profile activity loader (#1858) 2023-08-14 16:20:53 +05:30
srinivas pendemandGitHub 77fb50faa4 fix: route fix in imports and exports (#1857) 2023-08-14 16:19:13 +05:30
5ddfee12bc fix: changed the display of date format (#1855)
Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-14 16:10:57 +05:30
Aaryan KhandelwalandGitHub f7a596c113 fix: gantt chart block left drag flicker (#1854)
* fix: left drag flicker

* fix: opposite side manual scroll
2023-08-14 14:27:02 +05:30
Dakshesh JainandGitHub f73239be92 refactor: using webp image format instead of svg (#1852) 2023-08-14 13:26:16 +05:30
Anmol Singh BhatiaandGitHub dc2438b2d3 style: image upload modal aspect ratio (#1853) 2023-08-14 13:06:00 +05:30
srinivas pendemGitHubsrinivaspendem <you@example.comsrinivaspendem2612@gmail.com>
ddd3301d17 feat: csv, json and, xlsx exporter (#1840)
* feat : csv, jason and, xlxs exporter

* handeling the export fail

* adding expired state to exports

* typo update

* header change

* improvement: added validation for the expired date

---------

Co-authored-by: srinivaspendem <you@example.comsrinivaspendem2612@gmail.com>
2023-08-14 11:44:17 +05:30
Vamsi KuramaandGitHub 816f00d956 Merge pull request #1848 from ryota-murakami/fix/license 2023-08-12 17:12:14 +05:30
ryota-murakami 70e2509d52 package.json; fix license 2023-08-12 20:30:01 +09:00
1a9faa025a fix: export issues in CSV, JSON and XLSX (#1794)
* fix: file name change

* feat: added xml json and csv export

* chore: added openpyxl package

* fix: added initiated_by field

* fix: added initiated by details

* dev: refactoring

* fix: rendering assignee name and labels in sheet

* fix: handeled exception in label

* feat: implemented link expiration scheduler(8 days)

* fix: removed the expired field

---------

Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-08-11 20:09:52 +05:30
NikhilandGitHub feba1cc4d0 feat: project public boards (#1772)
* feat: project public boards

* dev: public issue and comment reactions

* dev: public issue comments

* dev: public comments

* dev: inbox for public boards

* dev: inbox issues for public board

* dev: public inbox issue

* dev: migrations

* dev: update api endpoints

* dev: project boards and views

* dev: state and label details

* dev: public issue voting

* dev: issue voting

* dev: workspace details

* dev: project icon and emoji
2023-08-11 19:27:44 +05:30
Anmol Singh BhatiaandGitHub 079a5b28d8 style: custom theming color picker position (#1846) 2023-08-11 19:17:59 +05:30
Aaryan KhandelwalandGitHub c6eea9c7a9 fix: empty groups not appearing in the kanban view (#1843) 2023-08-11 19:17:02 +05:30
Anmol Singh BhatiaandGitHub 5f5790ebf9 chore: start date option added in spreadsheet view (#1824) 2023-08-11 19:14:54 +05:30
Anmol Singh BhatiaandGitHub a3d99100ee fix: sub issue progress indicator fix (#1847) 2023-08-11 19:05:13 +05:30
Anmol Singh BhatiaandGitHub ac6d2b0139 chore: updated user permission for project and workspace settings (#1807)
* fix: updated user permission for project and workspace settings

* chore: workspace delete modal fix
2023-08-11 18:29:24 +05:30
Dakshesh JainandGitHub 7becec4ee9 feat: randomize color on label create (#1839)
fix: create label state being persisted after edit label
2023-08-11 17:42:47 +05:30
guru_sainathandGitHub cd5e5b96da feat: Mobx integration, List and Kanban boards implementation in plane space (#1844)
* feat: init mobx and issue filter

* feat: Implemented list and kanban views in plane space and integrated mobx.

* feat: updated store type check
2023-08-11 17:18:33 +05:30
Dakshesh JainandGitHub ad4cdcc512 fix: cmdk modal not closing when choosing an option (#1833) 2023-08-11 17:16:37 +05:30
NikhilandGitHub 6617049983 fix: sub issue endpoint for state distribution (#1845) 2023-08-11 16:38:03 +05:30
Dakshesh JainandGitHub abec46a725 fix: cmdk not changing theme if active theme is custom-theme (#1842) 2023-08-11 16:16:20 +05:30
NikhilandGitHub 88e987b902 feat: cycle and module sort order (#1841)
* dev: cycle and module ordering

* dev: sort order for smallest
2023-08-11 16:03:15 +05:30
Aaryan KhandelwalandGitHub 785a6e8871 chore: gantt chart resizable blocks, y-axis drag and drop (#1810)
* chore: gantt chart resizable blocks

* chore: right scroll added

* chore: left scroll added

* fix: build errors

* chore: remove unnecessary console logs

* chore: add block type and remove info toggle

* feat: gantt chart blocks y-axis drag and drop

* chore: disable drag flag

* fix: y-axis drag mutation

* fix: scroll container undefined error

* fix: negative scroll

* fix: negative scroll

* style: blocks tooltip consistency
2023-08-11 15:59:13 +05:30
Anmol Singh BhatiaandGitHub 762ef422ca chore: project search added in project list page (#1837) 2023-08-11 15:57:11 +05:30
Anmol Singh BhatiaandGitHub e2b5657c3e chore: profile cover photo (#1836) 2023-08-11 15:56:26 +05:30
Anmol Singh BhatiaandGitHub dbb53a663e style: sidebar ui improvement (#1816)
* style: sidebar ui improvement

* style: sidebar menu spacing

* style: sidebar consistent spacing

* style: notification improvement

* style: remove from favorite filled icon added

* chore: settings option added in sidebar context menu

* chore: update delete project option visibility for sidebar

* style: sidebar project list display border top on scroll
2023-08-11 15:53:10 +05:30
Anmol Singh BhatiaandGitHub 5c964d144a style: ui improvement (#1806)
* style: kanban board theming

* style: assignee ui revamp

* style: kanban board header, label , priority , state and avatar ui revamp

* style: kanban card state dropdown

* style: sidebar profile dropdown

* style: sidebar dropdown icon

* style: sidebar workspace dropdown

* style: assignee select component

* fix: state select

* style: consistent app header button
2023-08-11 15:50:05 +05:30
Anmol Singh BhatiaandGitHub 289e81d6eb chore: issue activity user redirection added (#1805)
* feat: issue activity user redirection added

* feat: analytics page user redirection
2023-08-11 15:48:52 +05:30
NikhilandGitHub def10af1e2 fix: issue date filtering (#1834) 2023-08-11 12:48:30 +05:30
NikhilandGitHub edaeae1b69 chore: sort order for cycle and modules (#1835) 2023-08-11 12:48:02 +05:30
NikhilandGitHub e06ee25800 fix: project states create (#1830) 2023-08-11 12:46:50 +05:30
NikhilandGitHub be86a7d38e feat: project member role (#1828) 2023-08-11 12:46:29 +05:30
NikhilandGitHub 0a1483c482 chore: issue list date filters and properties (#1820)
* dev: start date and target date validation and filter for null dated issues

* dev: remove print logs

* dev: issue property dates
2023-08-11 12:45:51 +05:30
NikhilandGitHub 11abd3cadf fix: user id for default analytics (#1808) 2023-08-11 12:45:04 +05:30
NikhilandGitHub 085cd1960e fix: project members endpoint email (#1804)
* fix: project members endpoint email

* dev: cycle and module assignee display name
2023-08-11 12:44:20 +05:30
NikhilandGitHub 2769a73898 remove: auto start date configuration (#1799) 2023-08-11 12:43:43 +05:30
NikhilandGitHub 8373f20944 fix: issues n plus 1 (#1785) 2023-08-11 12:43:00 +05:30
Dakshesh JainandGitHub f562fcd466 refactor: both delete or backspace can be used to open modal (#1829)
* refactor: both delete or backspace can be used to open modal

* fix: 'Bulk Issue Delete' modal will open on single click
2023-08-10 16:02:10 +05:30
Dakshesh JainandGitHub 8c8668a3e6 refactor: not making calls to member's endpoint to get membership status (#1831)
* refactor: not making call to members endpoint to get membership status

* refactor: type for IProject
2023-08-10 15:39:32 +05:30
Dakshesh JainandGitHub 9ce85cdf21 refactor: if/else conditions (#1822)
* refactor: shortcuts if/else

* fix: merge conflict
2023-08-10 13:12:17 +05:30
Dakshesh JainandGitHub 1c6cdb8328 refactor: added validation for display_name (#1823) 2023-08-10 13:05:03 +05:30
guru_sainathandGitHub 005b42cb8d dev: Updating themening worfkflow (#1827) 2023-08-10 13:03:42 +05:30
Dakshesh JainandGitHub be062ccd34 fix: import user using endpoint with email (#1819) 2023-08-09 17:49:25 +05:30
Dakshesh JainandGitHub c9d0c5353d style: displaying email in members page depending on user role (#1817)
* style: displaying email in members page depending on user role

* refactor: added null safety
2023-08-09 17:23:57 +05:30
Dakshesh JainandGitHub 2a6eb5fe23 style: option to edit display name in profile page (#1818) 2023-08-09 16:44:35 +05:30
Aaryan KhandelwalandGitHub d9ccce41bc chore: profile and my issues page start date option (#1815)
* chore: profile and my issues page start date option

* fix: properties order in my profile view options
2023-08-09 15:46:47 +05:30
Anmol Singh BhatiaandGitHub 3db69a3a71 style: profile empty state (#1811)
* style: profile empty state

* style: priority empty state padding
2023-08-09 15:45:20 +05:30
Dakshesh JainandGitHub faa50b0bbb fix: analytics showing user id instead of display name (#1812)
* fix: replacing first, last name and email with display name

* fix: different endpoint for workspace & project member

* fix: falling back to email if display_name doesn't exist

* fix: analytics showing user id instead of display name
2023-08-09 15:20:29 +05:30
Dakshesh JainandGitHub 1991e09035 refactor: removed escape keydown listener (#1814) 2023-08-09 15:18:26 +05:30
Aaryan KhandelwalandGitHub 4fcd081d27 chore: select start date option for issue (#1813) 2023-08-09 15:17:32 +05:30
5f1209f1db chore: empty state for multi-level dropdown (#1802)
* fix :label filter should show something if there is no label #1779 (#1795)

* style: children empty state

---------

Co-authored-by: Pankaj Chotaliya <34762752+pankajvc@users.noreply.github.com>
2023-08-08 14:13:26 +05:30
Aaryan KhandelwalandGitHub 88e5a05253 chore: subscribed by me tab on my issues page (#1800)
* chore: add subscribed by me tab in my issues

* chore: update tab titles

* fix: build error
2023-08-08 13:15:25 +05:30
Dakshesh JainandGitHub 981acc81c1 fix: replaced first name, last name or email to display name (#1796)
* fix: replacing first, last name and email with display name

* fix: different endpoint for workspace & project member

* fix: falling back to email if display_name doesn't exist
2023-08-08 13:01:43 +05:30
NikhilandGitHub cf306ee605 feat: user display name (#1179)
* feat: user display name for the entire system

* feat: update issue activity to remove emails

* dev: update to display name wherever assignees__email and member__email

* dev: update display names on issue activity and the user script

* dev: update display_name function to generate display_name from email

* dev: add email for test purpose

* dev: set default display name for the user

* dev: add migration script and default value

* dev: annotate with assignees_id

* dev: return assignees id

* dev: display name for the profile

* dev: project members endpoint

* dev: url update

* dev: trailing /

* dev: update workspace member serializer

* fix: activity for assignees
2023-08-08 12:59:04 +05:30
guru_sainathandGitHub 9df0ba6e3a feat: initiated plane space (#1801) 2023-08-08 12:55:42 +05:30
guru_sainathandGitHub b6744dcd29 Chore: mobx setup and app sidebar and theme management (#1798)
* dev: Mobx integration for app sidebar and custom theme

* dev: Handled edge case and conditional rendering for mobx store
2023-08-08 12:50:27 +05:30
DevMinerandGitHub a164dfd532 chore(frontend): add sharp (#1451) 2023-08-07 15:52:08 +05:30
9b4aebc385 promote: develop to stage-release v0.10-patch (#1783)
* chore: show message if dragging unjoined project (#1763)

* fix: invalid project selection in create issue modal (#1766)

* style: sidebar project list improvement (#1767)

* fix: comment reaction mutation (#1768)

* fix: user profiles n plus 1 (#1765)

* fix: bulk issue import (#1773)

* style: profile activity (#1771)

* style: profile activity comment log styling

* chore: profile feed activity refactor

* style: sidebar project list

* chore: add non existing states for project entities (#1770)

* fix: notification read status being toggled when click on link (#1769)

* fix: custom theme persisting after signing out (#1780)

* fix: custom theme persistence

* chore: remove console logs

* fix: build error

* fix: change theme from command k

---------

Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
2023-08-03 15:28:22 +05:30
NikhilandGitHub 9828d2332a Merge pull request #1761 from makeplane/develop
promote: develop to stage-release
2023-08-01 22:07:03 +05:30
NikhilandGitHub 9f69fe6060 Merge pull request #1750 from makeplane/develop
promote: develop to stage-release
2023-08-01 19:35:54 +05:30
Aaryan KhandelwalandGitHub d9339b8f8e Merge pull request #1729 from makeplane/develop
promote: develop to stage-release
2023-08-01 15:46:56 +05:30
971 changed files with 25053 additions and 9499 deletions
+2
View File
@@ -21,6 +21,8 @@ NEXT_PUBLIC_TRACK_EVENTS=0
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# public boards deploy url
NEXT_PUBLIC_DEPLOY_URL=""
# Backend
# Debug value for api server use it as 0 for production use
+1 -1
View File
@@ -4,7 +4,7 @@ module.exports = {
extends: ["custom"],
settings: {
next: {
rootDir: ["apps/*"],
rootDir: ["web/", "deploy/"],
},
},
};
+3 -1
View File
@@ -70,4 +70,6 @@ package-lock.json
# lock files
package-lock.json
pnpm-lock.yaml
pnpm-workspace.yaml
pnpm-workspace.yaml
.npmrc
+10 -3
View File
@@ -5,9 +5,11 @@ WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
RUN apk add tree
COPY . .
RUN turbo prune --scope=app --docker
RUN turbo prune --scope=app --scope=plane-deploy --docker
CMD tree -I node_modules/
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
@@ -21,14 +23,14 @@ COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
# Build the project
# # Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build --filter=app
RUN yarn turbo run build
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
@@ -96,11 +98,16 @@ RUN adduser --system --uid 1001 captain
COPY --from=installer /app/apps/app/next.config.js .
COPY --from=installer /app/apps/app/package.json .
COPY --from=installer /app/apps/space/next.config.js .
COPY --from=installer /app/apps/space/package.json .
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next
ENV NEXT_TELEMETRY_DISABLED 1
# RUN rm /etc/nginx/conf.d/default.conf
+10
View File
@@ -61,6 +61,16 @@ chmod +x setup.sh
> If running in a cloud env replace localhost with public facing IP address of the VM
- Setup Tiptap Pro
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
```
@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
```
- Run Docker compose up
```bash
+4 -4
View File
@@ -1,4 +1,4 @@
import os, sys
import os, sys, random, string
import uuid
sys.path.append("/code")
@@ -19,9 +19,9 @@ def populate():
user = User.objects.create(email=default_email, username=uuid.uuid4().hex)
user.set_password(default_password)
user.save()
print("User created")
print("Success")
print(f"User created with an email: {default_email}")
else:
print(f"User already exists with the default email: {default_email}")
if __name__ == "__main__":
+7 -6
View File
@@ -1,10 +1,5 @@
from .base import BaseSerializer
from .people import (
ChangePasswordSerializer,
ResetPasswordSerializer,
TokenSerializer,
)
from .user import UserSerializer, UserLiteSerializer
from .user import UserSerializer, UserLiteSerializer, ChangePasswordSerializer, ResetPasswordSerializer, UserAdminLiteSerializer
from .workspace import (
WorkSpaceSerializer,
WorkSpaceMemberSerializer,
@@ -12,6 +7,7 @@ from .workspace import (
WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer,
WorkspaceThemeSerializer,
WorkspaceMemberAdminSerializer,
)
from .project import (
ProjectSerializer,
@@ -22,6 +18,8 @@ from .project import (
ProjectFavoriteSerializer,
ProjectLiteSerializer,
ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
@@ -45,6 +43,7 @@ from .issue import (
IssueSubscriberSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
)
from .module import (
@@ -82,3 +81,5 @@ from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSeriali
from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer
from .exporter import ExporterHistorySerializer
+11
View File
@@ -14,6 +14,11 @@ from plane.db.models import Cycle, CycleIssue, CycleFavorite
class CycleWriteSerializer(BaseSerializer):
def validate(self, data):
if data.get("start_date", None) is not None and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None):
raise serializers.ValidationError("Start date cannot exceed end date")
return data
class Meta:
model = Cycle
fields = "__all__"
@@ -35,12 +40,18 @@ class CycleSerializer(BaseSerializer):
started_estimates = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
def validate(self, data):
if data.get("start_date", None) is not None and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None):
raise serializers.ValidationError("Start date cannot exceed end date")
return data
def get_assignees(self, obj):
members = [
{
"avatar": assignee.avatar,
"first_name": assignee.first_name,
"display_name": assignee.display_name,
"id": assignee.id,
}
for issue_cycle in obj.issue_cycle.all()
@@ -0,0 +1,26 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import ExporterHistory
from .user import UserLiteSerializer
class ExporterHistorySerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
class Meta:
model = ExporterHistory
fields = [
"id",
"created_at",
"updated_at",
"project",
"provider",
"status",
"url",
"initiated_by",
"initiated_by_detail",
"token",
"created_by",
"updated_by",
]
read_only_fields = fields
+15
View File
@@ -31,6 +31,7 @@ from plane.db.models import (
IssueAttachment,
IssueReaction,
CommentReaction,
IssueVote,
)
@@ -111,6 +112,11 @@ class IssueCreateSerializer(BaseSerializer):
"updated_at",
]
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
def create(self, validated_data):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None)
@@ -549,6 +555,14 @@ class CommentReactionSerializer(BaseSerializer):
class IssueVoteSerializer(BaseSerializer):
class Meta:
model = IssueVote
fields = ["issue", "vote", "workspace_id", "project_id", "actor"]
read_only_fields = fields
class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
@@ -568,6 +582,7 @@ class IssueCommentSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"access",
]
@@ -40,6 +40,11 @@ class ModuleWriteSerializer(BaseSerializer):
"updated_at",
]
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
def create(self, validated_data):
members = validated_data.pop("members_list", None)
-57
View File
@@ -1,57 +0,0 @@
from rest_framework.serializers import (
ModelSerializer,
Serializer,
CharField,
SerializerMethodField,
)
from rest_framework.authtoken.models import Token
from rest_framework_simplejwt.tokens import RefreshToken
from plane.db.models import User
class UserSerializer(ModelSerializer):
class Meta:
model = User
fields = "__all__"
extra_kwargs = {"password": {"write_only": True}}
class ChangePasswordSerializer(Serializer):
model = User
"""
Serializer for password change endpoint.
"""
old_password = CharField(required=True)
new_password = CharField(required=True)
class ResetPasswordSerializer(Serializer):
model = User
"""
Serializer for password change endpoint.
"""
new_password = CharField(required=True)
confirm_password = CharField(required=True)
class TokenSerializer(ModelSerializer):
user = UserSerializer()
access_token = SerializerMethodField()
refresh_token = SerializerMethodField()
def get_access_token(self, obj):
refresh_token = RefreshToken.for_user(obj.user)
return str(refresh_token.access_token)
def get_refresh_token(self, obj):
refresh_token = RefreshToken.for_user(obj.user)
return str(refresh_token)
class Meta:
model = Token
fields = "__all__"
+36 -4
View File
@@ -7,13 +7,14 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSerializer
from plane.api.serializers.user import UserLiteSerializer
from plane.api.serializers.user import UserLiteSerializer, UserAdminLiteSerializer
from plane.db.models import (
Project,
ProjectMember,
ProjectMemberInvite,
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
)
@@ -80,7 +81,15 @@ class ProjectSerializer(BaseSerializer):
class ProjectLiteSerializer(BaseSerializer):
class Meta:
model = Project
fields = ["id", "identifier", "name"]
fields = [
"id",
"identifier",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
read_only_fields = fields
@@ -94,6 +103,8 @@ class ProjectDetailSerializer(BaseSerializer):
total_modules = serializers.IntegerField(read_only=True)
is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True)
class Meta:
model = Project
@@ -110,6 +121,16 @@ class ProjectMemberSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberAdminSerializer(BaseSerializer):
workspace = WorkspaceLiteSerializer(read_only=True)
project = ProjectLiteSerializer(read_only=True)
member = UserAdminLiteSerializer(read_only=True)
class Meta:
model = ProjectMember
fields = "__all__"
class ProjectMemberInviteSerializer(BaseSerializer):
project = ProjectLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
@@ -137,8 +158,6 @@ class ProjectFavoriteSerializer(BaseSerializer):
]
class ProjectMemberLiteSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
is_subscribed = serializers.BooleanField(read_only=True)
@@ -147,3 +166,16 @@ class ProjectMemberLiteSerializer(BaseSerializer):
model = ProjectMember
fields = ["member", "id", "is_subscribed"]
read_only_fields = fields
class ProjectDeployBoardSerializer(BaseSerializer):
project_details = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
class Meta:
model = ProjectDeployBoard
fields = "__all__"
read_only_fields = [
"workspace",
"project" "anchor",
]
+43 -1
View File
@@ -1,3 +1,6 @@
# Third party imports
from rest_framework import serializers
# Module import
from .base import BaseSerializer
from plane.db.models import User
@@ -37,11 +40,50 @@ class UserLiteSerializer(BaseSerializer):
"id",
"first_name",
"last_name",
"email",
"avatar",
"is_bot",
"display_name",
]
read_only_fields = [
"id",
"is_bot",
]
class UserAdminLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
"email",
]
read_only_fields = [
"id",
"is_bot",
]
class ChangePasswordSerializer(serializers.Serializer):
model = User
"""
Serializer for password change endpoint.
"""
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True)
class ResetPasswordSerializer(serializers.Serializer):
model = User
"""
Serializer for password change endpoint.
"""
new_password = serializers.CharField(required=True)
confirm_password = serializers.CharField(required=True)
+22 -13
View File
@@ -3,7 +3,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .user import UserLiteSerializer, UserAdminLiteSerializer
from plane.db.models import (
User,
@@ -33,10 +33,30 @@ class WorkSpaceSerializer(BaseSerializer):
"owner",
]
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = [
"name",
"slug",
"id",
]
read_only_fields = fields
class WorkSpaceMemberSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
workspace = WorkSpaceSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
class Meta:
model = WorkspaceMember
fields = "__all__"
class WorkspaceMemberAdminSerializer(BaseSerializer):
member = UserAdminLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
class Meta:
model = WorkspaceMember
@@ -101,17 +121,6 @@ class TeamSerializer(BaseSerializer):
return super().update(instance, validated_data)
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = [
"name",
"slug",
"id",
]
read_only_fields = fields
class WorkspaceThemeSerializer(BaseSerializer):
class Meta:
model = WorkspaceTheme
+146
View File
@@ -32,6 +32,7 @@ from plane.api.views import (
InviteWorkspaceEndpoint,
JoinWorkspaceEndpoint,
WorkSpaceMemberViewSet,
WorkspaceMembersEndpoint,
WorkspaceInvitationsViewset,
UserWorkspaceInvitationsEndpoint,
WorkspaceMemberUserEndpoint,
@@ -59,6 +60,7 @@ from plane.api.views import (
ProjectViewSet,
InviteProjectEndpoint,
ProjectMemberViewSet,
ProjectMemberEndpoint,
ProjectMemberInvitationsViewset,
ProjectMemberUserEndpoint,
AddMemberToProjectEndpoint,
@@ -84,6 +86,7 @@ from plane.api.views import (
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
IssueCommentPublicViewSet,
IssueReactionViewSet,
CommentReactionViewSet,
ExportIssuesEndpoint,
@@ -163,6 +166,15 @@ from plane.api.views import (
NotificationViewSet,
UnreadNotificationEndpoint,
## End Notification
# Public Boards
ProjectDeployBoardViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardPublicSettingsEndpoint,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
InboxIssuePublicViewSet,
IssueVotePublicViewSet,
## End Public Boards
)
@@ -335,6 +347,11 @@ urlpatterns = [
),
name="workspace",
),
path(
"workspaces/<str:slug>/workspace-members/",
WorkspaceMembersEndpoint.as_view(),
name="workspace-members",
),
path(
"workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view(
@@ -468,6 +485,11 @@ urlpatterns = [
),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
ProjectMemberEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/add/",
AddMemberToProjectEndpoint.as_view(),
@@ -1469,4 +1491,128 @@ urlpatterns = [
name="unread-notifications",
),
## End Notification
# Public Boards
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
ProjectDeployBoardViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-deploy-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/<uuid:pk>/",
ProjectDeployBoardViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
name="project-deploy-board-settings",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/",
ProjectDeployBoardIssuesPublicEndpoint.as_view(),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="issue-comments-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentPublicViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="issue-comments-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/",
IssueReactionPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="issue-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
IssueReactionPublicViewSet.as_view(
{
"delete": "destroy",
}
),
name="issue-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/",
CommentReactionPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="comment-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
CommentReactionPublicViewSet.as_view(
{
"delete": "destroy",
}
),
name="comment-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssuePublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="inbox-issue",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
InboxIssuePublicViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="inbox-issue",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="issue-vote-project-board",
),
## End Public Boards
]
+16 -4
View File
@@ -12,8 +12,12 @@ from .project import (
ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
ProjectFavoritesViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint,
)
from .people import (
from .user import (
UserEndpoint,
UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint,
@@ -47,6 +51,7 @@ from .workspace import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceMembersEndpoint,
)
from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
@@ -73,9 +78,12 @@ from .issue import (
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
IssueCommentPublicViewSet,
CommentReactionViewSet,
IssueReactionViewSet,
ExportIssuesEndpoint
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
)
from .auth_extended import (
@@ -143,7 +151,7 @@ from .estimate import (
from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
from .analytic import (
AnalyticsEndpoint,
@@ -153,4 +161,8 @@ from .analytic import (
DefaultAnalyticsEndpoint,
)
from .notification import NotificationViewSet, UnreadNotificationEndpoint
from .notification import NotificationViewSet, UnreadNotificationEndpoint
from .exporter import (
ExportIssuesEndpoint,
)
+5 -5
View File
@@ -79,12 +79,12 @@ class AnalyticsEndpoint(BaseAPIView):
)
assignee_details = {}
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
assignee_details = (
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id")
.distinct("assignees__id")
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
)
@@ -243,21 +243,21 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
)
most_issue_created_user = (
queryset.exclude(created_by=None)
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__email")
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__display_name", "created_by__id")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
most_issue_closed_user = (
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
pending_issue_user = (
queryset.filter(completed_at__isnull=True)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
.annotate(count=Count("id"))
.order_by("-count")
)
+1 -1
View File
@@ -22,7 +22,7 @@ from sentry_sdk import capture_exception
## Module imports
from . import BaseAPIView
from plane.api.serializers.people import (
from plane.api.serializers import (
ChangePasswordSerializer,
ResetPasswordSerializer,
)
+5 -1
View File
@@ -165,6 +165,9 @@ class CycleViewSet(BaseViewSet):
try:
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
order_by = request.GET.get("order_by", "sort_order")
queryset = queryset.order_by(order_by)
# All Cycles
if cycle_view == "all":
@@ -370,7 +373,8 @@ class CycleViewSet(BaseViewSet):
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar")
.annotate(display_name=F("assignees__display_name"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
+100
View File
@@ -0,0 +1,100 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from . import BaseAPIView
from plane.api.permissions import WorkSpaceAdminPermission
from plane.bgtasks.export_task import issue_export_task
from plane.db.models import Project, ExporterHistory, Workspace
from plane.api.serializers import ExporterHistorySerializer
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
model = ExporterHistory
serializer_class = ExporterHistorySerializer
def post(self, request, slug):
try:
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
provider = request.data.get("provider", False)
multiple = request.data.get("multiple", False)
project_ids = request.data.get("project", [])
if provider in ["csv", "xlsx", "json"]:
if not project_ids:
project_ids = Project.objects.filter(
workspace__slug=slug
).values_list("id", flat=True)
project_ids = [str(project_id) for project_id in project_ids]
exporter = ExporterHistory.objects.create(
workspace=workspace,
project=project_ids,
initiated_by=request.user,
provider=provider,
)
issue_export_task.delay(
provider=exporter.provider,
workspace_id=workspace.id,
project_ids=project_ids,
token_id=exporter.token,
multiple=multiple,
slug=slug,
)
return Response(
{
"message": f"Once the export is ready you will be able to download it"
},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": f"Provider '{provider}' not found."},
status=status.HTTP_400_BAD_REQUEST,
)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def get(self, request, slug):
try:
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug
).select_related("workspace","initiated_by")
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
return self.paginate(
request=request,
queryset=exporter_history,
on_results=lambda exporter_history: ExporterHistorySerializer(
exporter_history, many=True
).data,
)
else:
return Response(
{"error": "per_page and cursor are required"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+1 -1
View File
@@ -458,7 +458,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
comment=f"{request.user.email} importer the issue from {service}",
comment=f"imported the issue from {service}",
verb="created",
created_by=request.user,
)
+267 -2
View File
@@ -15,7 +15,6 @@ from sentry_sdk import capture_exception
from .base import BaseViewSet
from plane.api.permissions import ProjectBasePermission, ProjectLitePermission
from plane.db.models import (
Project,
Inbox,
InboxIssue,
Issue,
@@ -23,6 +22,7 @@ from plane.db.models import (
IssueLink,
IssueAttachment,
ProjectMember,
ProjectDeployBoard,
)
from plane.api.serializers import (
IssueSerializer,
@@ -377,4 +377,269 @@ class InboxIssueViewSet(BaseViewSet):
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
)
class InboxIssuePublicViewSet(BaseViewSet):
serializer_class = InboxIssueSerializer
model = InboxIssue
filterset_fields = [
"status",
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"))
if project_deploy_board is not None:
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("issue", "workspace", "project")
)
else:
return InboxIssue.objects.none()
def list(self, request, slug, project_id, inbox_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.objects.filter(
issue_inbox__inbox_id=inbox_id,
workspace__slug=slug,
project_id=project_id,
)
.filter(**filters)
.annotate(bridge_id=F("issue_inbox__id"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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")
)
.prefetch_related(
Prefetch(
"issue_inbox",
queryset=InboxIssue.objects.only(
"status", "duplicate_to", "snoozed_till", "source"
),
)
)
)
issues_data = IssueStateInboxSerializer(issues, many=True).data
return Response(
issues_data,
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response({"error": "Project Deploy Board does not exist"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def create(self, request, slug, project_id, inbox_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [
"low",
"medium",
"high",
"urgent",
None,
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
)
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=project_id,
color="#ff7700",
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
description=request.data.get("issue", {}).get("description", {}),
description_html=request.data.get("issue", {}).get(
"description_html", "<p></p>"
),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_id,
state=state,
)
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
)
# create an inbox issue
InboxIssue.objects.create(
inbox_id=inbox_id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
# Get the project member
if str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST)
# Get issue data
issue_data = request.data.pop("issue", False)
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
# viewers and guests since only viewers and guests
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html),
"description": issue_data.get("description", issue.description)
}
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True
)
if issue_serializer.is_valid():
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
)
issue_serializer.save()
return Response(issue_serializer.data, status=status.HTTP_200_OK)
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except InboxIssue.DoesNotExist:
return Response(
{"error": "Inbox Issue does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
if str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except InboxIssue.DoesNotExist:
return Response({"error": "Inbox Issue does not exists"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+15 -1
View File
@@ -20,6 +20,17 @@ class SlackProjectSyncViewSet(BaseViewSet):
serializer_class = SlackProjectSyncSerializer
model = SlackProjectSync
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.filter(project__project_projectmember__member=self.request.user)
)
def create(self, request, slug, project_id, workspace_integration_id):
try:
serializer = SlackProjectSyncSerializer(data=request.data)
@@ -45,7 +56,10 @@ class SlackProjectSyncViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response({"error": "Slack is already enabled for the project"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Slack is already enabled for the project"},
status=status.HTTP_400_BAD_REQUEST,
)
except WorkspaceIntegration.DoesNotExist:
return Response(
{"error": "Workspace Integration does not exist"},
+391 -12
View File
@@ -48,6 +48,7 @@ from plane.api.serializers import (
ProjectMemberLiteSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
)
from plane.api.permissions import (
WorkspaceEntityPermission,
@@ -70,11 +71,12 @@ from plane.db.models import (
ProjectMember,
IssueReaction,
CommentReaction,
ProjectDeployBoard,
IssueVote,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.project_issue_export import issue_export_task
class IssueViewSet(BaseViewSet):
@@ -169,7 +171,6 @@ class IssueViewSet(BaseViewSet):
def list(self, request, slug, project_id):
try:
filters = issue_filters(request.query_params, "GET")
print(filters)
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
@@ -362,8 +363,14 @@ class UserWorkSpaceIssues(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
)
).distinct()
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
@@ -744,21 +751,25 @@ class SubIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
state_distribution = (
State.objects.filter(~Q(name="Triage"), workspace__slug=slug)
.annotate(
state_count=Count(
"state_issue",
filter=Q(state_issue__parent_id=issue_id),
)
State.objects.filter(
workspace__slug=slug, state_issue__parent_id=issue_id
)
.order_by("group")
.values("group", "state_count")
.annotate(state_group=F("group"))
.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
)
result = {item["group"]: item["state_count"] for item in state_distribution}
result = {item["state_group"]: item["state_count"] for item in state_distribution}
serializer = IssueLiteSerializer(
sub_issues,
@@ -1448,6 +1459,374 @@ class CommentReactionViewSet(BaseViewSet):
)
class IssueCommentPublicViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
filterset_fields = [
"issue__id",
"workspace__id",
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.comments:
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(issue_id=self.kwargs.get("issue_id"))
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
else:
return IssueComment.objects.none()
def create(self, request, slug, project_id, issue_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
access = (
"INTERNAL"
if ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists()
else "EXTERNAL"
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
issue_id=issue_id,
actor=request.user,
access=access,
)
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, issue_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug, pk=pk, actor=request.user
)
serializer = IssueCommentSerializer(
comment, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="comment.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,)
def destroy(self, request, slug, project_id, issue_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug, pk=pk, project_id=project_id, actor=request.user
)
issue_activity.delay(
type="comment.activity.deleted",
requested_data=json.dumps({"comment_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueReactionPublicViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
return (
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"))
.order_by("-created_at")
.distinct()
)
else:
return IssueReaction.objects.none()
def create(self, request, slug, project_id, issue_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
issue_id=issue_id,
reaction=reaction_code,
actor=request.user,
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
return Response(
{"error": "Issue reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class CommentReactionPublicViewSet(BaseViewSet):
serializer_class = CommentReactionSerializer
model = CommentReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id"))
.order_by("-created_at")
.distinct()
)
else:
return CommentReaction.objects.none()
def create(self, request, slug, project_id, comment_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, comment_id=comment_id, actor=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
comment_reaction = CommentReaction.objects.get(
project_id=project_id,
workspace__slug=slug,
comment_id=comment_id,
reaction=reaction_code,
actor=request.user,
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
return Response(
{"error": "Comment reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueVotePublicViewSet(BaseViewSet):
model = IssueVote
serializer_class = IssueVoteSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
def create(self, request, slug, project_id, issue_id):
try:
issue_vote, _ = IssueVote.objects.get_or_create(
actor_id=request.user.id,
project_id=project_id,
issue_id=issue_id,
vote=request.data.get("vote", 1),
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id):
try:
issue_vote = IssueVote.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
actor_id=request.user.id,
)
issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
+5 -2
View File
@@ -53,6 +53,8 @@ class ModuleViewSet(BaseViewSet):
)
def get_queryset(self):
order_by = self.request.GET.get("order_by", "sort_order")
subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
@@ -106,7 +108,7 @@ class ModuleViewSet(BaseViewSet):
filter=Q(issue_module__issue__state__group="backlog"),
)
)
.order_by("-is_favorite", "name")
.order_by(order_by, "name")
)
def perform_destroy(self, instance):
@@ -173,8 +175,9 @@ class ModuleViewSet(BaseViewSet):
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar")
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
+1 -1
View File
@@ -301,7 +301,7 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView):
issue=issue,
actor=request.user,
project_id=project_id,
comment=f"{request.user.email} created the issue from {page_block.name} block",
comment=f"created the issue from {page_block.name} block",
verb="created",
)
+309 -7
View File
@@ -5,7 +5,21 @@ from datetime import datetime
# Django imports
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import Q, Exists, OuterRef, Func, F, Min, Subquery
from django.db.models import (
Q,
Exists,
OuterRef,
Func,
F,
Max,
CharField,
Func,
Subquery,
Prefetch,
When,
Case,
Value,
)
from django.core.validators import validate_email
from django.conf import settings
@@ -13,6 +27,7 @@ from django.conf import settings
from rest_framework.response import Response
from rest_framework import status
from rest_framework import serializers
from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception
# Module imports
@@ -23,9 +38,16 @@ from plane.api.serializers import (
ProjectDetailSerializer,
ProjectMemberInviteSerializer,
ProjectFavoriteSerializer,
IssueLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
)
from plane.api.permissions import ProjectBasePermission
from plane.api.permissions import (
ProjectBasePermission,
ProjectEntityPermission,
ProjectMemberPermission,
)
from plane.db.models import (
Project,
@@ -48,9 +70,17 @@ from plane.db.models import (
IssueAssignee,
ModuleMember,
Inbox,
ProjectDeployBoard,
Issue,
IssueReaction,
IssueLink,
IssueAttachment,
Label,
)
from plane.bgtasks.project_invitation_task import project_invitation
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
class ProjectViewSet(BaseViewSet):
@@ -92,7 +122,9 @@ class ProjectViewSet(BaseViewSet):
)
)
.annotate(
total_members=ProjectMember.objects.filter(project_id=OuterRef("id"))
total_members=ProjectMember.objects.filter(
project_id=OuterRef("id"), member__is_bot=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -109,6 +141,20 @@ class ProjectViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
member_role=ProjectMember.objects.filter(
project_id=OuterRef("pk"),
member_id=self.request.user.id,
).values("role")
)
.annotate(
is_deployed=Exists(
ProjectDeployBoard.objects.filter(
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
)
)
.distinct()
)
@@ -180,7 +226,9 @@ class ProjectViewSet(BaseViewSet):
project_id=serializer.data["id"], member=request.user, role=20
)
if serializer.data["project_lead"] is not None:
if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
) != str(request.user.id):
ProjectMember.objects.create(
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
@@ -347,7 +395,9 @@ class InviteProjectEndpoint(BaseAPIView):
validate_email(email)
# Check if user is already a member of workspace
if ProjectMember.objects.filter(
project_id=project_id, member__email=email
project_id=project_id,
member__email=email,
member__is_bot=False,
).exists():
return Response(
{"error": "User is already member of workspace"},
@@ -451,14 +501,14 @@ class UserProjectInvitationsViewset(BaseViewSet):
class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberSerializer
serializer_class = ProjectMemberAdminSerializer
model = ProjectMember
permission_classes = [
ProjectBasePermission,
]
search_fields = [
"member__email",
"member__display_name",
"member__first_name",
]
@@ -984,3 +1034,255 @@ class ProjectFavoritesViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectDeployBoardViewSet(BaseViewSet):
permission_classes = [
ProjectMemberPermission,
]
serializer_class = ProjectDeployBoardSerializer
model = ProjectDeployBoard
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.select_related("project")
)
def create(self, request, slug, project_id):
try:
comments = request.data.get("comments", False)
reactions = request.data.get("reactions", False)
inbox = request.data.get("inbox", None)
votes = request.data.get("votes", False)
views = request.data.get(
"views",
{
"list": True,
"kanban": True,
"calendar": True,
"gantt": True,
"spreadsheet": True,
},
)
project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create(
anchor=f"{slug}/{project_id}",
project_id=project_id,
)
project_deploy_board.comments = comments
project_deploy_board.reactions = reactions
project_deploy_board.inbox = inbox
project_deploy_board.votes = votes
project_deploy_board.views = views
project_deploy_board.save()
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectMemberEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
project_members = ProjectMember.objects.filter(
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member")
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project Deploy Board does not exists"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.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 = IssueLiteSerializer(issue_queryset, many=True).data
states = State.objects.filter(
workspace__slug=slug, project_id=project_id
).values("name", "group", "color", "id")
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -34,6 +34,12 @@ class UserEndpoint(BaseViewSet):
workspace = Workspace.objects.get(
pk=request.user.last_workspace_id, workspace_member__member=request.user
)
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email
).count()
assigned_issues = Issue.issue_objects.filter(
assignees__in=[request.user]
).count()
serialized_data = UserSerializer(request.user).data
serialized_data["workspace"] = {
@@ -41,7 +47,11 @@ class UserEndpoint(BaseViewSet):
"last_workspace_slug": workspace.slug,
"fallback_workspace_id": request.user.last_workspace_id,
"fallback_workspace_slug": workspace.slug,
"invites": workspace_invites,
}
serialized_data.setdefault("issues", {})[
"assigned_issues"
] = assigned_issues
return Response(
serialized_data,
@@ -50,6 +60,13 @@ class UserEndpoint(BaseViewSet):
except Workspace.DoesNotExist:
# This exception will be hit even when the `last_workspace_id` is None
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email
).count()
assigned_issues = Issue.issue_objects.filter(
assignees__in=[request.user]
).count()
fallback_workspace = (
Workspace.objects.filter(workspace_member__member=request.user)
.order_by("created_at")
@@ -67,7 +84,11 @@ class UserEndpoint(BaseViewSet):
"fallback_workspace_slug": fallback_workspace.slug
if fallback_workspace is not None
else None,
"invites": workspace_invites,
}
serialized_data.setdefault("issues", {})[
"assigned_issues"
] = assigned_issues
return Response(
serialized_data,
@@ -81,7 +102,6 @@ class UserEndpoint(BaseViewSet):
)
class UpdateUserOnBoardedEndpoint(BaseAPIView):
def patch(self, request):
try:
+7
View File
@@ -19,6 +19,7 @@ from plane.db.models import (
IssueView,
Issue,
IssueViewFavorite,
IssueReaction,
)
from plane.utils.issue_filters import issue_filters
@@ -77,6 +78,12 @@ class ViewIssuesEndpoint(BaseAPIView):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
serializer = IssueLiteSerializer(issues, many=True)
+50 -16
View File
@@ -47,6 +47,7 @@ from plane.api.serializers import (
WorkspaceThemeSerializer,
IssueActivitySerializer,
IssueLiteSerializer,
WorkspaceMemberAdminSerializer,
)
from plane.api.views.base import BaseAPIView
from . import BaseViewSet
@@ -106,7 +107,9 @@ class WorkSpaceViewSet(BaseViewSet):
def get_queryset(self):
member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -191,7 +194,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
def get(self, request):
try:
member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -537,7 +542,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
class WorkSpaceMemberViewSet(BaseViewSet):
serializer_class = WorkSpaceMemberSerializer
serializer_class = WorkspaceMemberAdminSerializer
model = WorkspaceMember
permission_classes = [
@@ -545,7 +550,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
]
search_fields = [
"member__email",
"member__display_name",
"member__first_name",
]
@@ -624,7 +629,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if (
workspace_member.role == 20
and WorkspaceMember.objects.filter(
workspace__slug=slug, role=20
workspace__slug=slug,
role=20,
member__is_bot=False,
).count()
== 1
):
@@ -690,7 +697,7 @@ class TeamMemberViewSet(BaseViewSet):
]
search_fields = [
"member__email",
"member__display_name",
"member__first_name",
]
@@ -987,11 +994,11 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
upcoming_issues = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
target_date__gte=timezone.now(),
start_date__gte=timezone.now(),
workspace__slug=slug,
assignees__in=[request.user],
completed_at__isnull=True,
).values("id", "name", "workspace__slug", "project_id", "target_date")
).values("id", "name", "workspace__slug", "project_id", "start_date")
return Response(
{
@@ -1048,7 +1055,6 @@ class WorkspaceThemeViewSet(BaseViewSet):
class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
filters = issue_filters(request.query_params, "GET")
@@ -1077,6 +1083,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
.filter(**filters)
.values("priority")
.annotate(priority_count=Count("priority"))
.filter(priority_count__gte=1)
.annotate(
priority_order=Case(
*[
@@ -1146,14 +1153,18 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
upcoming_cycles = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__gt=timezone.now().date(),
issue__assignees__in=[user_id,]
issue__assignees__in=[
user_id,
],
).values("cycle__name", "cycle__id", "cycle__project_id")
present_cycle = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__lt=timezone.now().date(),
cycle__end_date__gt=timezone.now().date(),
issue__assignees__in=[user_id,]
issue__assignees__in=[
user_id,
],
).values("cycle__name", "cycle__id", "cycle__project_id")
return Response(
@@ -1166,7 +1177,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
"pending_issues": pending_issues_count,
"subscribed_issues": subscribed_issues_count,
"present_cycles": present_cycle,
"upcoming_cycles": upcoming_cycles,
"upcoming_cycles": upcoming_cycles,
}
)
except Exception as e:
@@ -1184,7 +1195,6 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter(
@@ -1212,12 +1222,13 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
class WorkspaceUserProfileEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
user_data = User.objects.get(pk=user_id)
requesting_workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user)
requesting_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user
)
projects = []
if requesting_workspace_member.role >= 10:
projects = (
@@ -1227,7 +1238,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
)
.annotate(
created_issues=Count(
"project_issue", filter=Q(project_issue__created_by_id=user_id)
"project_issue",
filter=Q(project_issue__created_by_id=user_id),
)
)
.annotate(
@@ -1282,6 +1294,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
"cover_image": user_data.cover_image,
"date_joined": user_data.date_joined,
"user_timezone": user_data.user_timezone,
"display_name": user_data.display_name,
},
},
status=status.HTTP_200_OK,
@@ -1439,3 +1452,24 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceMembersEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
try:
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
).select_related("workspace", "member")
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
return Response(serialzier.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+11 -11
View File
@@ -21,7 +21,7 @@ row_mapping = {
"state__name": "State",
"state__group": "State Group",
"labels__name": "Label",
"assignees__email": "Assignee Name",
"assignees__display_name": "Assignee Name",
"start_date": "Start Date",
"target_date": "Due Date",
"completed_at": "Completed At",
@@ -51,12 +51,12 @@ def analytic_export_task(email, data, slug):
segmented = segment
assignee_details = {}
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
assignee_details = (
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id")
.distinct("assignees__id")
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
)
if segment:
@@ -93,19 +93,19 @@ def analytic_export_task(email, data, slug):
else:
generated_row.append("0")
# x-axis replacement for names
if x_axis in ["assignees__email"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if len(assignee):
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows.append(tuple(generated_row))
# If segment is ["assignees__email"] then replace segment_zero rows with first and last names
if segmented in ["assignees__email"]:
# If segment is ["assignees__display_name"] then replace segment_zero rows with first and last names
if segmented in ["assignees__id"]:
for index, segm in enumerate(row_zero[2:]):
# find the name of the user
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(segm)]
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(segm)]
if len(assignee):
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
row_zero[index + 2] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows = [tuple(row_zero)] + rows
csv_buffer = io.StringIO()
@@ -141,8 +141,8 @@ def analytic_export_task(email, data, slug):
else distribution.get(item)[0].get("estimate "),
]
# x-axis replacement to names
if x_axis in ["assignees__email"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if len(assignee):
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
+346
View File
@@ -0,0 +1,346 @@
# Python imports
import csv
import io
import json
import boto3
import zipfile
# Django imports
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from botocore.client import Config
from openpyxl import Workbook
# Module imports
from plane.db.models import Issue, ExporterHistory
def dateTimeConverter(time):
if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time):
if time:
return time.strftime("%a, %d %b %Y")
def create_csv_file(data):
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
for row in data:
csv_writer.writerow(row)
csv_buffer.seek(0)
return csv_buffer.getvalue()
def create_json_file(data):
return json.dumps(data)
def create_xlsx_file(data):
workbook = Workbook()
sheet = workbook.active
for row in data:
sheet.append(row)
xlsx_buffer = io.BytesIO()
workbook.save(xlsx_buffer)
xlsx_buffer.seek(0)
return xlsx_buffer.getvalue()
def create_zip_file(files):
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
for filename, file_content in files:
zipf.writestr(filename, file_content)
zip_buffer.seek(0)
return zip_buffer
def upload_to_s3(zip_file, workspace_id, token_id, slug):
s3 = boto3.client(
"s3",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
expires_in = 7 * 24 * 60 * 60
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
exporter_instance = ExporterHistory.objects.get(token=token_id)
if presigned_url:
exporter_instance.url = presigned_url
exporter_instance.status = "completed"
exporter_instance.key = file_name
else:
exporter_instance.status = "failed"
exporter_instance.save(update_fields=["status", "url","key"])
def generate_table_row(issue):
return [
f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
issue["project__name"],
issue["name"],
issue["description_stripped"],
issue["state__name"],
issue["priority"],
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else "",
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else "",
issue["labels__name"],
issue["issue_cycle__cycle__name"],
dateConverter(issue["issue_cycle__cycle__start_date"]),
dateConverter(issue["issue_cycle__cycle__end_date"]),
issue["issue_module__module__name"],
dateConverter(issue["issue_module__module__start_date"]),
dateConverter(issue["issue_module__module__target_date"]),
dateTimeConverter(issue["created_at"]),
dateTimeConverter(issue["updated_at"]),
dateTimeConverter(issue["completed_at"]),
dateTimeConverter(issue["archived_at"]),
]
def generate_json_row(issue):
return {
"ID": f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project__name"],
"Name": issue["name"],
"Description": issue["description_stripped"],
"State": issue["state__name"],
"Priority": issue["priority"],
"Created By": f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else "",
"Assignee": f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else "",
"Labels": issue["labels__name"],
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
"Archived At": dateTimeConverter(issue["archived_at"]),
}
def update_json_row(rows, row):
matched_index = next(
(
index
for index, existing_row in enumerate(rows)
if existing_row["ID"] == row["ID"]
),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = (
rows[matched_index]["Assignee"],
rows[matched_index]["Labels"],
)
assignee, label = row["Assignee"], row["Labels"]
if assignee is not None and assignee not in existing_assignees:
rows[matched_index]["Assignee"] += f", {assignee}"
if label is not None and label not in existing_labels:
rows[matched_index]["Labels"] += f", {label}"
else:
rows.append(row)
def update_table_row(rows, row):
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = rows[matched_index][7:9]
assignee, label = row[7:9]
if assignee is not None and assignee not in existing_assignees:
rows[matched_index][7] += f", {assignee}"
if label is not None and label not in existing_labels:
rows[matched_index][8] += f", {label}"
else:
rows.append(row)
def generate_csv(header, project_id, issues, files):
"""
Generate CSV export for all the passed issues.
"""
rows = [
header,
]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
csv_file = create_csv_file(rows)
files.append((f"{project_id}.csv", csv_file))
def generate_json(header, project_id, issues, files):
rows = []
for issue in issues:
row = generate_json_row(issue)
update_json_row(rows, row)
json_file = create_json_file(rows)
files.append((f"{project_id}.json", json_file))
def generate_xlsx(header, project_id, issues, files):
rows = [header]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
xlsx_file = create_xlsx_file(rows)
files.append((f"{project_id}.xlsx", xlsx_file))
@shared_task
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
try:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "processing"
exporter_instance.save(update_fields=["status"])
workspace_issues = (
(
Issue.objects.filter(
workspace__id=workspace_id, project_id__in=project_ids
)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
)
.values(
"id",
"project__identifier",
"project__name",
"project__id",
"sequence_id",
"name",
"description_stripped",
"priority",
"state__name",
"created_at",
"updated_at",
"completed_at",
"archived_at",
"issue_cycle__cycle__name",
"issue_cycle__cycle__start_date",
"issue_cycle__cycle__end_date",
"issue_module__module__name",
"issue_module__module__start_date",
"issue_module__module__target_date",
"created_by__first_name",
"created_by__last_name",
"assignees__first_name",
"assignees__last_name",
"labels__name",
)
)
.order_by("project__identifier","sequence_id")
.distinct()
)
# CSV header
header = [
"ID",
"Project",
"Name",
"Description",
"State",
"Priority",
"Created By",
"Assignee",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Module Start Date",
"Module Target Date",
"Created At",
"Updated At",
"Completed At",
"Archived At",
]
EXPORTER_MAPPER = {
"csv": generate_csv,
"json": generate_json,
"xlsx": generate_xlsx,
}
files = []
if multiple:
for project_id in project_ids:
issues = workspace_issues.filter(project__id=project_id)
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(
header,
project_id,
issues,
files,
)
else:
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(
header,
workspace_id,
workspace_issues,
files,
)
zip_buffer = create_zip_file(files)
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
except Exception as e:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "failed"
exporter_instance.reason = str(e)
exporter_instance.save(update_fields=["status", "reason"])
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -0,0 +1,38 @@
# Python imports
import boto3
from datetime import timedelta
# Django imports
from django.conf import settings
from django.utils import timezone
from django.db.models import Q
# Third party imports
from celery import shared_task
from botocore.client import Config
# Module imports
from plane.db.models import ExporterHistory
@shared_task
def delete_old_s3_link():
# Get a list of keys and IDs to process
expired_exporter_history = ExporterHistory.objects.filter(
Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8))
).values_list("key", "id")
s3 = boto3.client(
"s3",
region_name="ap-south-1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
for file_name, exporter_id in expired_exporter_history:
# Delete object from S3
if file_name:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
ExporterHistory.objects.filter(id=exporter_id).update(url=None)
+59 -54
View File
@@ -48,7 +48,7 @@ def track_name(
field="name",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the name to {requested_data.get('name')}",
comment=f"updated the name to {requested_data.get('name')}",
)
)
@@ -75,7 +75,7 @@ def track_parent(
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to None",
comment=f"updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
)
@@ -95,7 +95,7 @@ def track_parent(
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
comment=f"updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id,
)
@@ -123,7 +123,7 @@ def track_priority(
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to None",
comment=f"updated the priority to None",
)
)
else:
@@ -137,7 +137,7 @@ def track_priority(
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
comment=f"updated the priority to {requested_data.get('priority')}",
)
)
@@ -165,7 +165,7 @@ def track_state(
field="state",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the state to {new_state.name}",
comment=f"updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
)
@@ -184,19 +184,24 @@ def track_description(
if current_instance.get("description_html") != requested_data.get(
"description_html"
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
)
)
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
if(last_activity is not None and last_activity.field == "description" and actor.id == last_activity.actor_id):
last_activity.created_at = timezone.now()
last_activity.save(update_fields=["created_at"])
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"updated the description to {requested_data.get('description_html')}",
)
)
# Track changes in issue target date
@@ -220,7 +225,7 @@ def track_target_date(
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to None",
comment=f"updated the target date to None",
)
)
else:
@@ -234,7 +239,7 @@ def track_target_date(
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
comment=f"updated the target date to {requested_data.get('target_date')}",
)
)
@@ -260,7 +265,7 @@ def track_start_date(
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to None",
comment=f"updated the start date to None",
)
)
else:
@@ -274,7 +279,7 @@ def track_start_date(
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
comment=f"updated the start date to {requested_data.get('start_date')}",
)
)
@@ -303,7 +308,7 @@ def track_labels(
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added label {label.name}",
comment=f"added label {label.name}",
new_identifier=label.id,
old_identifier=None,
)
@@ -324,7 +329,7 @@ def track_labels(
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed label {label.name}",
comment=f"removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
)
@@ -353,12 +358,12 @@ def track_assignees(
actor=actor,
verb="updated",
old_value="",
new_value=assignee.email,
new_value=assignee.display_name,
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added assignee {assignee.email}",
new_identifier=actor.id,
comment=f"added assignee {assignee.display_name}",
new_identifier=assignee.id,
)
)
@@ -374,13 +379,13 @@ def track_assignees(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=assignee.email,
old_value=assignee.display_name,
new_value="",
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}",
old_identifier=actor.id,
comment=f"removed assignee {assignee.display_name}",
old_identifier=assignee.id,
)
)
@@ -419,7 +424,7 @@ def track_blocks(
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocking issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
@@ -441,7 +446,7 @@ def track_blocks(
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocking issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
@@ -481,7 +486,7 @@ def track_blockings(
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocked by issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
@@ -503,7 +508,7 @@ def track_blockings(
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocked by issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
@@ -517,7 +522,7 @@ def create_issue_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created the issue",
comment=f"created the issue",
verb="created",
actor=actor,
)
@@ -539,7 +544,7 @@ def track_estimate_points(
field="estimate_point",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the estimate point to None",
comment=f"updated the estimate point to None",
)
)
else:
@@ -553,7 +558,7 @@ def track_estimate_points(
field="estimate_point",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the estimate point to {requested_data.get('estimate_point')}",
comment=f"updated the estimate point to {requested_data.get('estimate_point')}",
)
)
@@ -567,7 +572,7 @@ def track_archive_at(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} has restored the issue",
comment=f"has restored the issue",
verb="updated",
actor=actor,
field="archived_at",
@@ -661,7 +666,7 @@ def delete_issue_activity(
IssueActivity(
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the issue",
comment=f"deleted the issue",
verb="deleted",
actor=actor,
field="issue",
@@ -682,7 +687,7 @@ def create_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created a comment",
comment=f"created a comment",
verb="created",
actor=actor,
field="comment",
@@ -707,7 +712,7 @@ def update_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated a comment",
comment=f"updated a comment",
verb="updated",
actor=actor,
field="comment",
@@ -728,7 +733,7 @@ def delete_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the comment",
comment=f"deleted the comment",
verb="deleted",
actor=actor,
field="comment",
@@ -766,7 +771,7 @@ def create_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}",
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
)
@@ -787,7 +792,7 @@ def create_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added cycle {cycle.name}",
comment=f"added cycle {cycle.name}",
new_identifier=cycle.id,
)
)
@@ -816,7 +821,7 @@ def delete_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed this issue from {cycle.name if cycle is not None else None}",
comment=f"removed this issue from {cycle.name if cycle is not None else None}",
old_identifier=cycle.id if cycle is not None else None,
)
)
@@ -852,7 +857,7 @@ def create_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}",
comment=f"updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id,
new_identifier=new_module.id,
)
@@ -872,7 +877,7 @@ def create_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added module {module.name}",
comment=f"added module {module.name}",
new_identifier=module.id,
)
)
@@ -901,7 +906,7 @@ def delete_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed this issue from {module.name if module is not None else None}",
comment=f"removed this issue from {module.name if module is not None else None}",
old_identifier=module.id if module is not None else None,
)
)
@@ -920,7 +925,7 @@ def create_link_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created a link",
comment=f"created a link",
verb="created",
actor=actor,
field="link",
@@ -944,7 +949,7 @@ def update_link_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated a link",
comment=f"updated a link",
verb="updated",
actor=actor,
field="link",
@@ -969,7 +974,7 @@ def delete_link_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the link",
comment=f"deleted the link",
verb="deleted",
actor=actor,
field="link",
@@ -992,7 +997,7 @@ def create_attachment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created an attachment",
comment=f"created an attachment",
verb="created",
actor=actor,
field="attachment",
@@ -1010,7 +1015,7 @@ def delete_attachment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the attachment",
comment=f"deleted the attachment",
verb="deleted",
actor=actor,
field="attachment",
@@ -1,191 +0,0 @@
# Python imports
import csv
import io
# Django imports
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Issue
@shared_task
def issue_export_task(email, data, slug, exporter_name):
try:
project_ids = data.get("project_id", [])
issues_filter = {"workspace__slug": slug}
if project_ids:
issues_filter["project_id__in"] = project_ids
issues = (
Issue.objects.filter(**issues_filter)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
)
.values_list(
"project__identifier",
"sequence_id",
"name",
"description_stripped",
"priority",
"start_date",
"target_date",
"state__name",
"project__name",
"created_at",
"updated_at",
"completed_at",
"archived_at",
"issue_cycle__cycle__name",
"issue_cycle__cycle__start_date",
"issue_cycle__cycle__end_date",
"issue_module__module__name",
"issue_module__module__start_date",
"issue_module__module__target_date",
"created_by__first_name",
"created_by__last_name",
"assignees__first_name",
"assignees__last_name",
"labels__name",
)
)
# CSV header
header = [
"Issue ID",
"Project",
"Name",
"Description",
"State",
"Priority",
"Created By",
"Assignee",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Module Start Date",
"Module Target Date",
"Created At"
"Updated At"
"Completed At"
"Archived At"
]
# Prepare the CSV data
rows = [header]
# Write data for each issue
for issue in issues:
(
project_identifier,
sequence_id,
name,
description,
priority,
start_date,
target_date,
state_name,
project_name,
created_at,
updated_at,
completed_at,
archived_at,
cycle_name,
cycle_start_date,
cycle_end_date,
module_name,
module_start_date,
module_target_date,
created_by_first_name,
created_by_last_name,
assignees_first_names,
assignees_last_names,
labels_names,
) = issue
created_by_fullname = (
f"{created_by_first_name} {created_by_last_name}"
if created_by_first_name and created_by_last_name
else ""
)
assignees_names = ""
if assignees_first_names and assignees_last_names:
assignees_names = ", ".join(
[
f"{assignees_first_name} {assignees_last_name}"
for assignees_first_name, assignees_last_name in zip(
assignees_first_names, assignees_last_names
)
]
)
labels_names = ", ".join(labels_names) if labels_names else ""
row = [
f"{project_identifier}-{sequence_id}",
project_name,
name,
description,
state_name,
priority,
created_by_fullname,
assignees_names,
labels_names,
cycle_name,
cycle_start_date,
cycle_end_date,
module_name,
module_start_date,
module_target_date,
start_date,
target_date,
created_at,
updated_at,
completed_at,
archived_at,
]
rows.append(row)
# Create CSV file in-memory
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
# Write CSV data to the buffer
for row in rows:
writer.writerow(row)
subject = "Your Issue Export is ready"
context = {
"username": exporter_name,
}
html_content = render_to_string("emails/exports/issues.html", context)
text_content = strip_tags(html_content)
csv_buffer.seek(0)
msg = EmailMultiAlternatives(
subject, text_content, settings.EMAIL_FROM, [email]
)
msg.attach(f"{slug}-issues-{timezone.now().date()}.csv", csv_buffer.read(), "text/csv")
msg.send(fail_silently=False)
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
+4
View File
@@ -20,6 +20,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
"schedule": crontab(hour=0, minute=0),
},
"check-every-day-to-delete_exporter_history": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=0, minute=0),
},
}
# Load task modules from all registered Django app configs.
@@ -0,0 +1,243 @@
# Generated by Django 4.2.3 on 2023-08-14 07:12
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.exporter
import plane.db.models.project
import uuid
import random
import string
def generate_display_name(apps, schema_editor):
UserModel = apps.get_model("db", "User")
updated_users = []
for obj in UserModel.objects.all():
obj.display_name = (
obj.email.split("@")[0]
if len(obj.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
updated_users.append(obj)
UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100)
def rectify_field_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
for obj in Model.objects.filter(field="assignee"):
obj.field = "assignees"
updated_activity.append(obj)
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
def update_assignee_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
# Get all the users
User = apps.get_model("db", "User")
users = User.objects.values("id", "email", "display_name")
for obj in Model.objects.filter(field="assignees"):
if bool(obj.new_value) and not bool(obj.old_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.new_value
]
if assigned_user:
obj.new_value = assigned_user[0].get("display_name")
obj.new_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
if bool(obj.old_value) and not bool(obj.new_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.old_value
]
if assigned_user:
obj.old_value = assigned_user[0].get("display_name")
obj.old_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
updated_activity.append(obj)
Model.objects.bulk_update(
updated_activity,
["old_value", "new_value", "old_identifier", "new_identifier", "comment"],
batch_size=200,
)
def update_name_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
update_activity = []
for obj in Model.objects.filter(field="name"):
obj.comment = obj.comment.replace("start date", "name")
update_activity.append(obj)
Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000)
def random_cycle_order(apps, schema_editor):
CycleModel = apps.get_model("db", "Cycle")
updated_cycles = []
for obj in CycleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_cycles.append(obj)
CycleModel.objects.bulk_update(updated_cycles, ["sort_order"], batch_size=100)
def random_module_order(apps, schema_editor):
ModuleModel = apps.get_model("db", "Module")
updated_modules = []
for obj in ModuleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_modules.append(obj)
ModuleModel.objects.bulk_update(updated_modules, ["sort_order"], batch_size=100)
def update_user_issue_properties(apps, schema_editor):
IssuePropertyModel = apps.get_model("db", "IssueProperty")
updated_issue_properties = []
for obj in IssuePropertyModel.objects.all():
obj.properties["start_date"] = True
updated_issue_properties.append(obj)
IssuePropertyModel.objects.bulk_update(
updated_issue_properties, ["properties"], batch_size=100
)
def workspace_member_properties(apps, schema_editor):
WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember")
updated_workspace_members = []
for obj in WorkspaceMemberModel.objects.all():
obj.view_props["properties"]["start_date"] = True
obj.default_props["properties"]["start_date"] = True
updated_workspace_members.append(obj)
WorkspaceMemberModel.objects.bulk_update(
updated_workspace_members, ["view_props", "default_props"], batch_size=100
)
class Migration(migrations.Migration):
dependencies = [
('db', '0040_projectmember_preferences_user_cover_image_and_more'),
]
operations = [
migrations.AddField(
model_name='cycle',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='issuecomment',
name='access',
field=models.CharField(choices=[('INTERNAL', 'INTERNAL'), ('EXTERNAL', 'EXTERNAL')], default='INTERNAL', max_length=100),
),
migrations.AddField(
model_name='module',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='user',
name='display_name',
field=models.CharField(default='', max_length=255),
),
migrations.CreateModel(
name='ExporterHistory',
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)),
('project', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(default=uuid.uuid4), blank=True, null=True, size=None)),
('provider', models.CharField(choices=[('json', 'json'), ('csv', 'csv'), ('xlsx', 'xlsx')], max_length=50)),
('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)),
('reason', models.TextField(blank=True)),
('key', models.TextField(blank=True)),
('url', models.URLField(blank=True, max_length=800, null=True)),
('token', models.CharField(default=plane.db.models.exporter.generate_token, max_length=255, unique=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')),
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to='db.workspace')),
],
options={
'verbose_name': 'Exporter',
'verbose_name_plural': 'Exporters',
'db_table': 'exporters',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='ProjectDeployBoard',
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)),
('anchor', models.CharField(db_index=True, default=plane.db.models.project.get_anchor, max_length=255, unique=True)),
('comments', models.BooleanField(default=False)),
('reactions', models.BooleanField(default=False)),
('votes', models.BooleanField(default=False)),
('views', models.JSONField(default=plane.db.models.project.get_default_views)),
('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')),
('inbox', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bord_inbox', to='db.inbox')),
('project', models.ForeignKey(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')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Project Deploy Board',
'verbose_name_plural': 'Project Deploy Boards',
'db_table': 'project_deploy_boards',
'ordering': ('-created_at',),
'unique_together': {('project', 'anchor')},
},
),
migrations.CreateModel(
name='IssueVote',
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)),
('vote', models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')])),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to=settings.AUTH_USER_MODEL)),
('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')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='db.issue')),
('project', models.ForeignKey(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')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Vote',
'verbose_name_plural': 'Issue Votes',
'db_table': 'issue_votes',
'ordering': ('-created_at',),
'unique_together': {('issue', 'actor')},
},
),
migrations.AlterField(
model_name='modulelink',
name='title',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.RunPython(generate_display_name),
migrations.RunPython(rectify_field_issue_activity),
migrations.RunPython(update_assignee_issue_activity),
migrations.RunPython(update_name_activity),
migrations.RunPython(random_cycle_order),
migrations.RunPython(random_module_order),
migrations.RunPython(update_user_issue_properties),
migrations.RunPython(workspace_member_properties),
]
+5 -1
View File
@@ -18,6 +18,7 @@ from .project import (
ProjectMemberInvite,
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
)
from .issue import (
@@ -36,6 +37,7 @@ from .issue import (
IssueSubscriber,
IssueReaction,
CommentReaction,
IssueVote,
)
from .asset import FileAsset
@@ -72,4 +74,6 @@ from .inbox import Inbox, InboxIssue
from .analytic import AnalyticView
from .notification import Notification
from .notification import Notification
from .exporter import ExporterHistory
+12
View File
@@ -17,6 +17,7 @@ class Cycle(ProjectBaseModel):
related_name="owned_by_cycle",
)
view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
verbose_name = "Cycle"
@@ -24,6 +25,17 @@ class Cycle(ProjectBaseModel):
db_table = "cycles"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Cycle.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(Cycle, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the cycle"""
return f"{self.name} <{self.project.name}>"
+56
View File
@@ -0,0 +1,56 @@
import uuid
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from django.contrib.postgres.fields import ArrayField
# Module imports
from . import BaseModel
def generate_token():
return uuid4().hex
class ExporterHistory(BaseModel):
workspace = models.ForeignKey(
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters"
)
project = ArrayField(models.UUIDField(default=uuid.uuid4), blank=True, null=True)
provider = models.CharField(
max_length=50,
choices=(
("json", "json"),
("csv", "csv"),
("xlsx", "xlsx"),
),
)
status = models.CharField(
max_length=50,
choices=(
("queued", "Queued"),
("processing", "Processing"),
("completed", "Completed"),
("failed", "Failed"),
),
default="queued",
)
reason = models.TextField(blank=True)
key = models.TextField(blank=True)
url = models.URLField(max_length=800, blank=True, null=True)
token = models.CharField(max_length=255, default=generate_token, unique=True)
initiated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="workspace_exporters"
)
class Meta:
verbose_name = "Exporter"
verbose_name_plural = "Exporters"
db_table = "exporters"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the service"""
return f"{self.provider} <{self.workspace.name}>"
+35 -13
View File
@@ -108,11 +108,7 @@ class Issue(ProjectBaseModel):
~models.Q(name="Triage"), project=self.project
).first()
self.state = random_state
if random_state.group == "started":
self.start_date = timezone.now().date()
else:
if default_state.group == "started":
self.start_date = timezone.now().date()
self.state = default_state
except ImportError:
pass
@@ -127,8 +123,6 @@ class Issue(ProjectBaseModel):
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=timezone.now()
)
elif self.state.group == "started":
self.start_date = timezone.now().date()
else:
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=None
@@ -153,9 +147,6 @@ class Issue(ProjectBaseModel):
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
# If adding it to started state
if self.state.group == "started":
self.start_date = timezone.now().date()
# Strip the html tags using html parser
self.description_stripped = (
None
@@ -310,6 +301,14 @@ class IssueComment(ProjectBaseModel):
related_name="comments",
null=True,
)
access = models.CharField(
choices=(
("INTERNAL", "INTERNAL"),
("EXTERNAL", "EXTERNAL"),
),
default="INTERNAL",
max_length=100,
)
def save(self, *args, **kwargs):
self.comment_stripped = (
@@ -425,13 +424,14 @@ class IssueSubscriber(ProjectBaseModel):
class IssueReaction(ProjectBaseModel):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_reactions",
)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_reactions")
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_reactions"
)
reaction = models.CharField(max_length=20)
class Meta:
@@ -446,13 +446,14 @@ class IssueReaction(ProjectBaseModel):
class CommentReaction(ProjectBaseModel):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="comment_reactions",
)
comment = models.ForeignKey(IssueComment, on_delete=models.CASCADE, related_name="comment_reactions")
comment = models.ForeignKey(
IssueComment, on_delete=models.CASCADE, related_name="comment_reactions"
)
reaction = models.CharField(max_length=20)
class Meta:
@@ -466,6 +467,27 @@ class CommentReaction(ProjectBaseModel):
return f"{self.issue.name} {self.actor.email}"
class IssueVote(ProjectBaseModel):
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes")
actor = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes"
)
vote = models.IntegerField(
choices=(
(-1, "DOWNVOTE"),
(1, "UPVOTE"),
)
)
class Meta:
unique_together = ["issue", "actor"]
verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes"
db_table = "issue_votes"
ordering = ("-created_at",)
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)
+13 -1
View File
@@ -40,6 +40,7 @@ class Module(ProjectBaseModel):
through_fields=("module", "member"),
)
view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
unique_together = ["name", "project"]
@@ -48,6 +49,17 @@ class Module(ProjectBaseModel):
db_table = "modules"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Module.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(Module, self).save(*args, **kwargs)
def __str__(self):
return f"{self.name} {self.start_date} {self.target_date}"
@@ -86,7 +98,7 @@ class ModuleIssue(ProjectBaseModel):
class ModuleLink(ProjectBaseModel):
title = models.CharField(max_length=255, null=True)
title = models.CharField(max_length=255, blank=True, null=True)
url = models.URLField()
module = models.ForeignKey(
Module, on_delete=models.CASCADE, related_name="link_module"
+43 -6
View File
@@ -1,3 +1,6 @@
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
@@ -31,12 +34,9 @@ def get_default_props():
"showEmptyGroups": True,
}
def get_default_preferences():
return {
"pages": {
"block_display": True
}
}
return {"pages": {"block_display": True}}
class Project(BaseModel):
@@ -157,7 +157,6 @@ class ProjectMember(ProjectBaseModel):
preferences = models.JSONField(default=get_default_preferences)
sort_order = models.FloatField(default=65535)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = ProjectMember.objects.filter(
@@ -217,3 +216,41 @@ class ProjectFavorite(ProjectBaseModel):
def __str__(self):
"""Return user of the project"""
return f"{self.user.email} <{self.project.name}>"
def get_anchor():
return uuid4().hex
def get_default_views():
return {
"list": True,
"kanban": True,
"calendar": True,
"gantt": True,
"spreadsheet": True,
}
class ProjectDeployBoard(ProjectBaseModel):
anchor = models.CharField(
max_length=255, default=get_anchor, unique=True, db_index=True
)
comments = models.BooleanField(default=False)
reactions = models.BooleanField(default=False)
inbox = models.ForeignKey(
"db.Inbox", related_name="bord_inbox", on_delete=models.SET_NULL, null=True
)
votes = models.BooleanField(default=False)
views = models.JSONField(default=get_default_views)
class Meta:
unique_together = ["project", "anchor"]
verbose_name = "Project Deploy Board"
verbose_name_plural = "Project Deploy Boards"
db_table = "project_deploy_boards"
ordering = ("-created_at",)
def __str__(self):
"""Return project and anchor"""
return f"{self.anchor} <{self.project.name}>"
+12 -1
View File
@@ -1,6 +1,7 @@
# Python imports
from enum import unique
import uuid
import string
import random
# Django imports
from django.db import models
@@ -18,6 +19,7 @@ from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
def get_default_onboarding():
return {
"profile_complete": False,
@@ -26,6 +28,7 @@ def get_default_onboarding():
"workspace_join": False,
}
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
@@ -81,6 +84,7 @@ class User(AbstractBaseUser, PermissionsMixin):
role = models.CharField(max_length=300, null=True, blank=True)
is_bot = models.BooleanField(default=False)
theme = models.JSONField(default=dict)
display_name = models.CharField(max_length=255, default="")
is_tour_completed = models.BooleanField(default=False)
onboarding_step = models.JSONField(default=get_default_onboarding)
@@ -107,6 +111,13 @@ class User(AbstractBaseUser, PermissionsMixin):
self.token = uuid.uuid4().hex + uuid.uuid4().hex
self.token_updated_at = timezone.now()
if not self.display_name:
self.display_name = (
self.email.split("@")[0]
if len(self.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
if self.is_superuser:
self.is_staff = True
+1
View File
@@ -33,6 +33,7 @@ def get_default_props():
"estimate": True,
"created_on": True,
"updated_on": True,
"start_date": True,
},
"showEmptyGroups": True,
}
+1 -1
View File
@@ -214,4 +214,4 @@ SIMPLE_JWT = {
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task",)
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task","plane.bgtasks.exporter_expired_task")
+29 -16
View File
@@ -124,10 +124,11 @@ def filter_created_at(params, filter, method):
else:
if params.get("created_at", None) and len(params.get("created_at")):
for query in params.get("created_at"):
if query.get("timeline", "after") == "after":
filter["created_at__date__gte"] = query.get("datetime")
created_at_query = query.split(";")
if len(created_at_query) == 2 and "after" in created_at_query:
filter["created_at__date__gte"] = created_at_query[0]
else:
filter["created_at__date__lte"] = query.get("datetime")
filter["created_at__date__lte"] = created_at_query[0]
return filter
@@ -144,10 +145,11 @@ def filter_updated_at(params, filter, method):
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
for query in params.get("updated_at"):
if query.get("timeline", "after") == "after":
filter["updated_at__date__gte"] = query.get("datetime")
updated_at_query = query.split(";")
if len(updated_at_query) == 2 and "after" in updated_at_query:
filter["updated_at__date__gte"] = updated_at_query[0]
else:
filter["updated_at__date__lte"] = query.get("datetime")
filter["updated_at__date__lte"] = updated_at_query[0]
return filter
@@ -164,10 +166,11 @@ def filter_start_date(params, filter, method):
else:
if params.get("start_date", None) and len(params.get("start_date")):
for query in params.get("start_date"):
if query.get("timeline", "after") == "after":
filter["start_date__gte"] = query.get("datetime")
start_date_query = query.split(";")
if len(start_date_query) == 2 and "after" in start_date_query:
filter["start_date__gte"] = start_date_query[0]
else:
filter["start_date__lte"] = query.get("datetime")
filter["start_date__lte"] = start_date_query[0]
return filter
@@ -184,10 +187,11 @@ def filter_target_date(params, filter, method):
else:
if params.get("target_date", None) and len(params.get("target_date")):
for query in params.get("target_date"):
if query.get("timeline", "after") == "after":
filter["target_date__gt"] = query.get("datetime")
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gt"] = target_date_query[0]
else:
filter["target_date__lt"] = query.get("datetime")
filter["target_date__lt"] = target_date_query[0]
return filter
@@ -205,10 +209,11 @@ def filter_completed_at(params, filter, method):
else:
if params.get("completed_at", None) and len(params.get("completed_at")):
for query in params.get("completed_at"):
if query.get("timeline", "after") == "after":
filter["completed_at__date__gte"] = query.get("datetime")
completed_at_query = query.split(";")
if len(completed_at_query) == 2 and "after" in completed_at_query:
filter["completed_at__date__gte"] = completed_at_query[0]
else:
filter["completed_at__lte"] = query.get("datetime")
filter["completed_at__lte"] = completed_at_query[0]
return filter
@@ -292,9 +297,16 @@ def filter_subscribed_issues(params, filter, method):
return filter
def filter_start_target_date_issues(params, filter, method):
start_target_date = params.get("start_target_date", "false")
if start_target_date == "true":
filter["target_date__isnull"] = False
filter["start_date__isnull"] = False
return filter
def issue_filters(query_params, method):
filter = dict()
print(query_params)
ISSUE_FILTER = {
"state": filter_state,
@@ -318,6 +330,7 @@ def issue_filters(query_params, method):
"inbox_status": filter_inbox_status,
"sub_issue": filter_sub_issue_toggle,
"subscriber": filter_subscribed_issues,
"start_target_date": filter_start_target_date_issues,
}
for key, value in ISSUE_FILTER.items():
+2 -1
View File
@@ -32,4 +32,5 @@ celery==5.3.1
django_celery_beat==2.5.0
psycopg-binary==3.1.9
psycopg-c==3.1.9
scout-apm==2.26.1
scout-apm==2.26.1
openpyxl==3.1.2
-85
View File
@@ -1,85 +0,0 @@
{
"name": "Plane",
"description": "Plane helps you track your issues, epics, and product roadmaps.",
"repository": "http://github.com/makeplane/plane",
"logo": "https://avatars.githubusercontent.com/u/115727700?s=200&v=4",
"website": "https://plane.so/",
"success_url": "/",
"stack": "heroku-22",
"keywords": [
"plane",
"project management",
"django",
"next"
],
"addons": [
"heroku-postgresql:mini",
"heroku-redis:mini"
],
"buildpacks": [
{
"url": "https://github.com/heroku/heroku-buildpack-python.git"
},
{
"url": "https://github.com/heroku/heroku-buildpack-nodejs#v176"
}
],
"env": {
"EMAIL_HOST": {
"description": "Email host to send emails from",
"value": ""
},
"EMAIL_HOST_USER": {
"description": "Email host to send emails from",
"value": ""
},
"EMAIL_HOST_PASSWORD": {
"description": "Email host to send emails from",
"value": ""
},
"EMAIL_FROM": {
"description": "Email Sender",
"value": ""
},
"EMAIL_PORT": {
"description": "The default Email PORT to use",
"value": "587"
},
"AWS_REGION": {
"description": "AWS Region to use for S3",
"value": "false"
},
"AWS_ACCESS_KEY_ID": {
"description": "AWS Access Key ID to use for S3",
"value": ""
},
"AWS_SECRET_ACCESS_KEY": {
"description": "AWS Secret Access Key to use for S3",
"value": ""
},
"AWS_S3_BUCKET_NAME": {
"description": "AWS Bucket Name to use for S3",
"value": ""
},
"SENTRY_DSN": {
"description": "",
"value": ""
},
"WEB_URL": {
"description": "Web URL for Plane this will be used for redirections in the emails",
"value": ""
},
"GITHUB_CLIENT_SECRET": {
"description": "Github Client Secret",
"value": ""
},
"NEXT_PUBLIC_API_BASE_URL": {
"description": "Next Public API Base URL",
"value": ""
},
"SECRET_KEY": {
"description": "Django Secret Key",
"value": ""
}
}
}
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
root: true,
extends: ["custom"],
rules: {
"@next/next/no-img-element": "off",
},
};
-11
View File
@@ -1,11 +0,0 @@
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
COPY . .
RUN yarn global add turbo
RUN yarn install
EXPOSE 3000
CMD ["yarn","dev"]
@@ -1,161 +0,0 @@
import { useState, useEffect } from "react";
// next-themes
import { useTheme } from "next-themes";
// services
import userService from "services/user.service";
// hooks
import useUser from "hooks/use-user";
// constants
import { THEMES_OBJ } from "constants/themes";
// ui
import { CustomSelect } from "components/ui";
// types
import { ICustomTheme } from "types";
import { unsetCustomCssVariables } from "helpers/theme.helper";
type Props = {
setPreLoadedData: React.Dispatch<React.SetStateAction<ICustomTheme | null>>;
customThemeSelectorOptions: boolean;
setCustomThemeSelectorOptions: React.Dispatch<React.SetStateAction<boolean>>;
};
export const ThemeSwitch: React.FC<Props> = ({
setPreLoadedData,
customThemeSelectorOptions,
setCustomThemeSelectorOptions,
}) => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
const { user, mutateUser } = useUser();
const updateUserTheme = (newTheme: string) => {
if (!user) return;
setTheme(newTheme);
mutateUser((prevData) => {
if (!prevData) return prevData;
return {
...prevData,
theme: {
...prevData.theme,
theme: newTheme,
},
};
}, false);
userService.updateUser({
theme: {
...user.theme,
theme: newTheme,
},
});
};
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
const currentThemeObj = THEMES_OBJ.find((t) => t.value === theme);
return (
<CustomSelect
value={theme}
label={
currentThemeObj ? (
<div className="flex items-center gap-2">
<div
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
style={{
borderColor: currentThemeObj.icon.border,
}}
>
<div
className="h-full w-1/2 rounded-l-full"
style={{
background: currentThemeObj.icon.color1,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: currentThemeObj.icon.border,
background: currentThemeObj.icon.color2,
}}
/>
</div>
{currentThemeObj.label}
</div>
) : (
"Select your theme"
)
}
onChange={({ value, type }: { value: string; type: string }) => {
if (value === "custom") {
if (user?.theme.palette) {
setPreLoadedData({
background: user.theme.background !== "" ? user.theme.background : "#0d101b",
text: user.theme.text !== "" ? user.theme.text : "#c5c5c5",
primary: user.theme.primary !== "" ? user.theme.primary : "#3f76ff",
sidebarBackground:
user.theme.sidebarBackground !== "" ? user.theme.sidebarBackground : "#0d101b",
sidebarText: user.theme.sidebarText !== "" ? user.theme.sidebarText : "#c5c5c5",
darkPalette: false,
palette:
user.theme.palette !== ",,,,"
? user.theme.palette
: "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
theme: "custom",
});
}
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
} else {
if (customThemeSelectorOptions) setCustomThemeSelectorOptions(false);
unsetCustomCssVariables();
}
updateUserTheme(value);
document.documentElement.style.setProperty("--color-scheme", type);
}}
input
width="w-full"
position="right"
>
{THEMES_OBJ.map(({ value, label, type, icon }) => (
<CustomSelect.Option key={value} value={{ value, type }}>
<div className="flex items-center gap-2">
<div
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
style={{
borderColor: icon.border,
}}
>
<div
className="h-full w-1/2 rounded-l-full"
style={{
background: icon.color1,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: icon.border,
background: icon.color2,
}}
/>
</div>
{label}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
);
};
@@ -1,81 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// types
import { ICycle } from "types";
type Props = {
cycles: ICycle[];
};
export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: { data: ICycle }) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
if (_block?.start_date && _block.target_date) console.log("_block", _block);
return {
start_date: new Date(_block.created_at),
target_date: new Date(_block.updated_at),
data: _block,
};
})
: [];
return (
<div className="w-full h-full overflow-y-auto">
<GanttChartRoot
title={"Cycles"}
loaderTitle="Cycles"
blocks={cycles ? blockFormat(cycles) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
-109
View File
@@ -1,109 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
type Props = {};
export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { ganttIssues, mutateGanttIssues } = useGanttChartCycleIssues(
workspaceSlug as string,
projectId as string,
cycleId as string
);
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: any) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
{data.infoToggle && (
<Tooltip
tooltipContent={`No due-date set, rendered according to last updated date.`}
className={`z-[999999]`}
>
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
<span className="material-symbols-rounded text-custom-text-200 text-[18px]">
info
</span>
</div>
</Tooltip>
)}
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
console.log("payload", payload);
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
let startDate = new Date(_block.created_at);
let targetDate = new Date(_block.updated_at);
let infoToggle = true;
if (_block?.start_date && _block.target_date) {
startDate = _block?.start_date;
targetDate = _block.target_date;
infoToggle = false;
}
return {
start_date: new Date(startDate),
target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block,
};
})
: [];
return (
<div className="w-full h-full p-3">
<GanttChartRoot
title="Cycles"
loaderTitle="Cycles"
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
@@ -1,82 +0,0 @@
import { FC, useEffect, useState } from "react";
// helpers
import { ChartDraggable } from "../helpers/draggable";
// data
import { datePreview } from "../data";
export const GanttChartBlocks: FC<{
itemsContainerWidth: number;
blocks: null | any[];
sidebarBlockRender: FC;
blockRender: FC;
}> = ({ itemsContainerWidth, blocks, sidebarBlockRender, blockRender }) => {
const handleChartBlockPosition = (block: any) => {
// setChartBlocks((prevData: any) =>
// prevData.map((_block: any) => (_block?.data?.id == block?.data?.id ? block : _block))
// );
};
return (
<div
className="relative z-[5] mt-[58px] h-full w-[4000px] divide-x divide-gray-300 overflow-hidden overflow-y-auto"
style={{ width: `${itemsContainerWidth}px` }}
>
<div className="w-full">
{blocks &&
blocks.length > 0 &&
blocks.map((block: any, _idx: number) => (
<>
{block.start_date && block.target_date && (
<ChartDraggable
className="relative flex h-[40px] items-center"
key={`blocks-${_idx}`}
block={block}
handleBlock={handleChartBlockPosition}
>
<div
className="relative group inline-flex cursor-pointer items-center font-medium transition-all"
style={{ marginLeft: `${block?.position?.marginLeft}px` }}
>
<div className="flex-shrink-0 relative w-0 h-0 flex items-center invisible group-hover:visible whitespace-nowrap">
<div className="absolute right-0 mr-[5px] rounded-sm bg-custom-background-90 px-2 py-0.5 text-xs font-medium">
{block?.start_date ? datePreview(block?.start_date) : "-"}
</div>
</div>
<div
className="rounded shadow-sm bg-custom-background-100 overflow-hidden relative flex items-center h-[34px] border border-custom-border-200"
style={{
width: `${block?.position?.width}px`,
}}
>
{blockRender({
...block?.data,
infoToggle: block?.infoToggle ? true : false,
})}
</div>
<div className="flex-shrink-0 relative w-0 h-0 flex items-center invisible group-hover:visible whitespace-nowrap">
<div className="absolute left-0 ml-[5px] mr-[5px] rounded-sm bg-custom-background-90 px-2 py-0.5 text-xs font-medium">
{block?.target_date ? datePreview(block?.target_date) : "-"}
</div>
</div>
</div>
</ChartDraggable>
)}
</>
))}
</div>
{/* sidebar */}
{/* <div className="fixed top-0 bottom-0 w-[300px] flex-shrink-0 divide-y divide-custom-border-200 border-r border-custom-border-200 overflow-y-auto">
{blocks &&
blocks.length > 0 &&
blocks.map((block: any, _idx: number) => (
<div className="relative h-[40px] bg-custom-background-100" key={`sidebar-blocks-${_idx}`}>
{sidebarBlockRender(block?.data)}
</div>
))}
</div> */}
</div>
);
};
@@ -1,138 +0,0 @@
import { useState, useRef } from "react";
export const ChartDraggable = ({ children, block, handleBlock, className }: any) => {
const [dragging, setDragging] = useState(false);
const [chartBlockPositionLeft, setChartBlockPositionLeft] = useState(0);
const [blockPositionLeft, setBlockPositionLeft] = useState(0);
const [dragBlockOffsetX, setDragBlockOffsetX] = useState(0);
const handleMouseDown = (event: any) => {
const chartBlockPositionLeft: number = block.position.marginLeft;
const blockPositionLeft: number = event.target.getBoundingClientRect().left;
const dragBlockOffsetX: number = event.clientX - event.target.getBoundingClientRect().left;
console.log("--------------------");
console.log("chartBlockPositionLeft", chartBlockPositionLeft);
console.log("blockPositionLeft", blockPositionLeft);
console.log("dragBlockOffsetX", dragBlockOffsetX);
console.log("-->");
setDragging(true);
setChartBlockPositionLeft(chartBlockPositionLeft);
setBlockPositionLeft(blockPositionLeft);
setDragBlockOffsetX(dragBlockOffsetX);
};
const handleMouseMove = (event: any) => {
if (!dragging) return;
const currentBlockPosition = event.clientX - dragBlockOffsetX;
console.log("currentBlockPosition", currentBlockPosition);
if (currentBlockPosition <= blockPositionLeft) {
const updatedPosition = chartBlockPositionLeft - (blockPositionLeft - currentBlockPosition);
console.log("updatedPosition", updatedPosition);
handleBlock({ ...block, position: { ...block.position, marginLeft: updatedPosition } });
} else {
const updatedPosition = chartBlockPositionLeft + (blockPositionLeft - currentBlockPosition);
console.log("updatedPosition", updatedPosition);
handleBlock({ ...block, position: { ...block.position, marginLeft: updatedPosition } });
}
console.log("--------------------");
};
const handleMouseUp = () => {
setDragging(false);
setChartBlockPositionLeft(0);
setBlockPositionLeft(0);
setDragBlockOffsetX(0);
};
return (
<div
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
className={`${className ? className : ``}`}
>
{children}
</div>
);
};
// import { useState } from "react";
// export const ChartDraggable = ({ children, id, className = "", style }: any) => {
// const [dragging, setDragging] = useState(false);
// const [chartBlockPositionLeft, setChartBlockPositionLeft] = useState(0);
// const [blockPositionLeft, setBlockPositionLeft] = useState(0);
// const [dragBlockOffsetX, setDragBlockOffsetX] = useState(0);
// const handleDragStart = (event: any) => {
// // event.dataTransfer.setData("text/plain", event.target.id);
// const chartBlockPositionLeft: number = parseInt(event.target.style.left.slice(0, -2));
// const blockPositionLeft: number = event.target.getBoundingClientRect().left;
// const dragBlockOffsetX: number = event.clientX - event.target.getBoundingClientRect().left;
// console.log("chartBlockPositionLeft", chartBlockPositionLeft);
// console.log("blockPositionLeft", blockPositionLeft);
// console.log("dragBlockOffsetX", dragBlockOffsetX);
// console.log("--------------------");
// setDragging(true);
// setChartBlockPositionLeft(chartBlockPositionLeft);
// setBlockPositionLeft(blockPositionLeft);
// setDragBlockOffsetX(dragBlockOffsetX);
// };
// const handleDragEnd = () => {
// setDragging(false);
// setChartBlockPositionLeft(0);
// setBlockPositionLeft(0);
// setDragBlockOffsetX(0);
// };
// const handleDragOver = (event: any) => {
// event.preventDefault();
// if (dragging) {
// const scrollContainer = document.getElementById(`block-parent-${id}`) as HTMLElement;
// const currentBlockPosition = event.clientX - dragBlockOffsetX;
// console.log('currentBlockPosition')
// if (currentBlockPosition <= blockPositionLeft) {
// const updatedPosition = chartBlockPositionLeft - (blockPositionLeft - currentBlockPosition);
// console.log("updatedPosition", updatedPosition);
// if (scrollContainer) scrollContainer.style.left = `${updatedPosition}px`;
// } else {
// const updatedPosition = chartBlockPositionLeft + (blockPositionLeft - currentBlockPosition);
// console.log("updatedPosition", updatedPosition);
// if (scrollContainer) scrollContainer.style.left = `${updatedPosition}px`;
// }
// console.log("--------------------");
// }
// };
// const handleDrop = (event: any) => {
// event.preventDefault();
// setDragging(false);
// setChartBlockPositionLeft(0);
// setBlockPositionLeft(0);
// setDragBlockOffsetX(0);
// };
// return (
// <div
// id={id}
// draggable
// onDragStart={handleDragStart}
// onDragEnd={handleDragEnd}
// onDragOver={handleDragOver}
// onDrop={handleDrop}
// className={`${className} ${dragging ? "dragging" : ""}`}
// style={style}
// >
// {children}
// </div>
// );
// };
-1
View File
@@ -1 +0,0 @@
export * from "./root";
-107
View File
@@ -1,107 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks
import useGanttChartIssues from "hooks/gantt-chart/issue-view";
type Props = {};
export const IssueGanttChartView: FC<Props> = ({}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { ganttIssues, mutateGanttIssues } = useGanttChartIssues(
workspaceSlug as string,
projectId as string
);
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "#rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: any) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm font-normal">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
{data.infoToggle && (
<Tooltip
tooltipContent={`No due-date set, rendered according to last updated date.`}
className={`z-[999999]`}
>
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
<span className="material-symbols-rounded text-custom-text-200 text-[18px]">
info
</span>
</div>
</Tooltip>
)}
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
let startDate = new Date(_block.created_at);
let targetDate = new Date(_block.updated_at);
let infoToggle = true;
if (_block?.start_date && _block.target_date) {
startDate = _block?.start_date;
targetDate = _block.target_date;
infoToggle = false;
}
return {
start_date: new Date(startDate),
target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block,
};
})
: [];
return (
<div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
@@ -1,201 +0,0 @@
import React, { forwardRef, useEffect } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// hooks
import useUserAuth from "hooks/use-user-auth";
// react-color
import { TwitterPicker } from "react-color";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// services
import issuesService from "services/issues.service";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
// types
import { IIssueLabels } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
type Props = {
labelForm: boolean;
setLabelForm: React.Dispatch<React.SetStateAction<boolean>>;
isUpdating: boolean;
labelToUpdate: IIssueLabels | null;
};
const defaultValues: Partial<IIssueLabels> = {
name: "",
color: "rgb(var(--color-text-200))",
};
type Ref = HTMLDivElement;
export const CreateUpdateLabelInline = forwardRef<Ref, Props>(function CreateUpdateLabelInline(
{ labelForm, setLabelForm, isUpdating, labelToUpdate },
ref
) {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUserAuth();
const {
handleSubmit,
control,
register,
reset,
formState: { errors, isSubmitting },
watch,
setValue,
} = useForm<IIssueLabels>({
defaultValues,
});
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
await issuesService
.createIssueLabel(workspaceSlug as string, projectId as string, formData, user)
.then((res) => {
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) => [res, ...(prevData ?? [])],
false
);
reset(defaultValues);
setLabelForm(false);
});
};
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
await issuesService
.patchIssueLabel(
workspaceSlug as string,
projectId as string,
labelToUpdate?.id ?? "",
formData,
user
)
.then(() => {
reset(defaultValues);
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) =>
prevData?.map((p) => (p.id === labelToUpdate?.id ? { ...p, ...formData } : p)),
false
);
setLabelForm(false);
});
};
useEffect(() => {
if (!labelForm && isUpdating) return;
reset();
}, [labelForm, isUpdating, reset]);
useEffect(() => {
if (!labelToUpdate) return;
setValue(
"color",
labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000"
);
setValue("name", labelToUpdate.name);
}, [labelToUpdate, setValue]);
return (
<div
className={`flex scroll-m-8 items-center gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 ${
labelForm ? "" : "hidden"
}`}
ref={ref}
>
<div className="flex-shrink-0">
<Popover className="relative z-10 flex h-full w-full items-center justify-center">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex items-center text-base font-medium focus:outline-none ${
open ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<span
className="h-5 w-5 rounded"
style={{
backgroundColor: watch("color"),
}}
/>
<ChevronDownIcon
className={`ml-2 h-5 w-5 group-hover:text-custom-text-200 ${
open ? "text-gray-600" : "text-gray-400"
}`}
aria-hidden="true"
/>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-full left-0 z-20 mt-3 w-screen max-w-xs px-2 sm:px-0">
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div className="flex flex-1 flex-col justify-center">
<Input
type="text"
id="labelName"
name="name"
register={register}
placeholder="Label title"
validations={{
required: "Label title is required",
}}
error={errors.name}
/>
</div>
<SecondaryButton
onClick={() => {
reset();
setLabelForm(false);
}}
>
Cancel
</SecondaryButton>
{isUpdating ? (
<PrimaryButton onClick={handleSubmit(handleLabelUpdate)} loading={isSubmitting}>
{isSubmitting ? "Updating" : "Update"}
</PrimaryButton>
) : (
<PrimaryButton onClick={handleSubmit(handleLabelCreate)} loading={isSubmitting}>
{isSubmitting ? "Adding" : "Add"}
</PrimaryButton>
)}
</div>
);
});
-109
View File
@@ -1,109 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks
import useGanttChartModuleIssues from "hooks/gantt-chart/module-issues-view";
type Props = {};
export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
const { ganttIssues, mutateGanttIssues } = useGanttChartModuleIssues(
workspaceSlug as string,
projectId as string,
moduleId as string
);
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: any) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
{data.infoToggle && (
<Tooltip
tooltipContent={`No due-date set, rendered according to last updated date.`}
className={`z-[999999]`}
>
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
<span className="material-symbols-rounded text-custom-text-200 text-[18px]">
info
</span>
</div>
</Tooltip>
)}
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
console.log("payload", payload);
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
let startDate = new Date(_block.created_at);
let targetDate = new Date(_block.updated_at);
let infoToggle = true;
if (_block?.start_date && _block.target_date) {
startDate = _block?.start_date;
targetDate = _block.target_date;
infoToggle = false;
}
return {
start_date: new Date(startDate),
target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block,
};
})
: [];
return (
<div className="w-full h-full p-3">
<GanttChartRoot
title="Modules"
loaderTitle="Modules"
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
@@ -1,85 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// types
import { IModule } from "types";
// constants
import { MODULE_STATUS } from "constants/module";
type Props = {
modules: IModule[];
};
export const ModulesListGanttChartView: FC<Props> = ({ modules }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{
backgroundColor: MODULE_STATUS.find((s) => s.value === data.status)?.color,
}}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: { data: IModule }) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/modules/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === data.status)?.color }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
if (_block?.start_date && _block.target_date) console.log("_block", _block);
return {
start_date: new Date(_block.created_at),
target_date: new Date(_block.updated_at),
data: _block,
};
})
: [];
return (
<div className="w-full h-full overflow-y-auto">
<GanttChartRoot
title="Modules"
loaderTitle="Modules"
blocks={modules ? blockFormat(modules) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
@@ -1,91 +0,0 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import userService from "services/user.service";
// components
import { ActivityMessage } from "components/core";
// ui
import { Icon, Loader } from "components/ui";
// helpers
import { timeAgo } from "helpers/date-time.helper";
// fetch-keys
import { USER_PROFILE_ACTIVITY } from "constants/fetch-keys";
export const ProfileActivity = () => {
const router = useRouter();
const { workspaceSlug, userId } = router.query;
const { data: userProfileActivity } = useSWR(
workspaceSlug && userId
? USER_PROFILE_ACTIVITY(workspaceSlug.toString(), userId.toString())
: null,
workspaceSlug && userId
? () => userService.getUserProfileActivity(workspaceSlug.toString(), userId.toString())
: null
);
return (
<div className="space-y-2">
<h3 className="text-lg font-medium">Recent Activity</h3>
<div className="border border-custom-border-100 rounded p-6">
{userProfileActivity ? (
<div className="space-y-5">
{userProfileActivity.results.map((activity) => (
<div key={activity.id} className="flex gap-3">
<div className="flex-shrink-0">
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
<img
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
height={24}
width={24}
className="rounded"
/>
) : (
<div className="grid h-6 w-6 place-items-center rounded border-2 bg-gray-700 text-xs text-white">
{activity.actor_detail.first_name.charAt(0)}
</div>
)}
</div>
<div className="-mt-1 w-4/5 break-words">
<p className="text-sm text-custom-text-200">
<span className="font-medium text-custom-text-100">
{activity.actor_detail.first_name} {activity.actor_detail.last_name}{" "}
</span>
{activity.field ? (
<ActivityMessage activity={activity} showIssue />
) : (
<span>
created this{" "}
<a
href={`/${workspaceSlug}/projects/${activity.project}/issues/${activity.issue}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
Issue
<Icon iconName="launch" className="!text-xs" />
</a>
</span>
)}
</p>
<p className="text-xs text-custom-text-200">{timeAgo(activity.created_at)}</p>
</div>
</div>
))}
</div>
) : (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</div>
</div>
);
};
@@ -1,75 +0,0 @@
// ui
import { BarGraph, Loader } from "components/ui";
// helpers
import { capitalizeFirstLetter } from "helpers/string.helper";
// types
import { IUserProfileData } from "types";
type Props = {
userProfile: IUserProfileData | undefined;
};
export const ProfilePriorityDistribution: React.FC<Props> = ({ userProfile }) => (
<div className="space-y-2">
<h3 className="text-lg font-medium">Issues by Priority</h3>
{userProfile ? (
<div className="border border-custom-border-100 rounded">
<BarGraph
data={userProfile.priority_distribution.map((priority) => ({
priority: capitalizeFirstLetter(priority.priority ?? "None"),
value: priority.priority_count,
}))}
height="300px"
indexBy="priority"
keys={["value"]}
borderRadius={4}
padding={0.7}
customYAxisTickValues={userProfile.priority_distribution.map((p) => p.priority_count)}
tooltip={(datum) => (
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: datum.color,
}}
/>
<span className="font-medium text-custom-text-200">{datum.data.priority}:</span>
<span>{datum.value}</span>
</div>
)}
colors={(datum) => {
if (datum.data.priority === "Urgent") return "#991b1b";
else if (datum.data.priority === "High") return "#ef4444";
else if (datum.data.priority === "Medium") return "#f59e0b";
else if (datum.data.priority === "Low") return "#16a34a";
else return "#e5e5e5";
}}
theme={{
axis: {
domain: {
line: {
stroke: "transparent",
},
},
},
grid: {
line: {
stroke: "transparent",
},
},
}}
/>
</div>
) : (
<div className="grid place-items-center p-7">
<Loader className="flex items-end gap-12">
<Loader.Item width="30px" height="200px" />
<Loader.Item width="30px" height="150px" />
<Loader.Item width="30px" height="250px" />
<Loader.Item width="30px" height="150px" />
<Loader.Item width="30px" height="100px" />
</Loader>
</div>
)}
</div>
);
@@ -1,81 +0,0 @@
// ui
import { PieGraph } from "components/ui";
// types
import { IUserProfileData, IUserStateDistribution } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
stateDistribution: IUserStateDistribution[];
userProfile: IUserProfileData | undefined;
};
export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, userProfile }) => {
if (!userProfile) return null;
return (
<div className="space-y-2">
<h3 className="text-lg font-medium">Issues by State</h3>
<div className="border border-custom-border-100 rounded p-7">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<div>
<PieGraph
data={
userProfile.state_distribution.map((group) => ({
id: group.state_group,
label: group.state_group,
value: group.state_count,
color: STATE_GROUP_COLORS[group.state_group],
})) ?? []
}
height="250px"
innerRadius={0.6}
cornerRadius={5}
padAngle={2}
enableArcLabels
arcLabelsTextColor="#000000"
enableArcLinkLabels={false}
activeInnerRadiusOffset={5}
colors={(datum) => datum.data.color}
tooltip={(datum) => (
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 p-2 text-xs">
<span className="text-custom-text-200 capitalize">
{datum.datum.label} issues:
</span>{" "}
{datum.datum.value}
</div>
)}
margin={{
top: 32,
right: 0,
bottom: 32,
left: 0,
}}
/>
</div>
<div className="flex items-center">
<div className="space-y-4 w-full">
{stateDistribution.map((group) => (
<div
key={group.state_group}
className="flex items-center justify-between gap-2 text-xs"
>
<div className="flex items-center gap-1.5">
<div
className="h-2.5 w-2.5 rounded-sm"
style={{
backgroundColor: STATE_GROUP_COLORS[group.state_group],
}}
/>
<div className="capitalize whitespace-nowrap">{group.state_group}</div>
</div>
<div>{group.state_count}</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
@@ -1,249 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { mutate } from "swr";
// services
import projectService from "services/project.service";
// hooks
import useProjectMembers from "hooks/use-project-members";
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, Loader, Tooltip } from "components/ui";
// icons
import {
CalendarDaysIcon,
LinkIcon,
PencilIcon,
PlusIcon,
StarIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types
import type { IProject } from "types";
// fetch-keys
import { PROJECTS_LIST } from "constants/fetch-keys";
export type ProjectCardProps = {
project: IProject;
setToJoinProject: (id: string | null) => void;
setDeleteProject: (id: string | null) => void;
};
export const SingleProjectCard: React.FC<ProjectCardProps> = ({
project,
setToJoinProject,
setDeleteProject,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
// fetching project members information
const { members, hasJoined, isOwner, isMember } = useProjectMembers(
workspaceSlug as string,
project.id
);
const handleAddToFavorites = () => {
if (!workspaceSlug) return;
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
false
);
projectService
.addProjectToFavorites(workspaceSlug as string, {
project: project.id,
})
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully added the project to favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !project) return;
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)),
false
);
projectService
.removeProjectFromFavorites(workspaceSlug as string, project.id)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully removed the project from favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
});
});
};
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${project.id}/issues`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Project link copied to clipboard.",
});
});
};
return (
<>
{members ? (
<div className="flex flex-col rounded-[10px] bg-custom-background-90 shadow">
<Link href={`/${workspaceSlug as string}/projects/${project.id}/issues`}>
<a>
<div className="relative h-32 w-full rounded-t-[10px]">
<img
src={
project.cover_image ??
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"
}
alt={project.name}
className="absolute top-0 left-0 h-full w-full object-cover rounded-t-[10px]"
/>
<div className="absolute bottom-4 left-4 flex items-center gap-3 text-white">
{!hasJoined ? (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setToJoinProject(project.id);
}}
className="flex cursor-pointer items-center gap-1 rounded bg-green-600 px-2 py-1 text-xs"
>
<PlusIcon className="h-3 w-3" />
<span>Select to Join</span>
</button>
) : (
<span className="cursor-default rounded bg-green-600 px-2 py-1 text-xs">
Member
</span>
)}
{project.is_favorite && (
<span className="grid h-6 w-9 cursor-default place-items-center rounded bg-orange-400">
<StarIcon className="h-3 w-3" />
</span>
)}
</div>
</div>
</a>
</Link>
<div className="flex h-full flex-col rounded-b-[10px] p-4 text-custom-text-200">
<Link href={`/${workspaceSlug as string}/projects/${project.id}/issues`}>
<a>
<div className="flex items-center gap-1">
<h3 className="text-1.5xl font-medium text-custom-text-100">{project.name}</h3>
{project.emoji ? (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
{renderEmoji(project.emoji)}
</span>
) : project.icon_prop ? (
renderEmoji(project.icon_prop)
) : null}
</div>
<p className="mt-3.5 mb-7 break-words">
{truncateText(project.description ?? "", 100)}
</p>
</a>
</Link>
<div className="flex h-full items-end justify-between">
<Tooltip
tooltipContent={`Created at ${renderShortDateWithYearFormat(project.created_at)}`}
position="bottom"
>
<div className="flex cursor-default items-center gap-1.5 text-xs">
<CalendarDaysIcon className="h-4 w-4" />
{renderShortDateWithYearFormat(project.created_at)}
</div>
</Tooltip>
{hasJoined ? (
<div className="flex items-center">
{(isOwner || isMember) && (
<Link href={`/${workspaceSlug}/projects/${project.id}/settings`}>
<a className="grid cursor-pointer place-items-center rounded p-1 duration-300 hover:bg-custom-background-90">
<PencilIcon className="h-4 w-4" />
</a>
</Link>
)}
<CustomMenu width="auto" verticalEllipsis>
{isOwner && (
<CustomMenu.MenuItem onClick={() => setDeleteProject(project.id)}>
<span className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete project</span>
</span>
</CustomMenu.MenuItem>
)}
{project.is_favorite ? (
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
) : (
<CustomMenu.MenuItem onClick={handleAddToFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" />
<span>Add to favorites</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy project link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
) : null}
</div>
</div>
</div>
) : (
<Loader>
<Loader.Item height="144px" />
</Loader>
)}
</>
);
};
@@ -1,313 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-beautiful-dnd
import { DraggableProvided, DraggableStateSnapshot } from "react-beautiful-dnd";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// services
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, Tooltip } from "components/ui";
// icons
import { EllipsisVerticalIcon, LinkIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
import {
ArchiveOutlined,
ArticleOutlined,
ContrastOutlined,
DatasetOutlined,
ExpandMoreOutlined,
FilterNoneOutlined,
PhotoFilterOutlined,
SettingsOutlined,
} from "@mui/icons-material";
// helpers
import { truncateText } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types
import { IProject } from "types";
// fetch-keys
import { PROJECTS_LIST } from "constants/fetch-keys";
type Props = {
project: IProject;
sidebarCollapse: boolean;
provided?: DraggableProvided;
snapshot?: DraggableStateSnapshot;
handleDeleteProject: () => void;
handleCopyText: () => void;
shortContextMenu?: boolean;
};
const navigation = (workspaceSlug: string, projectId: string) => [
{
name: "Issues",
href: `/${workspaceSlug}/projects/${projectId}/issues`,
Icon: FilterNoneOutlined,
},
{
name: "Cycles",
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
Icon: ContrastOutlined,
},
{
name: "Modules",
href: `/${workspaceSlug}/projects/${projectId}/modules`,
Icon: DatasetOutlined,
},
{
name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`,
Icon: PhotoFilterOutlined,
},
{
name: "Pages",
href: `/${workspaceSlug}/projects/${projectId}/pages`,
Icon: ArticleOutlined,
},
{
name: "Settings",
href: `/${workspaceSlug}/projects/${projectId}/settings`,
Icon: SettingsOutlined,
},
];
export const SingleSidebarProject: React.FC<Props> = ({
project,
sidebarCollapse,
provided,
snapshot,
handleDeleteProject,
handleCopyText,
shortContextMenu = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const handleAddToFavorites = () => {
if (!workspaceSlug) return;
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
false
);
projectService
.addProjectToFavorites(workspaceSlug as string, {
project: project.id,
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
})
);
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug) return;
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)),
false
);
projectService.removeProjectFromFavorites(workspaceSlug as string, project.id).catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
})
);
};
return (
<Disclosure key={project.id} defaultOpen={projectId === project.id}>
{({ open }) => (
<>
<div
className={`group relative text-custom-sidebar-text-10 px-2 py-1 w-full flex items-center hover:bg-custom-sidebar-background-80 rounded-md ${
snapshot?.isDragging ? "opacity-60" : ""
}`}
>
{provided && (
<Tooltip
tooltipContent={
project.sort_order === null
? "Join the project to rearrange"
: "Drag to rearrange"
}
position="top-right"
>
<button
type="button"
className={`absolute top-1/2 -translate-y-1/2 -left-4 hidden rounded p-0.5 ${
sidebarCollapse ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
{...provided?.dragHandleProps}
>
<EllipsisVerticalIcon className="h-4" />
<EllipsisVerticalIcon className="-ml-5 h-4" />
</button>
</Tooltip>
)}
<Tooltip
tooltipContent={`${project.name}`}
position="right"
className="ml-2"
disabled={!sidebarCollapse}
>
<Disclosure.Button
as="div"
className={`flex items-center flex-grow truncate cursor-pointer select-none text-left text-sm font-medium ${
sidebarCollapse ? "justify-center" : `justify-between`
}`}
>
<div
className={`flex items-center flex-grow w-full truncate gap-x-2 ${
sidebarCollapse ? "justify-center" : ""
}`}
>
{project.emoji ? (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
{renderEmoji(project.emoji)}
</span>
) : project.icon_prop ? (
<div className="h-7 w-7 flex-shrink-0 grid place-items-center">
{renderEmoji(project.icon_prop)}
</div>
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
{!sidebarCollapse && (
<p className={`truncate ${open ? "" : "text-custom-sidebar-text-200"}`}>
{project.name}
</p>
)}
</div>
{!sidebarCollapse && (
<ExpandMoreOutlined
fontSize="small"
className={`flex-shrink-0 ${
open ? "rotate-180" : ""
} !hidden group-hover:!block text-custom-sidebar-text-200 duration-300`}
/>
)}
</Disclosure.Button>
</Tooltip>
{!sidebarCollapse && (
<CustomMenu className="hidden group-hover:block flex-shrink-0" ellipsis>
{!shortContextMenu && (
<CustomMenu.MenuItem onClick={handleDeleteProject}>
<span className="flex items-center justify-start gap-2 ">
<TrashIcon className="h-4 w-4" />
<span>Delete project</span>
</span>
</CustomMenu.MenuItem>
)}
{!project.is_favorite && (
<CustomMenu.MenuItem onClick={handleAddToFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" />
<span>Add to favorites</span>
</span>
</CustomMenu.MenuItem>
)}
{project.is_favorite && (
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy project link</span>
</span>
</CustomMenu.MenuItem>
{project.archive_in > 0 && (
<CustomMenu.MenuItem
onClick={() =>
router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`)
}
>
<div className="flex items-center justify-start gap-2">
<ArchiveOutlined fontSize="small" />
<span>Archived Issues</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel className={`space-y-2 mt-1 ${sidebarCollapse ? "" : "ml-[2.25rem]"}`}>
{navigation(workspaceSlug as string, project?.id).map((item) => {
if (
(item.name === "Cycles" && !project.cycle_view) ||
(item.name === "Modules" && !project.module_view) ||
(item.name === "Views" && !project.issue_views_view) ||
(item.name === "Pages" && !project.page_view)
)
return;
return (
<Link key={item.name} href={item.href}>
<a className="block w-full">
<Tooltip
tooltipContent={`${project?.name}: ${item.name}`}
position="right"
className="ml-2"
disabled={!sidebarCollapse}
>
<div
className={`group flex items-center rounded-md px-2 py-1.5 gap-2.5 text-xs font-medium outline-none ${
router.asPath.includes(item.href)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${sidebarCollapse ? "justify-center" : ""}`}
>
<item.Icon
sx={{
fontSize: 18,
}}
/>
{!sidebarCollapse && item.name}
</div>
</Tooltip>
</a>
</Link>
);
})}
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
);
};
@@ -1,236 +0,0 @@
import { useCallback, useState, useImperativeHandle } from "react";
import { useRouter } from "next/router";
import { InvalidContentHandler } from "remirror";
import {
BoldExtension,
ItalicExtension,
CalloutExtension,
PlaceholderExtension,
CodeBlockExtension,
CodeExtension,
HistoryExtension,
LinkExtension,
UnderlineExtension,
HeadingExtension,
OrderedListExtension,
ListItemExtension,
BulletListExtension,
ImageExtension,
DropCursorExtension,
StrikeExtension,
MentionAtomExtension,
FontSizeExtension,
} from "remirror/extensions";
import {
Remirror,
useRemirror,
EditorComponent,
OnChangeJSON,
OnChangeHTML,
FloatingToolbar,
FloatingWrapper,
} from "@remirror/react";
import { TableExtension } from "@remirror/extension-react-tables";
// tlds
import tlds from "tlds";
// services
import fileService from "services/file.service";
// components
import { CustomFloatingToolbar } from "./toolbar/float-tool-tip";
import { MentionAutoComplete } from "./mention-autocomplete";
export interface IRemirrorRichTextEditor {
placeholder?: string;
mentions?: any[];
tags?: any[];
onBlur?: (jsonValue: any, htmlValue: any) => void;
onJSONChange?: (jsonValue: any) => void;
onHTMLChange?: (htmlValue: any) => void;
value?: any;
showToolbar?: boolean;
editable?: boolean;
customClassName?: string;
gptOption?: boolean;
noBorder?: boolean;
borderOnFocus?: boolean;
forwardedRef?: any;
}
const RemirrorRichTextEditor: React.FC<IRemirrorRichTextEditor> = (props) => {
const {
placeholder,
mentions = [],
tags = [],
onBlur = () => {},
onJSONChange = () => {},
onHTMLChange = () => {},
value = "",
showToolbar = true,
editable = true,
customClassName,
gptOption = false,
noBorder = false,
borderOnFocus = true,
forwardedRef,
} = props;
const [disableToolbar, setDisableToolbar] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
// remirror error handler
const onError: InvalidContentHandler = useCallback(
({ json, invalidContent, transformers }: any) =>
// Automatically remove all invalid nodes and marks.
transformers.remove(json, invalidContent),
[]
);
const uploadImageHandler = (value: any): any => {
try {
const formData = new FormData();
formData.append("asset", value[0].file);
formData.append("attributes", JSON.stringify({}));
return [
() =>
new Promise(async (resolve, reject) => {
const imageUrl = await fileService
.uploadFile(workspaceSlug as string, formData)
.then((response) => response.asset);
resolve({
align: "left",
alt: "Not Found",
height: "100%",
width: "35%",
src: imageUrl,
});
}),
];
} catch {
return [];
}
};
// remirror manager
const { manager, state } = useRemirror({
extensions: () => [
new BoldExtension(),
new ItalicExtension(),
new UnderlineExtension(),
new HeadingExtension({ levels: [1, 2, 3] }),
new FontSizeExtension({ defaultSize: "16", unit: "px" }),
new OrderedListExtension(),
new ListItemExtension(),
new BulletListExtension({ enableSpine: true }),
new CalloutExtension({ defaultType: "warn" }),
new CodeBlockExtension(),
new CodeExtension(),
new PlaceholderExtension({
placeholder: placeholder || "Enter text...",
emptyNodeClass: "empty-node",
}),
new HistoryExtension(),
new LinkExtension({
autoLink: true,
autoLinkAllowedTLDs: tlds,
selectTextOnClick: true,
defaultTarget: "_blank",
}),
new ImageExtension({
enableResizing: true,
uploadHandler: uploadImageHandler,
createPlaceholder() {
const div = document.createElement("div");
div.className =
"w-[35%] aspect-video bg-custom-background-80 text-custom-text-200 animate-pulse";
return div;
},
}),
new DropCursorExtension(),
new StrikeExtension(),
new MentionAtomExtension({
matchers: [
{ name: "at", char: "@" },
{ name: "tag", char: "#" },
],
}),
new TableExtension(),
],
content: value,
selection: "start",
stringHandler: "html",
onError,
});
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
manager.view.updateState(manager.createState({ content: "", selection: "start" }));
},
setEditorValue: (value: any) => {
manager.view.updateState(
manager.createState({
content: value,
selection: "end",
})
);
},
}));
return (
<div className="relative">
<Remirror
manager={manager}
initialContent={state}
classNames={[
`p-3 relative focus:outline-none rounded-md focus:border-custom-border-200 ${
noBorder ? "" : "border border-custom-border-200"
} ${
borderOnFocus ? "focus:border border-custom-border-200" : "focus:border-0"
} ${customClassName}`,
]}
editable={editable}
onBlur={(event) => {
const html = event.helpers.getHTML();
const json = event.helpers.getJSON();
setDisableToolbar(true);
onBlur(json, html);
}}
onFocus={() => setDisableToolbar(false)}
>
<div className="prose prose-brand max-w-full prose-p:my-1">
<EditorComponent />
</div>
{editable && !disableToolbar && (
<FloatingWrapper
positioner="always"
renderOutsideEditor
floatingLabel="Custom Floating Toolbar"
>
<FloatingToolbar className="z-50 overflow-hidden rounded">
<CustomFloatingToolbar
gptOption={gptOption}
editorState={state}
setDisableToolbar={setDisableToolbar}
/>
</FloatingToolbar>
</FloatingWrapper>
)}
<MentionAutoComplete mentions={mentions} tags={tags} />
{<OnChangeJSON onChange={onJSONChange} />}
{<OnChangeHTML onChange={onHTMLChange} />}
</Remirror>
</div>
);
};
RemirrorRichTextEditor.displayName = "RemirrorRichTextEditor";
export default RemirrorRichTextEditor;
@@ -1,64 +0,0 @@
import { useState, useEffect, FC } from "react";
// remirror imports
import { cx } from "@remirror/core";
import { useMentionAtom, MentionAtomNodeAttributes, FloatingWrapper } from "@remirror/react";
// export const;
export interface IMentionAutoComplete {
mentions?: any[];
tags?: any[];
}
export const MentionAutoComplete: FC<IMentionAutoComplete> = (props) => {
const { mentions = [], tags = [] } = props;
// states
const [options, setOptions] = useState<MentionAtomNodeAttributes[]>([]);
const { state, getMenuProps, getItemProps, indexIsHovered, indexIsSelected } = useMentionAtom({
items: options,
});
useEffect(() => {
if (!state) {
return;
}
const searchTerm = state.query.full.toLowerCase();
let filteredOptions: MentionAtomNodeAttributes[] = [];
if (state.name === "tag") {
filteredOptions = tags.filter((tag) => tag?.label.toLowerCase().includes(searchTerm));
} else if (state.name === "at") {
filteredOptions = mentions.filter((user) => user?.label.toLowerCase().includes(searchTerm));
}
filteredOptions = filteredOptions.sort().slice(0, 5);
setOptions(filteredOptions);
}, [state, mentions, tags]);
const enabled = Boolean(state);
return (
<FloatingWrapper positioner="cursor" enabled={enabled} placement="bottom-start">
<div {...getMenuProps()} className="suggestions">
{enabled &&
options.map((user, index) => {
const isHighlighted = indexIsSelected(index);
const isHovered = indexIsHovered(index);
return (
<div
key={user.id}
className={cx("suggestion", isHighlighted && "highlighted", isHovered && "hovered")}
{...getItemProps({
item: user,
index,
})}
>
{user.label}
</div>
);
})}
</div>
</FloatingWrapper>
);
};
@@ -1,145 +0,0 @@
import React, { useEffect, useState } from "react";
import { TableExtension } from "@remirror/extension-react-tables";
import {
EditorComponent,
ReactComponentExtension,
Remirror,
TableComponents,
tableControllerPluginKey,
ThemeProvider,
useCommands,
useRemirror,
useRemirrorContext,
} from "@remirror/react";
import type { AnyExtension } from "remirror";
const CommandMenu: React.FC = () => {
const { createTable, ...commands } = useCommands();
return (
<div>
<p>commands:</p>
<p
style={{
display: "flex",
flexDirection: "column",
justifyItems: "flex-start",
alignItems: "flex-start",
}}
>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-3"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: false })}
>
insert a 3*3 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-3-headers"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: true })}
>
insert a 3*3 table with headers
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-4-10"
onClick={() => createTable({ rowsCount: 10, columnsCount: 4, withHeaderRow: false })}
>
insert a 4*10 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-30"
onClick={() => createTable({ rowsCount: 30, columnsCount: 3, withHeaderRow: false })}
>
insert a 3*30 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-8-100"
onClick={() => createTable({ rowsCount: 100, columnsCount: 8, withHeaderRow: false })}
>
insert a 8*100 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.addTableColumnAfter()}
>
add a column after the current one
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.addTableRowBefore()}
>
add a row before the current one
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.deleteTable()}
>
delete the table
</button>
</p>
</div>
);
};
const ProsemirrorDocData: React.FC = () => {
const ctx = useRemirrorContext({ autoUpdate: false });
const [jsonPluginState, setJsonPluginState] = useState("");
const [jsonDoc, setJsonDoc] = useState("");
const { addHandler, view } = ctx;
useEffect(() => {
addHandler("updated", () => {
setJsonDoc(JSON.stringify(view.state.doc.toJSON(), null, 2));
const pluginStateValues = tableControllerPluginKey.getState(view.state)?.values;
setJsonPluginState(
JSON.stringify({ ...pluginStateValues, tableNodeResult: "hidden" }, null, 2)
);
});
}, [addHandler, view]);
return (
<div>
<p>tableControllerPluginKey.getState(view.state)</p>
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
<code>{jsonPluginState}</code>
</pre>
<p>view.state.doc.toJSON()</p>
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
<code>{jsonDoc}</code>
</pre>
</div>
);
};
const Table = ({
children,
extensions,
}: {
children?: React.ReactElement;
extensions: () => AnyExtension[];
}): JSX.Element => {
const { manager, state } = useRemirror({ extensions });
return (
<ThemeProvider>
<Remirror manager={manager} initialContent={state}>
<EditorComponent />
<TableComponents />
<CommandMenu />
<ProsemirrorDocData />
{children}
</Remirror>
</ThemeProvider>
);
};
const Basic = (): JSX.Element => <Table extensions={defaultExtensions} />;
const defaultExtensions = () => [new ReactComponentExtension(), new TableExtension()];
export default Basic;
@@ -1,316 +0,0 @@
import React, {
ChangeEvent,
HTMLProps,
KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions";
// buttons
import {
ToggleBoldButton,
ToggleItalicButton,
ToggleUnderlineButton,
ToggleStrikeButton,
ToggleOrderedListButton,
ToggleBulletListButton,
ToggleCodeButton,
ToggleHeadingButton,
useActive,
CommandButton,
useAttrs,
useChainedCommands,
useCurrentSelection,
useExtensionEvent,
useUpdateReason,
} from "@remirror/react";
import { EditorState } from "remirror";
type Props = {
gptOption?: boolean;
editorState: Readonly<EditorState>;
setDisableToolbar: React.Dispatch<React.SetStateAction<boolean>>;
};
const useLinkShortcut = () => {
const [linkShortcut, setLinkShortcut] = useState<ShortcutHandlerProps | undefined>();
const [isEditing, setIsEditing] = useState(false);
useExtensionEvent(
LinkExtension,
"onShortcut",
useCallback(
(props) => {
if (!isEditing) {
setIsEditing(true);
}
return setLinkShortcut(props);
},
[isEditing]
)
);
return { linkShortcut, isEditing, setIsEditing };
};
const useFloatingLinkState = () => {
const chain = useChainedCommands();
const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut();
const { to, empty } = useCurrentSelection();
const url = (useAttrs().link()?.href as string) ?? "";
const [href, setHref] = useState<string>(url);
// A positioner which only shows for links.
const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []);
const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]);
const updateReason = useUpdateReason();
useLayoutEffect(() => {
if (!isEditing) {
return;
}
if (updateReason.doc || updateReason.selection) {
setIsEditing(false);
}
}, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]);
useEffect(() => {
setHref(url);
}, [url]);
const submitHref = useCallback(() => {
setIsEditing(false);
const range = linkShortcut ?? undefined;
if (href === "") {
chain.removeLink();
} else {
chain.updateLink({ href, auto: false }, range);
}
chain.focus(range?.to ?? to).run();
}, [setIsEditing, linkShortcut, chain, href, to]);
const cancelHref = useCallback(() => {
setIsEditing(false);
}, [setIsEditing]);
const clickEdit = useCallback(() => {
if (empty) {
chain.selectLink();
}
setIsEditing(true);
}, [chain, empty, setIsEditing]);
return useMemo(
() => ({
href,
setHref,
linkShortcut,
linkPositioner,
isEditing,
setIsEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
}),
[
href,
linkShortcut,
linkPositioner,
isEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
setIsEditing,
]
);
};
const DelayAutoFocusInput = ({
autoFocus,
setDisableToolbar,
...rest
}: HTMLProps<HTMLInputElement> & {
setDisableToolbar: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!autoFocus) {
return;
}
setDisableToolbar(false);
const frame = window.requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [autoFocus, setDisableToolbar]);
useEffect(() => {
setDisableToolbar(false);
}, [setDisableToolbar]);
return (
<>
<label htmlFor="link-input" className="text-sm">
Add Link
</label>
<input
ref={inputRef}
{...rest}
onKeyDown={(e) => {
if (rest.onKeyDown) rest.onKeyDown(e);
setDisableToolbar(false);
}}
className={`${rest.className} mt-1`}
onFocus={() => {
setDisableToolbar(false);
}}
onBlur={() => {
setDisableToolbar(true);
}}
/>
</>
);
};
export const CustomFloatingToolbar: React.FC<Props> = ({
gptOption,
editorState,
setDisableToolbar,
}) => {
const { isEditing, setIsEditing, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
useFloatingLinkState();
const active = useActive();
const activeLink = active.link();
const handleClickEdit = useCallback(() => {
clickEdit();
}, [clickEdit]);
return (
<div className="z-[99999] flex flex-col items-center gap-y-2 divide-x divide-y divide-custom-border-200 rounded border border-custom-border-200 bg-custom-background-80 p-1 px-0.5 shadow-md">
<div className="flex items-center gap-y-2 divide-x divide-custom-border-200">
<div className="flex items-center gap-x-1 px-2">
<ToggleHeadingButton
attrs={{
level: 1,
}}
/>
<ToggleHeadingButton
attrs={{
level: 2,
}}
/>
<ToggleHeadingButton
attrs={{
level: 3,
}}
/>
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleBoldButton />
<ToggleItalicButton />
<ToggleUnderlineButton />
<ToggleStrikeButton />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleOrderedListButton />
<ToggleBulletListButton />
</div>
{gptOption && (
<div className="flex items-center gap-x-1 px-2">
<button
type="button"
className="rounded py-1 px-1.5 text-xs hover:bg-custom-background-90"
onClick={() => console.log(editorState.selection.$anchor.nodeBefore)}
>
AI
</button>
</div>
)}
<div className="flex items-center gap-x-1 px-2">
<ToggleCodeButton />
</div>
{activeLink ? (
<div className="flex items-center gap-x-1 px-2">
<CommandButton
commandName="openLink"
onSelect={() => {
window.open(href, "_blank");
}}
icon="externalLinkFill"
enabled
/>
<CommandButton
commandName="updateLink"
onSelect={handleClickEdit}
icon="pencilLine"
enabled
/>
<CommandButton commandName="removeLink" onSelect={onRemove} icon="linkUnlink" enabled />
</div>
) : (
<CommandButton
commandName="updateLink"
onSelect={() => {
if (isEditing) {
setIsEditing(false);
} else {
handleClickEdit();
}
}}
icon="link"
enabled
active={isEditing}
/>
)}
</div>
{isEditing && (
<div className="p-2 w-full">
<DelayAutoFocusInput
autoFocus
placeholder="Paste your link here..."
id="link-input"
setDisableToolbar={setDisableToolbar}
className="w-full px-2 py-0.5"
onChange={(e: ChangeEvent<HTMLInputElement>) => setHref(e.target.value)}
value={href}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
const { code } = e;
if (code === "Enter") {
submitHref();
}
if (code === "Escape") {
cancelHref();
}
}}
/>
</div>
)}
</div>
);
};
@@ -1,57 +0,0 @@
// remirror
import { useCommands, useActive } from "@remirror/react";
// ui
import { CustomMenu } from "components/ui";
const HeadingControls = () => {
const { toggleHeading, focus } = useCommands();
const active = useActive();
return (
<div className="flex items-center gap-1">
<CustomMenu
width="lg"
label={`${
active.heading({ level: 1 })
? "Heading 1"
: active.heading({ level: 2 })
? "Heading 2"
: active.heading({ level: 3 })
? "Heading 3"
: "Normal text"
}`}
>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 1 });
focus();
}}
className={`${active.heading({ level: 1 }) ? "bg-indigo-50" : ""}`}
>
Heading 1
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 2 });
focus();
}}
className={`${active.heading({ level: 2 }) ? "bg-indigo-50" : ""}`}
>
Heading 2
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 3 });
focus();
}}
className={`${active.heading({ level: 3 }) ? "bg-indigo-50" : ""}`}
>
Heading 3
</CustomMenu.MenuItem>
</CustomMenu>
</div>
);
};
export default HeadingControls;
@@ -1,35 +0,0 @@
// buttons
import {
ToggleBoldButton,
ToggleItalicButton,
ToggleUnderlineButton,
ToggleStrikeButton,
ToggleOrderedListButton,
ToggleBulletListButton,
RedoButton,
UndoButton,
} from "@remirror/react";
// headings
import HeadingControls from "./heading-controls";
export const RichTextToolbar: React.FC = () => (
<div className="flex items-center gap-y-2 divide-x">
<div className="flex items-center gap-x-1 px-2">
<RedoButton />
<UndoButton />
</div>
<div className="px-2">
<HeadingControls />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleBoldButton />
<ToggleItalicButton />
<ToggleUnderlineButton />
<ToggleStrikeButton />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleOrderedListButton />
<ToggleBulletListButton />
</div>
</div>
);
@@ -1,215 +0,0 @@
import React, {
ChangeEvent,
HTMLProps,
KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions";
import {
CommandButton,
FloatingToolbar,
FloatingWrapper,
useActive,
useAttrs,
useChainedCommands,
useCurrentSelection,
useExtensionEvent,
useUpdateReason,
} from "@remirror/react";
const useLinkShortcut = () => {
const [linkShortcut, setLinkShortcut] = useState<ShortcutHandlerProps | undefined>();
const [isEditing, setIsEditing] = useState(false);
useExtensionEvent(
LinkExtension,
"onShortcut",
useCallback(
(props) => {
if (!isEditing) {
setIsEditing(true);
}
return setLinkShortcut(props);
},
[isEditing]
)
);
return { linkShortcut, isEditing, setIsEditing };
};
const useFloatingLinkState = () => {
const chain = useChainedCommands();
const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut();
const { to, empty } = useCurrentSelection();
const url = (useAttrs().link()?.href as string) ?? "";
const [href, setHref] = useState<string>(url);
// A positioner which only shows for links.
const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []);
const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]);
const updateReason = useUpdateReason();
useLayoutEffect(() => {
if (!isEditing) {
return;
}
if (updateReason.doc || updateReason.selection) {
setIsEditing(false);
}
}, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]);
useEffect(() => {
setHref(url);
}, [url]);
const submitHref = useCallback(() => {
setIsEditing(false);
const range = linkShortcut ?? undefined;
if (href === "") {
chain.removeLink();
} else {
chain.updateLink({ href, auto: false }, range);
}
chain.focus(range?.to ?? to).run();
}, [setIsEditing, linkShortcut, chain, href, to]);
const cancelHref = useCallback(() => {
setIsEditing(false);
}, [setIsEditing]);
const clickEdit = useCallback(() => {
if (empty) {
chain.selectLink();
}
setIsEditing(true);
}, [chain, empty, setIsEditing]);
return useMemo(
() => ({
href,
setHref,
linkShortcut,
linkPositioner,
isEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
}),
[href, linkShortcut, linkPositioner, isEditing, clickEdit, onRemove, submitHref, cancelHref]
);
};
const DelayAutoFocusInput = ({ autoFocus, ...rest }: HTMLProps<HTMLInputElement>) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!autoFocus) {
return;
}
const frame = window.requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [autoFocus]);
return <input ref={inputRef} {...rest} />;
};
export const FloatingLinkToolbar = () => {
const { isEditing, linkPositioner, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
useFloatingLinkState();
const active = useActive();
const activeLink = active.link();
const { empty } = useCurrentSelection();
const handleClickEdit = useCallback(() => {
clickEdit();
}, [clickEdit]);
const linkEditButtons = activeLink ? (
<>
<CommandButton
commandName="openLink"
onSelect={() => {
window.open(href, "_blank");
}}
icon="externalLinkFill"
enabled
/>
<CommandButton
commandName="updateLink"
onSelect={handleClickEdit}
icon="pencilLine"
enabled
/>
<CommandButton commandName="removeLink" onSelect={onRemove} icon="linkUnlink" enabled />
</>
) : (
<CommandButton commandName="updateLink" onSelect={handleClickEdit} icon="link" enabled />
);
return (
<>
{!isEditing && (
<FloatingToolbar className="rounded bg-custom-background-80 p-1 shadow-lg">
{linkEditButtons}
</FloatingToolbar>
)}
{!isEditing && empty && (
<FloatingToolbar
positioner={linkPositioner}
className="rounded bg-custom-background-80 p-1 shadow-lg"
>
{linkEditButtons}
</FloatingToolbar>
)}
<FloatingWrapper
positioner="always"
placement="bottom"
enabled={isEditing}
renderOutsideEditor
>
<DelayAutoFocusInput
autoFocus
placeholder="Enter link..."
onChange={(e: ChangeEvent<HTMLInputElement>) => setHref(e.target.value)}
value={href}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
const { code } = e;
if (code === "Enter") {
submitHref();
}
if (code === "Escape") {
cancelHref();
}
}}
/>
</FloatingWrapper>
</>
);
};
@@ -1,55 +0,0 @@
import { useCommands } from "@remirror/react";
export const TableControls = () => {
const { createTable, ...commands } = useCommands();
return (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: false })}
className="rounded p-1 hover:bg-custom-background-90"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-table"
width="18"
height="18"
viewBox="0 0 24 24"
stroke="#2c3e50"
fill="none"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="4" y="4" width="16" height="16" rx="2" />
<line x1="4" y1="10" x2="20" y2="10" />
<line x1="10" y1="4" x2="10" y2="20" />
</svg>
</button>
<button
type="button"
onClick={() => commands.deleteTable()}
className="rounded p-1 hover:bg-custom-background-90"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-trash"
width="18"
height="18"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="#2c3e50"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="7" x2="20" y2="7" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
</svg>
</button>
</div>
);
};
@@ -1,19 +0,0 @@
import { ExclamationIcon } from "components/icons";
type Props = {
bannerName: string;
};
export const IntegrationAndImportExportBanner: React.FC<Props> = ({ bannerName }) => (
<div className="flex flex-col items-start gap-3">
<h3 className="text-2xl font-semibold">{bannerName}</h3>
<div className="flex items-center gap-3 rounded-[10px] border border-custom-primary/75 bg-custom-primary/5 p-4 text-sm text-custom-text-100">
<ExclamationIcon height={24} width={24} className="fill-current text-custom-text-100" />
<p className="leading-5">
Integrations and importers are only available on the cloud version. We plan to open-source
our SDKs in the near future so that the community can request or contribute integrations as
needed.
</p>
</div>
</div>
);
-32
View File
@@ -1,32 +0,0 @@
import React from "react";
type IssueLabelsListProps = {
labels?: (string | undefined)[];
length?: number;
showLength?: boolean;
};
export const IssueLabelsList: React.FC<IssueLabelsListProps> = ({
labels,
length = 5,
showLength = true,
}) => (
<>
{labels && (
<>
{labels.slice(0, length).map((color, index) => (
<div className={`flex h-4 w-4 rounded-full ${index ? "-ml-3.5" : ""}`}>
<span
className={`h-4 w-4 flex-shrink-0 rounded-full border border-custom-border-200
`}
style={{
backgroundColor: color && color !== "" ? color : "#000000",
}}
/>
</div>
))}
{labels.length > length ? <span>+{labels.length - length}</span> : null}
</>
)}
</>
);
-109
View File
@@ -1,109 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks
import useGanttChartViewIssues from "hooks/gantt-chart/view-issues-view";
type Props = {};
export const ViewIssuesGanttChartView: FC<Props> = ({}) => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const { ganttIssues, mutateGanttIssues } = useGanttChartViewIssues(
workspaceSlug as string,
projectId as string,
viewId as string
);
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: any) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
{data.infoToggle && (
<Tooltip
tooltipContent={`No due-date set, rendered according to last updated date.`}
className={`z-[999999]`}
>
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
<span className="material-symbols-rounded text-custom-text-200 text-[18px]">
info
</span>
</div>
</Tooltip>
)}
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
console.log("payload", payload);
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
let startDate = new Date(_block.created_at);
let targetDate = new Date(_block.updated_at);
let infoToggle = true;
if (_block?.start_date && _block.target_date) {
startDate = _block?.start_date;
targetDate = _block.target_date;
infoToggle = false;
}
return {
start_date: new Date(startDate),
target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block,
};
})
: [];
return (
<div className="w-full h-full p-3">
<GanttChartRoot
title="Issue Views"
loaderTitle="Issue Views"
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
@@ -1,35 +0,0 @@
// icons
import { Bars3Icon } from "@heroicons/react/24/outline";
type Props = {
breadcrumbs?: JSX.Element;
left?: JSX.Element;
right?: JSX.Element;
setToggleSidebar: React.Dispatch<React.SetStateAction<boolean>>;
noHeader: boolean;
};
const Header: React.FC<Props> = ({ breadcrumbs, left, right, setToggleSidebar, noHeader }) => (
<div
className={`relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 px-5 py-4 ${
noHeader ? "md:hidden" : ""
}`}
>
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div className="block md:hidden">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
onClick={() => setToggleSidebar((prevData) => !prevData)}
>
<Bars3Icon className="h-5 w-5" />
</button>
</div>
{breadcrumbs}
<div className="flex-shrink-0">{left}</div>
</div>
<div className="flex-shrink-0">{right}</div>
</div>
);
export default Header;
-96
View File
@@ -1,96 +0,0 @@
.empty-node::after {
content: attr(data-placeholder);
color: rgb(var(--color-text-400));
position: absolute;
pointer-events: none;
top: 15px;
margin-left: 1px;
}
.ProseMirror {
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
-moz-tab-size: 4;
tab-size: 4;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
outline: none;
cursor: text;
line-height: 1.2;
font-family: inherit;
font-size: 14px;
color: inherit;
-moz-box-sizing: border-box;
box-sizing: border-box;
appearance: textfield;
-webkit-appearance: textfield;
-moz-appearance: textfield;
}
.ProseMirror-icon {
display: inline-block;
line-height: 0.8;
vertical-align: -2px;
color: #666;
cursor: pointer;
margin: 0 3px;
padding: 3px 8px;
border-radius: 3px;
border: 1px solid transparent;
transition: background 50ms ease-in-out;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.ProseMirror-menu-disabled.ProseMirror-icon {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.ProseMirror-icon svg {
fill: currentColor;
height: 1em;
}
.ProseMirror-icon span {
vertical-align: text-top;
}
.remirror-editor-wrapper .remirror-editor {
min-height: 150px;
}
.issue-comments-section .remirror-editor-wrapper .remirror-editor,
.page-block-section .remirror-editor-wrapper .remirror-editor {
min-height: 50px;
}
.remirror-section .remirror-editor-wrapper .remirror-editor {
min-height: 0 !important;
}
.remirror-editor-wrapper {
padding-top: 8px;
}
.MuiButtonBase-root {
border: none !important;
border-radius: 0.25rem !important;
padding: 0.25rem !important;
}
.MuiButtonBase-root svg {
fill: rgb(var(--color-text-100)) !important;
}
.MuiButtonBase-root.Mui-selected,
.MuiButtonBase-root:hover {
background-color: rgb(var(--color-background-100)) !important;
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "tsconfig/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"],
"compilerOptions": {
"baseUrl": ".",
"jsx": "preserve"
}
}
-4
View File
@@ -1,4 +0,0 @@
# Deploy the Plane image
FROM makeplane/plane
LABEL maintainer="engineering@plane.so"
+2 -1
View File
@@ -38,11 +38,12 @@ services:
container_name: planefrontend
image: makeplane/plane-frontend:latest
restart: always
command: /usr/local/bin/start.sh
command: /usr/local/bin/start.sh apps/app/server.js app
env_file:
- .env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
NEXT_PUBLIC_GITHUB_ID: "0"
+3 -1
View File
@@ -41,12 +41,14 @@ services:
dockerfile: ./apps/app/Dockerfile.web
args:
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
NEXT_PUBLIC_DEPLOY_URL: http://localhost/spaces
restart: always
command: /usr/local/bin/start.sh
command: /usr/local/bin/start.sh apps/app/server.js app
env_file:
- .env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
NEXT_PUBLIC_GITHUB_ID: "0"
-3
View File
@@ -1,3 +0,0 @@
build:
docker:
web: deploy/heroku/Dockerfile
+5
View File
@@ -18,6 +18,11 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /space/ {
proxy_pass http://localhost:4000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
+5 -11
View File
@@ -1,22 +1,16 @@
{
"name": "plane",
"repository": "https://github.com/makeplane/plane.git",
"license": "MIT",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
"apps/*",
"web/",
"space/",
"packages/*"
],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"start": "turbo run start",
"lint": "turbo run lint",
"clean": "turbo run clean"
},
"devDependencies": {
"eslint-config-custom": "*",
"prettier": "latest",
"turbo": "latest"
"prettier": "latest"
},
"packageManager": "yarn@1.22.19"
}
+2 -1
View File
@@ -1,6 +1,7 @@
#!/bin/sh
FROM=$1
TO=$2
DIRECTORY=$3
if [ "${FROM}" = "${TO}" ]; then
echo "Nothing to replace, the value is already set to ${TO}."
@@ -11,4 +12,4 @@ fi
# Only perform action if $FROM and $TO are different.
echo "Replacing all statically built instances of $FROM with this string $TO ."
grep -R -la "${FROM}" apps/app/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}"
grep -R -la "${FROM}" apps/$DIRECTORY/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}"
+13
View File
@@ -14,3 +14,16 @@ echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./.en
# WEB_URL for email redirection and image saving
echo -e "WEB_URL=$1" >> ./.env
# Generate Prompt for taking tiptap auth key
echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n"
echo -e "\e[1;38m 1. Head over to TipTap cloud's Pro Extensions Page, https://collab.tiptap.dev/pro-extensions \e[0m"
echo -e "\e[1;38m 2. Copy the token given to you under the first paragraph, after 'Here it is' \e[0m \n"
read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken
echo "@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_API_BASE_URL=''
+39
View File
@@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# env
.env
+2
View File
@@ -0,0 +1,2 @@
.next
.vercel
+7
View File
@@ -0,0 +1,7 @@
{
"printWidth": 120,
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"trailingComma": "es5"
}
+70
View File
@@ -0,0 +1,70 @@
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=space --docker
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install --network-timeout 500000
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build --filter=space
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} space
FROM node:18-alpine AS runner
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 plane
RUN adduser --system --uid 1001 captain
USER captain
COPY --from=installer /app/apps/space/next.config.js .
COPY --from=installer /app/apps/space/package.json .
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
USER captain
ENV NEXT_TELEMETRY_DISABLED 1
EXPOSE 3000
+56
View File
@@ -0,0 +1,56 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /space
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* ./
RUN yarn
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /space
COPY . .
COPY --from=deps /space/node_modules ./node_modules
COPY --from=deps /space/yarn.lock .
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# If using npm comment out above and use below instead
# RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /space
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /space/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /space/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /space/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
CMD ["node", "space/server.js"]
+10
View File
@@ -0,0 +1,10 @@
<br /><br />
<p align="center">
<a href="https://plane.so">
<img src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_logo_.webp" alt="Plane Logo" width="70">
</a>
</p>
<h3 align="center"><b>Plane Space</b></h3>
<p align="center"><b>Open-source, self-hosted project planning tool</b></p>
@@ -0,0 +1,66 @@
// next imports
import Link from "next/link";
import Image from "next/image";
import { Metadata, ResolvingMetadata } from "next";
// components
import IssueNavbar from "components/issues/navbar";
import IssueFilter from "components/issues/filters-render";
// service
import ProjectService from "services/project.service";
import { redirect } from "next/navigation";
type LayoutProps = {
params: { workspace_slug: string; project_slug: string };
};
export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> {
// read route params
const { workspace_slug, project_slug } = params;
const projectServiceInstance = new ProjectService();
try {
const project = await projectServiceInstance?.getProjectSettingsAsync(workspace_slug, project_slug);
return {
title: `${project?.project_details?.name} | ${workspace_slug}`,
description: `${
project?.project_details?.description || `${project?.project_details?.name} | ${workspace_slug}`
}`,
icons: `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${
typeof project?.project_details?.emoji != "object"
? String.fromCodePoint(parseInt(project?.project_details?.emoji))
: "✈️"
}</text></svg>`,
};
} catch (error: any) {
if (error?.data?.error) {
redirect(`/project-not-published`);
}
return {};
}
}
const RootLayout = ({ children }: { children: React.ReactNode }) => (
<div className="relative w-screen min-h-[500px] h-screen overflow-hidden flex flex-col">
<div className="flex-shrink-0 h-[60px] border-b border-gray-300 relative flex items-center bg-white select-none">
<IssueNavbar />
</div>
{/* <div className="flex-shrink-0 min-h-[50px] h-auto py-1.5 border-b border-gray-300 relative flex items-center shadow-md bg-white select-none">
<IssueFilter />
</div> */}
<div className="w-full h-full relative bg-gray-100/50 overflow-hidden">{children}</div>
<div className="absolute z-[99999] bottom-[10px] right-[10px] bg-white rounded-sm shadow-lg border border-gray-100">
<Link href="https://plane.so" className="p-1 px-2 flex items-center gap-1" target="_blank">
<div className="w-[24px] h-[24px] relative flex justify-center items-center">
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
</div>
<div className="text-xs">
Powered by <b>Plane Deploy</b>
</div>
</Link>
</div>
</div>
);
export default RootLayout;
@@ -0,0 +1,113 @@
"use client";
import { useEffect } from "react";
// next imports
import { useRouter, useParams, useSearchParams } from "next/navigation";
// mobx
import { observer } from "mobx-react-lite";
// components
import { IssueListView } from "components/issues/board-views/list";
import { IssueKanbanView } from "components/issues/board-views/kanban";
import { IssueCalendarView } from "components/issues/board-views/calendar";
import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet";
import { IssueGanttView } from "components/issues/board-views/gantt";
// mobx store
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { TIssueBoardKeys } from "store/types";
const WorkspaceProjectPage = observer(() => {
const store: RootStore = useMobxStore();
const router = useRouter();
const routerParams = useParams();
const routerSearchparams = useSearchParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const board =
routerSearchparams &&
routerSearchparams.get("board") != null &&
(routerSearchparams.get("board") as TIssueBoardKeys | "");
// updating default board view when we are in the issues page
useEffect(() => {
if (workspace_slug && project_slug && store?.project?.workspaceProjectSettings) {
const workspacePRojectSettingViews = store?.project?.workspaceProjectSettings?.views;
const userAccessViews: TIssueBoardKeys[] = [];
Object.keys(workspacePRojectSettingViews).filter((_key) => {
if (_key === "list" && workspacePRojectSettingViews.list === true) userAccessViews.push(_key);
if (_key === "kanban" && workspacePRojectSettingViews.kanban === true) userAccessViews.push(_key);
if (_key === "calendar" && workspacePRojectSettingViews.calendar === true) userAccessViews.push(_key);
if (_key === "spreadsheet" && workspacePRojectSettingViews.spreadsheet === true) userAccessViews.push(_key);
if (_key === "gantt" && workspacePRojectSettingViews.gantt === true) userAccessViews.push(_key);
});
if (userAccessViews && userAccessViews.length > 0) {
if (!board) {
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
} else {
if (userAccessViews.includes(board)) {
if (store.issue.currentIssueBoardView === null) store.issue.setCurrentIssueBoardView(board);
else {
if (board === store.issue.currentIssueBoardView)
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
else {
store.issue.setCurrentIssueBoardView(board);
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
}
}
} else {
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
}
}
}
}
}, [workspace_slug, project_slug, board, router, store?.issue, store?.project?.workspaceProjectSettings]);
useEffect(() => {
if (workspace_slug && project_slug) {
store?.project?.getProjectSettingsAsync(workspace_slug, project_slug);
store?.issue?.getIssuesAsync(workspace_slug, project_slug);
}
}, [workspace_slug, project_slug, store?.project, store?.issue]);
return (
<div className="relative w-full h-full overflow-hidden">
{store?.issue?.loader && !store.issue.issues ? (
<div className="text-sm text-center py-10 text-gray-500">Loading...</div>
) : (
<>
{store?.issue?.error ? (
<div className="text-sm text-center py-10 text-gray-500">Something went wrong.</div>
) : (
store?.issue?.currentIssueBoardView && (
<>
{store?.issue?.currentIssueBoardView === "list" && (
<div className="relative w-full h-full overflow-y-auto">
<div className="container mx-auto px-5 py-3">
<IssueListView />
</div>
</div>
)}
{store?.issue?.currentIssueBoardView === "kanban" && (
<div className="relative w-full h-full mx-auto px-5">
<IssueKanbanView />
</div>
)}
{store?.issue?.currentIssueBoardView === "calendar" && <IssueCalendarView />}
{store?.issue?.currentIssueBoardView === "spreadsheet" && <IssueSpreadsheetView />}
{store?.issue?.currentIssueBoardView === "gantt" && <IssueGanttView />}
</>
)
)}
</>
)}
</div>
);
});
export default WorkspaceProjectPage;
+7
View File
@@ -0,0 +1,7 @@
"use client";
const WorkspaceProjectPage = () => (
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Workspace Space</div>
);
export default WorkspaceProjectPage;
+20
View File
@@ -0,0 +1,20 @@
"use client";
// root styles
import "styles/globals.css";
// mobx store provider
import { MobxStoreProvider } from "lib/mobx/store-provider";
import MobxStoreInit from "lib/mobx/store-init";
const RootLayout = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
<body className="antialiased w-100">
<MobxStoreProvider>
<MobxStoreInit />
<main>{children}</main>
</MobxStoreProvider>
</body>
</html>
);
export default RootLayout;

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