Compare commits

...

125 Commits

Author SHA1 Message Date
sharma01ketan ea581a6948 fix kanban view localStorage 2024-09-10 18:48:31 +05:30
sriram veeraghanta 04b10cabc8 fix: tailwind warning fixes 2024-09-10 17:57:06 +05:30
sriram veeraghanta 545717cc51 fix: security update for express pacakge 2024-09-10 17:36:32 +05:30
sriram veeraghanta 1ca0a15792 fix: upgrading tubro version 2024-09-10 17:31:10 +05:30
sriram veeraghanta c5971f03aa Merge branch 'preview' of github.com:makeplane/plane into preview 2024-09-10 17:29:34 +05:30
sriram veeraghanta 902403a54d chore: linting warning resolved 2024-09-10 17:29:19 +05:30
Akshat Jain 1d6ebb7c41 add the SERVICE_FOLDER value to install.sh script dynamically (#5553) 2024-09-10 17:29:16 +05:30
Rounak Shrestha 106914e14e fix: Local Setup on Windows (#5539) 2024-09-10 17:28:18 +05:30
Aaryan Khandelwal 8acb60baef [WEB-1116] fix: current version not displaying the latest content (#5573)
* fix: current version sync

* chore: update read only editor ref type
2024-09-10 16:13:20 +05:30
Manish Gupta 1da97d5814 skipped stable tag for prerelrease, modified docker tag for branch name with special characters (#5570) 2024-09-10 15:10:10 +05:30
Goran 5fb2dd0b6e fix(webhook): allow private ip to be used as payload url (#5535)
Co-authored-by: gmajkic <gmajkic@veepee.com>
2024-09-10 14:57:30 +05:30
Akshita Goyal ff6c3ce1a0 fix: settings page scrollbar (#5572) 2024-09-10 14:44:32 +05:30
Anmol Singh Bhatia ec51e9d8ce fix-header-theme (#5564) 2024-09-10 14:42:24 +05:30
Aaryan Khandelwal cc07992e47 [WEB-2424] fix: add optional chaining for parent node (#5571)
* fix: add optional chaining for parent node

* chore: revert yarn lock changes
2024-09-10 14:41:48 +05:30
M. Palanikannan 069f8b950e fix: svg not supported in image uploads in the editor (#5558)
* fix: svg not supported in image uploads

* fix: svg image file error message fixed
2024-09-10 14:27:27 +05:30
Akshita Goyal 5eb868e07d [WEB 2418] Fix minor UI inconsistencies (#5568)
* fix: project features modal padding

* fix: minor ui inconsistencies

* fix: lint issue
2024-09-10 14:24:07 +05:30
Aaryan Khandelwal 7c77fc1680 fix: task list not getting synced (#5566) 2024-09-09 21:35:31 +05:30
Anmol Singh Bhatia 99a7867a5e [WEB-2228] fix: dashboard peek overview issue stats #5442 (#5560)
* fix: dashboard issue stats

* chore: code refactor
2024-09-09 20:37:46 +05:30
Ketan Sharma c44bf861e0 [WEB-2415] fix:remove input type to fix image upload (#5563)
* remove input type to fix things

* made the same changes in all locations
2024-09-09 20:12:15 +05:30
M. Palanikannan 4d38a10f8b fix: character count to work properly on editor rerenders and read only mode (#5554)
* fix: character count to work properly on editor rerenders and read only mode

* fix: desctructing properly at the start
2024-09-09 19:59:07 +05:30
Akshita Goyal 7c3fc690e9 fix: project features modal padding (#5562) 2024-09-09 19:22:47 +05:30
Prateek Shourya 8cf1c2d136 [WEB-2413] chore: admin application restructuring. (#5557) 2024-09-09 17:43:56 +05:30
Ketan Sharma fe280b2beb [WEB-2106] fix: add date and state change functionalities to list and grid view (#5533)
* added functionality to list and grid

* fixed logic for archived module

* fixed logic for list view

* improved logic and fixed linting issues

* improved variable names
2024-09-09 16:50:56 +05:30
Ketan Sharma ad5c6ee4f5 [WEB-2201] fix: clear email button on login screen (#5546)
* fixed the logic

* made required css changes

* replicated same for space component

* fixed variable name

* replicated for space

* better variable name

* improved the css

* replicated for space
2024-09-09 14:58:06 +05:30
Mihir ba0d1ba518 Update sidebar (#5549)
Removed else statement which was expanding it whenever windowSize changed or webapp was hard refreshed.
2024-09-09 14:57:05 +05:30
M. Palanikannan 70ea1459cd fix: async loading of the redis extension (#5537)
* fix: async loading of the redis extension

* fix: initialize redis connection and hocuspocusserver only during server start

* fix: removed console logs

* fix: remove async

* fix: error handling and shutting down gracefully in unhandled errors

* feat: added compression library

* fix: added helmet for security headers
2024-09-07 14:24:20 +05:30
Aaryan Khandelwal 8154a190d2 [WEB-1116] fix: editor info badges occupying multiple lines (#5548) 2024-09-07 09:01:01 +05:30
Ketan Sharma 29fd1186ee [WEB-2129] fix: module creation and updation toast error (#5550)
* chore: added error message for module name

* used the backend message

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-07 08:58:28 +05:30
Aaryan Khandelwal 68b412badf [WEB-1933] refactor: link create/update for issues and modules (#5543)
* chore: added module and issue link validation

* refactor: issues and modules link moda;

* chore: changed the url validation logic

* chore: code cleanup

* refactor: modules link logic

* chore: removed the validator function

* fix: url validation regex

* chore: removed unwanted imports

* chore: reverted the external api changes

* refactor: link modals

* refactor: reset modal logic

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-06 22:52:29 +05:30
Akshita Goyal c95aa6a0f7 [WEB-2273] Fix: page alignments (#5541)
* chore: headers + common containers

* fix: filters code splitting

* fix: home header

* fix: header changes

* chore: page alignments fixed

* fix: uncommented filters

* fix: used enums

* fix: cards + filters

* fix: enum changes

* fix: reverted package changes

* fix: reverted package changes

* fix: Card + tags seperated + naming fixed

* fix: card + tags seperated + naming fixed

* fix: mobile headers fixed partially

* fix: build errors + minor css

* fix: checkbox spacing

* fix: review changes

* fix: lint errors

* fix: minor review changes

* fix: header-alignments

* fix: tabs

* fix: settings page

* fix: subgroup page

* fix: mobile headers

* fix: settings mobile header made observable

* fix: lint error + edge case handling
2024-09-06 18:38:53 +05:30
rahulramesha 751cd6c862 [WEB-2365] fix: Minor UI in-consistencies cause by tooltip changes (#5545)
* Fix minor in-consistencies caused by tooltip on hover changes

* fix linting
2024-09-06 18:37:57 +05:30
Prateek Shourya 1032bc75d7 [WEB-2332] chore: layout structure improvement. (#5538)
* [WEB-2332] chore: layout structure improvement.

* chore: improve layout.
2024-09-06 16:46:42 +05:30
Ketan Sharma 9415a5ba00 made required changes in css (#5542) 2024-09-06 16:22:59 +05:30
Akshat Jain d24a4e18a2 add: API_BASE_URL env to selfhost envs (#5523)
* add: API_BASE_URL env to selfhost envs

* Update variables.env
2024-09-06 16:22:16 +05:30
Anmol Singh Bhatia 52f78a86af [PWA-26] chore: pwa input focus improvement (#5507)
* chore: pwa dropdown input focus improvement

* chore: tab indices helper function updated and code refactor

* chore: modal tab index refactoring

* fix: PWA filters input autofocus

* chore: intake tab index updated and code refactor

* chore: code refactor
2024-09-06 16:21:14 +05:30
Anmol Singh Bhatia c84c37805c [PWA-22] chore: pwa issue redirection (#5544)
* chore: issue peek overview redirection hook added

* chore: handleIssuePeekOverview function updated
2024-09-06 15:36:06 +05:30
Anmol Singh Bhatia c2758caf95 chore: pwa issue detail improvement (#5540) 2024-09-06 15:23:48 +05:30
M. Palanikannan 73654a25c4 fix: redis connection instantiated out (#5534) 2024-09-05 20:18:26 +05:30
M. Palanikannan e1380f52ec fix: add the redis extension conditionally (#5524)
* fix: add the redis extension conditionally

* chore: import order and stuff

* fix: added logger, error handling and routing

* feat: configured sentry with source maps

* fix: sentry config and returning json

* fix: remove on change logs

* fix: add pretty print
2024-09-05 18:15:46 +05:30
Anmol Singh Bhatia 406ffcd7de [WEB-2358] fix: recent collaborators (#5532)
* fix: recent collaborators

* fix: recent collaborators loader
2024-09-05 18:09:10 +05:30
Bavisetti Narayan d265635f7e chore: workspace active page filter (#5531) 2024-09-05 15:38:45 +05:30
Bavisetti Narayan 3d7098855f [WEB-2358] chore: optimised the recent collaborators endpoint (#5470)
* chore: optimised the recent collaborators endpoint

* chore: recent collabators code refactor

* chore: sorted the user's based on active issues

* chore: recent collaborators sorting

* chore: code refactor

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-09-05 15:38:10 +05:30
rahulramesha bf49ebb519 Add missing Mobx observers to components (#5530) 2024-09-05 15:34:08 +05:30
Bavisetti Narayan 4c8e8d985c fix: now parent can be expanded in external api (#5511) 2024-09-05 13:32:03 +05:30
Bavisetti Narayan a3a7053be7 chore: added identifiers in the notification (#5513) 2024-09-05 13:30:44 +05:30
Aaryan Khandelwal dbecf5cf5e chore: add favorites option inside a page (#5512) 2024-09-05 13:18:11 +05:30
Aaryan Khandelwal bd20d71fc4 chore: add extra check to the version editor (#5521) 2024-09-05 12:38:50 +05:30
Aaryan Khandelwal b80049d533 fix: untitle page title in favorites list (#5515) 2024-09-05 12:37:15 +05:30
Akshita Goyal 87dbb9b888 [WEB-2273] Chore: page alignments (#5505)
* chore: headers + common containers

* fix: filters code splitting

* fix: home header

* fix: header changes

* chore: page alignments fixed

* fix: uncommented filters

* fix: used enums

* fix: cards + filters

* fix: enum changes

* fix: reverted package changes

* fix: reverted package changes

* fix: Card + tags seperated + naming fixed

* fix: card + tags seperated + naming fixed

* fix: mobile headers fixed partially

* fix: build errors + minor css

* fix: checkbox spacing

* fix: review changes

* fix: lint errors

* fix: minor review changes
2024-09-05 12:16:24 +05:30
Prateek Shourya c78b2344b8 [WEB-2376] dev: workspace settings improvement & refactor. (#5519)
* [WEB-2376] dev: workspace settings improvement & refactor.

* chore: update `filterWorkspaceSettingLinks` to `shouldRenderSettingLink`.
2024-09-04 20:21:16 +05:30
Anmol Singh Bhatia eea6ceaec4 fix: pwa intake issue comment section z-index (#5522) 2024-09-04 20:15:46 +05:30
Mihir 7750844fc3 [WEB-2216] fix: added validation check for white space for create issue modal (#5468)
* Updated validation check for issue modal

* Updates to functions for throwing errors

* Updates to functions for throwing errors
2024-09-04 20:15:14 +05:30
Aaryan Khandelwal f0da532db7 fix: remove esm build for the ui package (#5517) 2024-09-04 18:12:31 +05:30
dependabot[bot] 5180daae87 chore(deps): bump cryptography in /apiserver/requirements (#5520)
Bumps [cryptography](https://github.com/pyca/cryptography) from 42.0.5 to 43.0.1.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/42.0.5...43.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-04 15:17:25 +04:00
M. Palanikannan 9f12d13dea fix: initialize redis client and pass it to hocuspocus (#5516)
* fix: initialize redis client and pass it to hocuspocus

* chore: renamed func

* fix: yarn lock
2024-09-04 16:35:01 +05:30
Prateek Shourya 20b1558dd7 [WEB-2332] fix: application layout and minor UI improvements. (#5514)
* [WEB-2332] fix: application layout and minor UI improvements.

* [WEB-2332] fix: revert back layout changes.

* fix: lint error.

* fix: lint errors.
2024-09-04 16:09:55 +05:30
Akshita Goyal 22656d0114 [WEB-2273] Chore: header UI (#5467)
* chore: headers + common containers

* fix: filters code splitting

* fix: home header

* fix: header changes

* fix: uncommented filters

* fix: used enums

* fix: enum changes
2024-09-04 14:38:30 +05:30
Aaryan Khandelwal 747905a96d refactor: utility handlers (#5510) 2024-09-03 18:36:31 +05:30
Ketan Sharma b6d596b474 replaced necessary .svg files with .webp and made edits the imports in the file (#5474) 2024-09-03 18:31:01 +05:30
Dima Hinev a36d4480bd chore: search on enter for image picker popover unsplash input (#5499) 2024-09-03 18:29:48 +05:30
rahulramesha 3fbfe94f5f add issue_type to filters from when loading from persisted data (#5509) 2024-09-03 17:59:43 +05:30
M. Palanikannan 1cd7259852 fix: parse redis url to get hostname and port (#5502)
* fix: parse redis url to get hostname and port

* fix: redis url accepted for connection

* chore: add redis url to example env

* fix: let users add redis port and host incase redis url is not present

* chore: create url from host and port variables

* fix: return empty string incase of no config
2024-09-03 17:29:03 +05:30
Aaryan Khandelwal 5840b40d96 [WEB-1116] chore: live server code splitting (#5508)
* chore: live server code splitting

* chore: update import paths

* chore: update bebel path alias

* fix: document types type

* chore: updated error messages
2024-09-03 17:03:50 +05:30
Ketan Sharma 1ef535af7b [WEB-2254] fix: change message for issue via link empty state (#5492)
* change empty state message for issues opened via link

* remove log statement
2024-09-03 15:56:38 +05:30
rahulramesha fd3e3d1a19 fix dev build for plane ui (#5506) 2024-09-03 15:44:00 +05:30
Aaryan Khandelwal 9910ed6e5f [WEB-1116] refactor: page helpers for document transformation (#5503)
* refactor: page helpers for document transformation

* refactor: update tranforamtion function name
2024-09-03 15:31:32 +05:30
Aaryan Khandelwal 539acd58f7 chore: update live server env example file (#5496) 2024-09-03 13:00:08 +05:30
Prateek Shourya a11c12cd7b [ENG-37] chore: sidebar help section revamp. (#5495)
* [ENG-37] chore: sidebar help section revamp.

* fix: lint error.
2024-09-02 21:29:09 +05:30
Anmol Singh Bhatia e9f486eec6 fix: completed cycle issue transfer validation (#5494) 2024-09-02 18:01:37 +05:30
Aaryan Khandelwal 6c3a8a9647 [WEB-1116] feat: pages realtime collaboration (#5493)
* [WEB-1116] feat: pages realtime sync (#5057)

* init: live server for editor realtime sync

* chore: authentication added

* chore: updated logic to convert html to binary for old pages

* chore: added description json on page update

* chore: made all functions generic

* chore: save description in json and html formats

* refactor: document editor components

* chore: uncomment ui package components

* fix: without props extensions refactor

* fix: merge conflicts resolved from preview

* chore: init docker compose

* chore: pages custom error codes

* chore: add health check endpoint to the live server

* chore: update without props extensions type

* chore: better error handling

* chore: update react-hook-form versions

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* fix: docker related fixes

* fix: module type fixes

* fix: nginx update

* fix: adding live server workflow

* fix: workflow fixes

* fix: docker compose fixes

* fix: workflow fixes

* fix: path config

* fix: docker compose warnings

* fix: nginx port forwarding

* fix: update docker compose with new env

* fix: env var fixes

* fix: error handling

* fix: docker compose env var

* fix: compose fixes

* chore: update server start message

* chore: handle errors

* fix: build errors

* chore: update port

* chore: update server port

* chore: show error on authentication fail

* chore: show error on authentication fail

* feat: add redis extension

* chore: updated restore version logic

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
2024-09-02 17:54:12 +05:30
Akshat Jain 2c950713a7 Add RabbitMQ Service to Docker Compose Configuration (#5439)
* fix: celery broker setup

* fix: docker compose update

* fixed rabbitmq vhost issue

* fix: env fixes

* fix-envs-issue in selfhost docker compose

* volume name fix

* added depends on for rabbitmq service

* Add: AMQP_URL for remote rabbitmq urls

* added amqp url im docker compose

* changed default user to guest

* fix: changes the Rabbit mq password var name

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-09-02 17:40:17 +05:30
Prateek Shourya 8526b801f4 fix: cycle analytics reponse. (#5480) 2024-09-02 15:12:32 +05:30
rahulramesha 10c253471c [WEB-2365] fix: Misaligned tooltips in few components (#5486)
* fix mis-aligned tooltips in few components

* fix tooltip for kanban title
2024-09-02 15:09:55 +05:30
Dima Hinev 65b9cfbfe2 fix: improve identation for workspace menu (#5487) (#5489) 2024-09-02 15:08:40 +05:30
Anmol Singh Bhatia 12a304b04f [WEB-2228] fix: dashboard peek overview issue fetch (#5442)
* fix: dashboard peekoverview issue fetch

* fix: intake issue modal remove parent issue action
2024-09-02 14:01:57 +05:30
Aaryan Khandelwal bac5b53ffb [WEB-2348] fix: allow updating comments with just mentions in them (#5471)
* fix: accept mentions while updating comments

* chore: remove console log

* chore: update empty string helper function
2024-09-02 14:00:41 +05:30
Aaryan Khandelwal 03c28a11e8 fix: highlight current user on read only lite text editor (#5472) 2024-09-02 13:58:55 +05:30
rahulramesha bcd08b3159 [WEB-2363] fix: Error while updating issue in cycles (#5478)
* fix update parent stats error

* fix web lint
2024-09-02 13:58:36 +05:30
Bavisetti Narayan 599092d76b chore: added issue webhook (#5463) 2024-08-30 20:26:43 +05:30
Bavisetti Narayan 1d2e7d3fd8 [WEB-2359] chore: resolved the bugs reported in sentry (#5447)
* chore: resolved the bugs reported in sentry

* chore: html content none type validation

* chore: changed the webhook key name
2024-08-30 20:26:09 +05:30
Ketan Sharma 9d9a812f7b changed the old message to the new one (#5475) 2024-08-30 19:58:39 +05:30
Anmol Singh Bhatia b9f78ba42b chore: next image config updated (#5452) 2024-08-30 19:24:29 +05:30
Ketan Sharma 2e890e4d6f [WEB-2294] fix: remove 'Add Project' button from archives route and remove it from the dropdown in header (#5469)
* fix: remove 'Add Project' button from archives route and remove it from the dropdown in header

* Improved Code Logic

* Fixed Clear All Button and UI Fixes
2024-08-30 19:08:35 +05:30
rahulramesha c1d3da0cab use-platform-os hook optimization to not cause re renders (#5453) 2024-08-30 19:05:22 +05:30
rahulramesha 4598b1b49d [WEB-2341] feat: Add display filters and display properties to create/update view dialog (#5451)
* Add display filters and display properties to create view dialog

* revert back display filter selection change
2024-08-30 19:04:38 +05:30
rahulramesha 693085577d [WEB-2316] chore: Render Tooltips and Drop downs in certain places on hover hover to improve rendering performance (#5456)
* render tooltips and dropdowns in certain places post hover to improve performance

* fix useEffect hooks
2024-08-29 21:07:49 +05:30
Anmol Singh Bhatia 33ab6029dc fix: intake issue accept modal (#5465) 2024-08-29 19:26:26 +05:30
Ketan Sharma dc2e7ca3d5 increase z-index from z-20 to z-[21] in dropdown.tsx (#5446) 2024-08-29 19:25:55 +05:30
Mihir b14a919c35 [WEB-2145] chore: added copy button for intake issues (#5455)
* chore: added copy button for intake issues

* Updated button UX

Updated button UX and handleCopyIssue function

* Removed commented code
2024-08-29 18:22:02 +05:30
Nikhil 6d8ba9dfa3 chore: add migration on svg (#5464) 2024-08-29 15:13:17 +05:30
Nikhil 0fbe4c4de2 chore: limit svg uploads (#5462)
* fix: limit svg file uploads

* chore: limit svg uploads
2024-08-29 13:31:41 +05:30
Nikhil 22a214795d chore: user and profile serializers (#5459)
* fix: user serializer

* chore: remove __all__from serializers
2024-08-29 13:31:13 +05:30
Aaryan Khandelwal f843a5153b fix: version history editor overflow (#5461) 2024-08-29 12:49:59 +05:30
Anmol Singh Bhatia 3c78292618 [WEB-2344] fix: quick action hover (#5449)
* fix: quick action hover

* chore: code refactor
2024-08-28 20:02:14 +05:30
Aaryan Khandelwal de273dd618 [WEB-2293] refactor: version editor (#5454)
* refactor: version editor

* chore: added missing props
2024-08-28 19:56:28 +05:30
Aaryan Khandelwal 0cce39ec7c [WEB-2338] chore: handle untitled page breadcrumbs (#5445)
* chore: handle untitle page titles

* chore: store page title in a const
2024-08-28 14:35:45 +05:30
Anmol Singh Bhatia 3ee14771e7 [PWA-1] fix: pwa app sidebar redirection (#5416)
* fix: pwa app sidebar redirection

* chore: pwa app sidebar improvement
2024-08-28 14:33:10 +05:30
Anmol Singh Bhatia 59697d34f8 [PWA-17] chore: project view list header improvement (#5425)
* chore: project view list header improvement

* chore: code refactor
2024-08-28 14:31:27 +05:30
Aaryan Khandelwal 7efda1c392 [WEB-2050] dev: added new information panels to a page (#5409)
* dev: added new information panels to pages

* refactor: update function name
2024-08-28 14:08:29 +05:30
Aaryan Khandelwal fb2a04dc14 chore: add authorization to restore version (#5444) 2024-08-28 14:03:01 +05:30
Mohamed Ashraf e6baa6fa2c chore: add IDX configuration so anyone can edit the project from idx.google.com (#5398)
* chore: add IDX configuration so anyone can edit the project from idx.google.com

* chore: add python, postgres and redis to the idx config
2024-08-28 13:52:25 +05:30
Prateek Shourya 9372677f0c [WEB-2343] fix: click events in spreadsheet layout quick action menu. (#5443) 2024-08-27 22:11:25 +05:30
Akshita Goyal 716300d964 [WEB-2114]: Chore: project cycle optimization (#5430)
* chore: project cycle optimization

* fix: typo

* chore: changed the label typo

* feat: intergrated optimized api

* chore: added every key as plural

* fix: productivity dropdown

* fix: removed logging

* fix: handled loading

* fix: loaders

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-08-27 19:50:20 +05:30
Aaryan Khandelwal b22bdef9e1 chore: move version history editor to edition specific structure (#5441) 2024-08-27 19:50:07 +05:30
guru_sainath 23dcdd6407 [WEB-2115] chore: implemented global paginator and handled project issues pagination v1 (#5432)
* chore: implemented global paginator and handled project issues paginated v1

* chore: updated order_by

* chore: updated updated_at parameter to updated_at__gte

* chore: changed updated_at__gte default value to None
2024-08-27 19:12:55 +05:30
guru_sainath 09209694a4 [WEB-2329] chore: updated UI for module and cycle detail overview (#5435)
* chore: updated UI for module and cycle detail overview

* chore: z-index issue in sheet
2024-08-27 17:45:17 +05:30
Prateek Shourya 88013e3b06 [WEB-2312] chore: minor UI and UX copy improvements. (#5438) 2024-08-27 17:27:59 +05:30
sriram veeraghanta 51fba04226 fix: intake issue bugfixes on external apis 2024-08-27 16:58:42 +05:30
Anmol Singh Bhatia f39fc3e9ca [PWA-12] chore: project analytics modal header improvement (#5427)
* chore: project analytics modal header improvement

* chore: code refactor
2024-08-27 16:49:52 +05:30
Anmol Singh Bhatia e3cd7050fa [PWA-11] fix: pwa kanban layout block (#5426)
* fix: pwa kanban layout block

* chore: code refactor
2024-08-27 16:47:49 +05:30
Anmol Singh Bhatia a19226ac64 fix: intake issue create and update modal (#5434) 2024-08-27 16:47:05 +05:30
rahulramesha e7a41b3c32 redirect to issues page post deletion (#5437) 2024-08-27 16:46:53 +05:30
Ketan Sharma 224c8bc0a1 add vertical padding to div containing SidebarUserMenu (#5436) 2024-08-27 16:08:50 +05:30
Prateek Shourya 83ceba3166 [WEB-2332 | 2295] style: UI improvements. (#5433)
* [WEB-2332] style: minor layout improvements.

* [WEB-2295] style: fix scrollbar padding in workspace list section of profile settings.

* style: add `app-container` css.
2024-08-27 14:26:09 +05:30
Ketan Sharma 08c9bd7949 change z-index from 5 to 1 (#5428) 2024-08-27 12:54:12 +05:30
Ketan Sharma 4689ebe2ba Fix: Error Toast Message for Issue Attachment (#5424) 2024-08-26 16:58:32 +05:30
rahulramesha 0dce67b149 fix to use the correct created by while checking if the current user is the creator of the inbox issue (#5422) 2024-08-26 16:57:01 +05:30
Akshita Goyal 803992cc98 [WEB-1936] fix: flicker issue in issues list layout (#5412)
* fix: flicker issue in issues list layout

* fix: formatting

* fix: optimization

* fix: added optional chaining for safety
2024-08-26 16:56:21 +05:30
rahulramesha 890379b64f Make quick action dropdowns use capture phase of the event to trigger closure on outside click (#5414) 2024-08-26 14:40:11 +05:30
Aaryan Khandelwal a0ed51c845 [WEB-2293] feat: pages version history (#5417)
* chore: project page version

* feat: page version history implemented

* chore: hide save button when version history overlay is active

* refactor: updated navigation logic

* chore: added error states

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-08-26 14:03:55 +05:30
Anmol Singh Bhatia d802316c5c [WEB-2263] fix: god mode wrong credentials error message banner (#5407)
* fix: god mode wrong credentials error message banner

* chore: code refactor
2024-08-26 13:07:00 +05:30
Anmol Singh Bhatia bd3f117545 [PWA-2] fix: pwa input zoom effect (#5402)
* fix: pwa input zoom effect

* fix: pwa input zoom effect

* fix: pwa input zoom effect

* fix: pwa sticky issue comment

* chore: code refactor

* chore: code refactor
2024-08-26 13:02:30 +05:30
Anmol Singh Bhatia 9065932c86 fix: pwa sticky issue comment (#5419) 2024-08-23 19:06:12 +05:30
Prateek Shourya 700f3ee823 chore: pricing update. (#5410) 2024-08-23 18:04:55 +05:30
rahulramesha adf891bcba [WEB-2150] fix: issue selection redirect alert (#5406)
* fix issue selection redirect alert

* change message content for user prompt
2024-08-23 18:00:15 +05:30
576 changed files with 13621 additions and 11211 deletions
+7
View File
@@ -8,6 +8,13 @@ PGDATA="/var/lib/postgresql/data"
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
# RabbitMQ Settings
RABBITMQ_HOST="plane-mq"
RABBITMQ_PORT="5672"
RABBITMQ_USER="plane"
RABBITMQ_PASSWORD="plane"
RABBITMQ_VHOST="plane"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+108 -13
View File
@@ -2,6 +2,12 @@ name: Branch Build
on:
workflow_dispatch:
inputs:
arm64:
description: "Build for ARM64 architecture"
required: false
default: false
type: boolean
push:
branches:
- master
@@ -11,6 +17,8 @@ on:
env:
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
ARM64_BUILD: ${{ github.event.inputs.arm64 }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
jobs:
branch_build_setup:
@@ -27,12 +35,14 @@ jobs:
build_admin: ${{ steps.changed_files.outputs.admin_any_changed }}
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_web: ${{ steps.changed_files.outputs.web_any_changed }}
build_live: ${{ steps.changed_files.outputs.live_any_changed }}
flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }}
steps:
- id: set_env_variables
name: Set Environment Variables
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ env.ARM64_BUILD }}" == "true" ] || ([ "${{ github.event_name }}" == "release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ]); then
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
@@ -44,6 +54,8 @@ jobs:
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
fi
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
flat_branch_name=$(echo ${{ env.TARGET_BRANCH }} | sed 's/[^a-zA-Z0-9\._]/-/g')
echo "FLAT_BRANCH_NAME=${flat_branch_name}" >> $GITHUB_OUTPUT
- id: checkout_files
name: Checkout Files
@@ -79,13 +91,21 @@ jobs:
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
live:
- live/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
branch_build_push_web:
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Web Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -95,7 +115,10 @@ jobs:
- name: Set Frontend Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-frontend:stable,makeplane/plane-frontend:${{ github.event.release.tag_name }}
TAG=makeplane/plane-frontend:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-frontend:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-frontend:latest
else
@@ -134,10 +157,11 @@ jobs:
branch_build_push_admin:
if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Admin Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }}
ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -147,7 +171,10 @@ jobs:
- name: Set Admin Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-admin:stable,makeplane/plane-admin:${{ github.event.release.tag_name }}
TAG=makeplane/plane-admin:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-admin:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-admin:latest
else
@@ -186,10 +213,11 @@ jobs:
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Space Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -199,7 +227,10 @@ jobs:
- name: Set Space Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-space:stable,makeplane/plane-space:${{ github.event.release.tag_name }}
TAG=makeplane/plane-space:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-space:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-space:latest
else
@@ -238,10 +269,11 @@ jobs:
branch_build_push_apiserver:
if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push API Server Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -251,7 +283,10 @@ jobs:
- name: Set Backend Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-backend:stable,makeplane/plane-backend:${{ github.event.release.tag_name }}
TAG=makeplane/plane-backend:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-backend:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-backend:latest
else
@@ -288,12 +323,69 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
branch_build_push_live:
if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
LIVE_TAG: makeplane/plane-live:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Live Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-live:${{ github.event.release.tag_name }}
if [ "${{ github.event.release.prerelease }}" != "true" ]; then
TAG=${TAG},makeplane/plane-live:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-live:latest
else
TAG=${{ env.LIVE_TAG }}
fi
echo "LIVE_TAG=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4
- name: Build and Push Live Server to Docker Hub
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./live/Dockerfile.live
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.LIVE_TAG }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Proxy Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -303,7 +395,10 @@ jobs:
- name: Set Proxy Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-proxy:stable,makeplane/plane-proxy:${{ github.event.release.tag_name }}
TAG=makeplane/plane-proxy:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-proxy:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-proxy:latest
else
+16
View File
@@ -0,0 +1,16 @@
{ pkgs, ... }: {
# Which nixpkgs channel to use.
channel = "stable-23.11"; # or "unstable"
# Use https://search.nixos.org/packages to find packages
packages = [
pkgs.nodejs_20
pkgs.python3
];
services.docker.enable = true;
services.postgres.enable = true;
services.redis.enable = true;
}
@@ -10,8 +10,9 @@ import {
// components
import { AuthenticationMethodCard } from "@/components/authentication";
// helpers
import { UpgradeButton } from "@/components/common/upgrade-button";
import { getBaseAuthenticationModes } from "@/helpers/authentication.helper";
// plane admin components
import { UpgradeButton } from "@/plane-admin/components/common";
// images
import OIDCLogo from "@/public/logos/oidc-logo.svg";
import SAMLLogo from "@/public/logos/saml-logo.svg";
@@ -27,24 +28,24 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
updateConfig,
resolvedTheme,
}) => [
...getBaseAuthenticationModes({ disabled, updateConfig, resolvedTheme }),
{
key: "oidc",
name: "OIDC",
description: "Authenticate your users via the OpenID Connect protocol.",
icon: <Image src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
config: <UpgradeButton />,
unavailable: true,
},
{
key: "saml",
name: "SAML",
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
icon: <Image src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
config: <UpgradeButton />,
unavailable: true,
},
];
...getBaseAuthenticationModes({ disabled, updateConfig, resolvedTheme }),
{
key: "oidc",
name: "OIDC",
description: "Authenticate your users via the OpenID Connect protocol.",
icon: <Image src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
config: <UpgradeButton />,
unavailable: true,
},
{
key: "saml",
name: "SAML",
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
icon: <Image src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
config: <UpgradeButton />,
unavailable: true,
},
];
export const AuthenticationModes: React.FC<TAuthenticationModeProps> = observer((props) => {
const { disabled, updateConfig } = props;
+1
View File
@@ -0,0 +1 @@
export * from "./upgrade-button";
+19
View File
@@ -0,0 +1,19 @@
import { enableStaticRendering } from "mobx-react";
// stores
import { CoreRootStore } from "@/store/root.store";
enableStaticRendering(typeof window === "undefined");
export class RootStore extends CoreRootStore {
constructor() {
super();
}
hydrate(initialData: any) {
super.hydrate(initialData);
}
resetOnSignOut() {
super.resetOnSignOut();
}
}
@@ -0,0 +1,29 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// helpers
import { TAuthErrorInfo } from "@/helpers/authentication.helper";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
const { bannerData, handleBannerData } = props;
if (!bannerData) return <></>;
return (
<div className="relative flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10">
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
<Info size={16} className="text-custom-primary-100" />
</div>
<div className="w-full text-sm font-medium text-custom-primary-100">{bannerData?.message}</div>
<div
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80"
onClick={() => handleBannerData && handleBannerData(undefined)}
>
<X className="w-4 h-4 flex-shrink-0" />
</div>
</div>
);
};
@@ -1,3 +1,4 @@
export * from "./auth-banner";
export * from "./email-config-switch";
export * from "./password-config-switch";
export * from "./authentication-method-card";
-1
View File
@@ -8,4 +8,3 @@ export * from "./empty-state";
export * from "./logo-spinner";
export * from "./page-header";
export * from "./code-block";
export * from "./upgrade-button";
+23 -1
View File
@@ -8,8 +8,16 @@ import { Button, Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common";
// helpers
import {
authErrorHandler,
EAuthenticationErrorCodes,
EErrorAlertType,
TAuthErrorInfo,
} from "@/helpers/authentication.helper";
import { API_BASE_URL } from "@/helpers/common.helper";
import { AuthService } from "@/services/auth.service";
import { AuthBanner } from "../authentication";
// ui
// icons
@@ -53,6 +61,7 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
@@ -91,6 +100,15 @@ export const InstanceSignInForm: FC = (props) => {
[formData.email, formData.password, isSubmitting]
);
useEffect(() => {
if (errorCode) {
const errorDetail = authErrorHandler(errorCode?.toString() as EAuthenticationErrorCodes);
if (errorDetail) {
setErrorInfo(errorDetail);
}
}
}, [errorCode]);
return (
<div className="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 lg:pt-28 transition-all">
<div className="relative flex flex-col space-y-6">
@@ -103,7 +121,11 @@ export const InstanceSignInForm: FC = (props) => {
</p>
</div>
{errorData.type && errorData?.message && <Banner type="error" message={errorData?.message} />}
{errorData.type && errorData?.message ? (
<Banner type="error" message={errorData?.message} />
) : (
<>{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}</>
)}
<form
className="space-y-4"
+1
View File
@@ -18,6 +18,7 @@ export const AdminLayout: FC<TAdminLayout> = observer((props) => {
const { children } = props;
// router
const router = useRouter();
// store hooks
const { isUserLoggedIn } = useUser();
useEffect(() => {
+2 -2
View File
@@ -1,8 +1,8 @@
"use client";
import { ReactNode, createContext } from "react";
// store
import { RootStore } from "@/store/root.store";
// plane admin store
import { RootStore } from "@/plane-admin/store/root.store";
let rootStore = new RootStore();
+2 -2
View File
@@ -13,7 +13,7 @@ import { EInstanceStatus, TInstanceStatus } from "@/helpers/instance.helper";
// services
import { InstanceService } from "@/services/instance.service";
// root store
import { RootStore } from "@/store/root.store";
import { CoreRootStore } from "@/store/root.store";
export interface IInstanceStore {
// issues
@@ -46,7 +46,7 @@ export class InstanceStore implements IInstanceStore {
// service
instanceService;
constructor(private store: RootStore) {
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observable
isLoading: observable.ref,
+1 -1
View File
@@ -6,7 +6,7 @@ import { IUserStore, UserStore } from "./user.store";
enableStaticRendering(typeof window === "undefined");
export class RootStore {
export abstract class CoreRootStore {
theme: IThemeStore;
instance: IInstanceStore;
user: IUserStore;
+2 -2
View File
@@ -1,6 +1,6 @@
import { action, observable, makeObservable } from "mobx";
// root store
import { RootStore } from "@/store/root.store";
import { CoreRootStore } from "@/store/root.store";
type TTheme = "dark" | "light";
export interface IThemeStore {
@@ -21,7 +21,7 @@ export class ThemeStore implements IThemeStore {
isSidebarCollapsed: boolean | undefined = undefined;
theme: string | undefined = undefined;
constructor(private store: RootStore) {
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observables
isNewUserPopup: observable.ref,
+2 -2
View File
@@ -6,7 +6,7 @@ import { EUserStatus, TUserStatus } from "@/helpers/user.helper";
import { AuthService } from "@/services/auth.service";
import { UserService } from "@/services/user.service";
// root store
import { RootStore } from "@/store/root.store";
import { CoreRootStore } from "@/store/root.store";
export interface IUserStore {
// observables
@@ -31,7 +31,7 @@ export class UserStore implements IUserStore {
userService;
authService;
constructor(private store: RootStore) {
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observables
isLoading: observable.ref,
+1
View File
@@ -0,0 +1 @@
export * from "ce/components/common";
+1
View File
@@ -0,0 +1 @@
export * from "ce/store/root.store";
+2 -2
View File
@@ -29,7 +29,7 @@
"postcss": "^8.4.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.0",
"react-hook-form": "7.51.5",
"swr": "^2.2.4",
"tailwindcss": "3.3.2",
"uuid": "^9.0.1",
@@ -47,4 +47,4 @@
"tsconfig": "*",
"typescript": "^5.4.2"
}
}
}
+7 -1
View File
@@ -15,12 +15,18 @@ POSTGRES_DB="plane"
POSTGRES_PORT=5432
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# RabbitMQ Settings
RABBITMQ_HOST="plane-mq"
RABBITMQ_PORT="5672"
RABBITMQ_USER="plane"
RABBITMQ_PASSWORD="plane"
RABBITMQ_VHOST="plane"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
@@ -10,6 +10,7 @@ from .issue import (
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueExpandSerializer,
IssueLiteSerializer,
)
from .state import StateLiteSerializer, StateSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
+2
View File
@@ -67,6 +67,7 @@ class BaseSerializer(serializers.ModelSerializer):
# Import all the expandable serializers
from . import (
IssueSerializer,
IssueLiteSerializer,
ProjectLiteSerializer,
StateLiteSerializer,
UserLiteSerializer,
@@ -86,6 +87,7 @@ class BaseSerializer(serializers.ModelSerializer):
"actor": UserLiteSerializer,
"owned_by": UserLiteSerializer,
"members": UserLiteSerializer,
"parent": IssueLiteSerializer,
}
# Check if field in expansion then expand the field
if expand in expansion:
+15 -4
View File
@@ -1,6 +1,3 @@
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
# Django imports
from django.utils import timezone
from lxml import html
@@ -30,6 +27,9 @@ from .module import ModuleLiteSerializer, ModuleSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
class IssueSerializer(BaseSerializer):
assignees = serializers.ListField(
@@ -274,6 +274,17 @@ class IssueSerializer(BaseSerializer):
return data
class IssueLiteSerializer(BaseSerializer):
class Meta:
model = Issue
fields = [
"id",
"sequence_id",
"project_id",
]
read_only_fields = fields
class LabelSerializer(BaseSerializer):
class Meta:
model = Label
@@ -304,7 +315,7 @@ class IssueLinkSerializer(BaseSerializer):
"created_at",
"updated_at",
]
def validate_url(self, value):
# Check URL format
validate_url = URLValidator()
+23
View File
@@ -71,6 +71,16 @@ class ModuleSerializer(BaseSerializer):
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if Module.objects.filter(
name=module_name, project_id=project_id
).exists():
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
module = Module.objects.create(**validated_data, project_id=project_id)
if members is not None:
ModuleMember.objects.bulk_create(
@@ -93,6 +103,19 @@ class ModuleSerializer(BaseSerializer):
def update(self, instance, validated_data):
members = validated_data.pop("members", None)
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if (
Module.objects.filter(
name=module_name, project=instance.project
)
.exclude(id=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
+2 -7
View File
@@ -1,7 +1,7 @@
# Python imports
import json
# Django improts
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.db.models import Q, Value, UUIDField
@@ -184,13 +184,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None and not project.inbox_view:
if inbox is None:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project's api"
+5 -1
View File
@@ -298,7 +298,11 @@ class ModuleAPIEndpoint(BaseAPIView):
actor_id=str(request.user.id),
issue_id=None,
project_id=str(project_id),
current_instance=None,
current_instance=json.dumps(
{
"module_name": str(module.name),
}
),
epoch=int(timezone.now().timestamp()),
)
module.delete()
@@ -92,6 +92,7 @@ from .page import (
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
PageVersionDetailSerializer,
)
from .estimate import (
+22 -12
View File
@@ -437,17 +437,21 @@ class IssueLinkSerializer(BaseSerializer):
"issue",
]
def validate_url(self, value):
# Check URL format
validate_url = URLValidator()
try:
validate_url(value)
except ValidationError:
raise serializers.ValidationError("Invalid URL format.")
def to_internal_value(self, data):
# Modify the URL before validation by appending http:// if missing
url = data.get("url", "")
if url and not url.startswith(("http://", "https://")):
data["url"] = "http://" + url
# Check URL scheme
if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.")
return super().to_internal_value(data)
def validate_url(self, value):
# Use Django's built-in URLValidator for validation
url_validator = URLValidator()
try:
url_validator(value)
except ValidationError:
raise serializers.ValidationError({"error": "Invalid URL format."})
return value
@@ -533,7 +537,7 @@ class IssueReactionSerializer(BaseSerializer):
"project",
"issue",
"actor",
"deleted_at"
"deleted_at",
]
@@ -552,7 +556,13 @@ class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
fields = "__all__"
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
read_only_fields = [
"workspace",
"project",
"comment",
"actor",
"deleted_at",
]
class IssueVoteSerializer(BaseSerializer):
+69 -3
View File
@@ -5,6 +5,10 @@ from rest_framework import serializers
from .base import BaseSerializer, DynamicBaseSerializer
from .project import ProjectLiteSerializer
# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from plane.db.models import (
User,
Module,
@@ -64,6 +68,16 @@ class ModuleWriteSerializer(BaseSerializer):
members = validated_data.pop("member_ids", None)
project = self.context["project"]
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if Module.objects.filter(
name=module_name, project=project
).exists():
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
module = Module.objects.create(**validated_data, project=project)
if members is not None:
ModuleMember.objects.bulk_create(
@@ -86,6 +100,19 @@ class ModuleWriteSerializer(BaseSerializer):
def update(self, instance, validated_data):
members = validated_data.pop("member_ids", None)
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if (
Module.objects.filter(
name=module_name, project=instance.project
)
.exclude(id=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
@@ -155,16 +182,48 @@ class ModuleLinkSerializer(BaseSerializer):
"module",
]
# Validation if url already exists
def to_internal_value(self, data):
# Modify the URL before validation by appending http:// if missing
url = data.get("url", "")
if url and not url.startswith(("http://", "https://")):
data["url"] = "http://" + url
return super().to_internal_value(data)
def validate_url(self, value):
# Use Django's built-in URLValidator for validation
url_validator = URLValidator()
try:
url_validator(value)
except ValidationError:
raise serializers.ValidationError({"error": "Invalid URL format."})
return value
def create(self, validated_data):
validated_data["url"] = self.validate_url(validated_data.get("url"))
if ModuleLink.objects.filter(
url=validated_data.get("url"),
module_id=validated_data.get("module_id"),
).exists():
raise serializers.ValidationError({"error": "URL already exists."})
return super().create(validated_data)
def update(self, instance, validated_data):
validated_data["url"] = self.validate_url(validated_data.get("url"))
if (
ModuleLink.objects.filter(
url=validated_data.get("url"),
module_id=instance.module_id,
)
.exclude(pk=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
return ModuleLink.objects.create(**validated_data)
return super().update(instance, validated_data)
class ModuleSerializer(DynamicBaseSerializer):
@@ -229,7 +288,14 @@ class ModuleDetailSerializer(ModuleSerializer):
cancelled_estimate_points = serializers.FloatField(read_only=True)
class Meta(ModuleSerializer.Meta):
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"]
fields = ModuleSerializer.Meta.fields + [
"link_module",
"sub_issues",
"backlog_estimate_points",
"unstarted_estimate_points",
"started_estimate_points",
"cancelled_estimate_points",
]
class ModuleUserPropertiesSerializer(BaseSerializer):
+34 -1
View File
@@ -167,7 +167,40 @@ class PageLogSerializer(BaseSerializer):
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = "__all__"
fields = [
"id",
"workspace",
"page",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"workspace",
"page",
]
class PageVersionDetailSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = [
"id",
"workspace",
"page",
"last_saved_at",
"description_binary",
"description_html",
"description_json",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"workspace",
"page",
+22 -3
View File
@@ -16,26 +16,39 @@ from .base import BaseSerializer
class UserSerializer(BaseSerializer):
class Meta:
model = User
fields = "__all__"
# Exclude password field from the serializer
fields = [
field.name
for field in User._meta.fields
if field.name != "password"
]
# Make all system fields and email read only
read_only_fields = [
"id",
"username",
"mobile_number",
"email",
"token",
"created_at",
"updated_at",
"is_superuser",
"is_staff",
"is_managed",
"last_active",
"last_login_time",
"last_logout_time",
"last_login_ip",
"last_logout_ip",
"last_login_uagent",
"token_updated_at",
"last_location",
"last_login_medium",
"created_location",
"is_bot",
"is_password_autoset",
"is_email_verified",
"is_active",
"token_updated_at",
]
extra_kwargs = {"password": {"write_only": True}}
# If the user has already filled first name or last name then he is onboarded
def get_is_onboarded(self, obj):
@@ -208,9 +221,15 @@ class ProfileSerializer(BaseSerializer):
class Meta:
model = Profile
fields = "__all__"
read_only_fields = [
"user",
]
class AccountSerializer(BaseSerializer):
class Meta:
model = Account
fields = "__all__"
read_only_fields = [
"user",
]
+2 -2
View File
@@ -40,7 +40,7 @@ class WebhookSerializer(DynamicBaseSerializer):
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
if ip.is_loopback:
raise serializers.ValidationError(
{"url": "URL resolves to a blocked IP address."}
)
@@ -92,7 +92,7 @@ class WebhookSerializer(DynamicBaseSerializer):
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
if ip.is_loopback:
raise serializers.ValidationError(
{"url": "URL resolves to a blocked IP address."}
)
+12
View File
@@ -6,6 +6,8 @@ from plane.app.views import (
CycleIssueViewSet,
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
CycleProgressEndpoint,
CycleAnalyticsEndpoint,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
CycleArchiveUnarchiveEndpoint,
@@ -106,4 +108,14 @@ urlpatterns = [
CycleArchiveUnarchiveEndpoint.as_view(),
name="cycle-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/progress/",
CycleProgressEndpoint.as_view(),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/analytics/",
CycleAnalyticsEndpoint.as_view(),
name="project-cycle",
),
]
+8 -1
View File
@@ -20,6 +20,7 @@ from plane.app.views import (
IssueViewSet,
LabelViewSet,
BulkArchiveIssuesEndpoint,
IssuePaginatedViewSet,
)
urlpatterns = [
@@ -38,6 +39,12 @@ urlpatterns = [
),
name="project-issue",
),
# updated v1 paginated issues
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/v2/issues/",
IssuePaginatedViewSet.as_view({"get": "list"}),
name="project-issues-paginated",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueViewSet.as_view(
@@ -303,5 +310,5 @@ urlpatterns = [
}
),
name="project-issue-draft",
)
),
]
+3
View File
@@ -98,6 +98,8 @@ from .cycle.base import (
CycleUserPropertiesEndpoint,
CycleViewSet,
TransferCycleIssueEndpoint,
CycleAnalyticsEndpoint,
CycleProgressEndpoint,
)
from .cycle.issue import (
CycleIssueViewSet,
@@ -112,6 +114,7 @@ from .issue.base import (
IssueViewSet,
IssueUserDisplayPropertyEndpoint,
BulkDeleteIssuesEndpoint,
IssuePaginatedViewSet,
)
from .issue.activity import (
File diff suppressed because it is too large Load Diff
+23 -86
View File
@@ -574,105 +574,42 @@ def dashboard_recent_projects(self, request, slug):
def dashboard_recent_collaborators(self, request, slug):
# Subquery to count activities for each project member
activity_count_subquery = (
IssueActivity.objects.filter(
workspace__slug=slug,
actor=OuterRef("member"),
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.values("actor")
.annotate(num_activities=Count("pk"))
.values("num_activities")
)
# Get all project members and annotate them with activity counts
project_members_with_activities = (
ProjectMember.objects.filter(
WorkspaceMember.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
is_active=True,
)
.annotate(
num_activities=Coalesce(
Subquery(activity_count_subquery),
Value(0),
output_field=IntegerField(),
),
is_current_user=Case(
When(member=request.user, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
active_issue_count=Count(
Case(
When(
member__issue_assignee__issue__state__group__in=[
"unstarted",
"started",
],
member__issue_assignee__issue__workspace__slug=slug,
member__issue_assignee__issue__project__project_projectmember__member=request.user,
member__issue_assignee__issue__project__project_projectmember__is_active=True,
then=F("member__issue_assignee__issue__id"),
),
distinct=True,
output_field=IntegerField(),
),
distinct=True,
),
user_id=F("member_id"),
)
.values_list("member", flat=True)
.order_by("is_current_user", "-num_activities")
.values("user_id", "active_issue_count")
.order_by("-active_issue_count")
.distinct()
)
search = request.query_params.get("search", None)
if search:
project_members_with_activities = (
project_members_with_activities.filter(
Q(member__display_name__icontains=search)
| Q(member__first_name__icontains=search)
| Q(member__last_name__icontains=search)
)
)
return self.paginate(
request=request,
queryset=project_members_with_activities,
controller=lambda qs: self.get_results_controller(qs, slug),
return Response(
(project_members_with_activities),
status=status.HTTP_200_OK,
)
class DashboardEndpoint(BaseAPIView):
def get_results_controller(self, project_members_with_activities, slug):
user_active_issue_counts = (
User.objects.filter(
id__in=project_members_with_activities,
)
.annotate(
active_issue_count=Count(
Case(
When(
issue_assignee__issue__state__group__in=[
"unstarted",
"started",
],
issue_assignee__issue__workspace__slug=slug,
issue_assignee__issue__project__project_projectmember__is_active=True,
then=F("issue_assignee__issue__id"),
),
output_field=IntegerField(),
),
distinct=True,
)
)
.values("active_issue_count", user_id=F("id"))
)
# Create a dictionary to store the active issue counts by user ID
active_issue_counts_dict = {
user["user_id"]: user["active_issue_count"]
for user in user_active_issue_counts
}
# Preserve the sequence of project members with activities
paginated_results = [
{
"user_id": member_id,
"active_issue_count": active_issue_counts_dict.get(
member_id, 0
),
}
for member_id in project_members_with_activities
]
return paginated_results
def create(self, request, slug):
serializer = DashboardSerializer(data=request.data)
if serializer.is_valid():
+5 -7
View File
@@ -16,9 +16,7 @@ from rest_framework.response import Response
# Module imports
from ..base import BaseViewSet
from plane.app.permissions import (
allow_permission, ROLE
)
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import (
Inbox,
InboxIssue,
@@ -169,9 +167,9 @@ class InboxIssueViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
inbox_id = Inbox.objects.filter(
inbox_id = Inbox.objects.get(
workspace__slug=slug, project_id=project_id
).first()
)
filters = issue_filters(request.GET, "GET", "issue__")
inbox_issue = (
InboxIssue.objects.filter(
@@ -522,9 +520,9 @@ class InboxIssueViewSet(BaseViewSet):
model=Issue,
)
def retrieve(self, request, slug, project_id, pk):
inbox_id = Inbox.objects.filter(
inbox_id = Inbox.objects.get(
workspace__slug=slug, project_id=project_id
).first()
)
inbox_issue = (
InboxIssue.objects.select_related("issue")
.prefetch_related(
+159 -5
View File
@@ -57,10 +57,11 @@ from plane.utils.paginator import (
from .. import BaseAPIView, BaseViewSet
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
from plane.bgtasks.webhook_task import model_activity
class IssueListEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def get(self, request, slug, project_id):
issue_ids = request.GET.get("issues", False)
@@ -425,6 +426,16 @@ class IssueViewSet(BaseViewSet):
issue = user_timezone_converter(
issue, datetime_fields, request.user.user_timezone
)
# Send the model activity
model_activity.delay(
model_name="issue",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -573,7 +584,15 @@ class IssueViewSet(BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = self.get_queryset().filter(pk=pk).first()
model_activity.delay(
model_name="issue",
model_id=str(serializer.data.get("id", None)),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -599,7 +618,6 @@ class IssueViewSet(BaseViewSet):
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def patch(self, request, slug, project_id):
issue_property = IssueUserProperty.objects.get(
@@ -630,10 +648,8 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
class BulkDeleteIssuesEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN])
def delete(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
@@ -654,3 +670,141 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
{"message": f"{total_issues} issues were deleted"},
status=status.HTTP_200_OK,
)
class IssuePaginatedViewSet(BaseViewSet):
def get_queryset(self):
workspace_slug = self.kwargs.get("slug")
project_id = self.kwargs.get("project_id")
return (
Issue.issue_objects.filter(
workspace__slug=workspace_slug, project_id=project_id
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
# converting the datetime fields in paginated data
datetime_fields = ["created_at", "updated_at"]
paginated_data = user_timezone_converter(
paginated_data, datetime_fields, timezone
)
return paginated_data
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
cursor = request.GET.get("cursor", None)
is_description_required = request.GET.get("description", False)
updated_at = request.GET.get("updated_at__gte", None)
# required fields
required_fields = [
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"is_draft",
"archived_at",
"deleted_at",
"module_ids",
"label_ids",
"assignee_ids",
"link_count",
"attachment_count",
"sub_issues_count",
]
if is_description_required:
required_fields.append("description_html")
# querying issues
base_queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
).order_by("updated_at")
queryset = self.get_queryset().order_by("updated_at")
# filtering issues by greater then updated_at given by the user
if updated_at:
base_queryset = base_queryset.filter(updated_at__gte=updated_at)
queryset = queryset.filter(updated_at__gte=updated_at)
queryset = queryset.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
paginated_data = paginate(
base_queryset=base_queryset,
queryset=queryset,
cursor=cursor,
on_result=lambda results: self.process_paginated_result(
required_fields, results, request.user.user_timezone
),
)
return Response(paginated_data, status=status.HTTP_200_OK)
+1
View File
@@ -566,6 +566,7 @@ class PagesDescriptionViewSet(BaseViewSet):
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.description = request.data.get("description")
page.save()
# Return a success response
page_version.delay(
+9 -7
View File
@@ -5,16 +5,18 @@ from rest_framework.response import Response
# Module imports
from plane.db.models import PageVersion
from ..base import BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import PageVersionSerializer
from plane.app.serializers import (
PageVersionSerializer,
PageVersionDetailSerializer,
)
from plane.app.permissions import allow_permission, ROLE
class PageVersionEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
@@ -25,7 +27,7 @@ class PageVersionEndpoint(BaseAPIView):
pk=pk,
)
# Serialize the page version
serializer = PageVersionSerializer(page_version)
serializer = PageVersionDetailSerializer(page_version)
return Response(serializer.data, status=status.HTTP_200_OK)
# Return all page versions
page_versions = PageVersion.objects.filter(
@@ -24,7 +24,7 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
workspace__slug=slug,
parent__isnull=True,
).filter(
Q(project__isnull=True)
Q(project__isnull=True) & ~Q(entity_type="page")
| (
Q(project__isnull=False)
& Q(project__project_projectmember__member=request.user)
@@ -161,6 +161,8 @@ def process_mention(mention_component):
def process_html_content(content):
if content is None:
return None
processed_content_list = []
for html_content in content:
processed_content = process_mention(html_content)
+2 -2
View File
@@ -174,7 +174,7 @@ def generate_table_row(issue):
if issue["assignees__first_name"] and issue["assignees__last_name"]
else ""
),
issue["labels__name"],
issue["labels__name"] if issue["labels__name"] else "",
issue["issue_cycle__cycle__name"],
dateConverter(issue["issue_cycle__cycle__start_date"]),
dateConverter(issue["issue_cycle__cycle__end_date"]),
@@ -207,7 +207,7 @@ def generate_json_row(issue):
if issue["assignees__first_name"] and issue["assignees__last_name"]
else ""
),
"Labels": issue["labels__name"],
"Labels": issue["labels__name"] if issue["labels__name"] else "",
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(
issue["issue_cycle__cycle__start_date"]
@@ -1,13 +1,11 @@
# Python imports
import json
import requests
# Third Party imports
from celery import shared_task
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
+144 -2
View File
@@ -128,7 +128,9 @@ def extract_mentions(issue_instance):
"mention-component", attrs={"target": "users"}
)
mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
mentions = [
mention_tag["entity_identifier"] for mention_tag in mention_tags
]
return list(set(mentions))
except Exception:
@@ -198,6 +200,16 @@ def create_mention_notification(
"actor": str(activity.get("actor_id")),
"new_value": str(activity.get("new_value")),
"old_value": str(activity.get("old_value")),
"old_identifier": (
str(activity.get("old_identifier"))
if activity.get("old_identifier")
else None
),
"new_identifier": (
str(activity.get("new_identifier"))
if activity.get("new_identifier")
else None
),
},
},
)
@@ -440,6 +452,24 @@ def notifications(
if issue_comment is not None
else ""
),
"old_identifier": (
str(
issue_activity.get(
"old_identifier"
)
)
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(
issue_activity.get(
"new_identifier"
)
)
if issue_activity.get("new_identifier")
else None
),
},
},
)
@@ -489,6 +519,28 @@ def notifications(
if issue_comment is not None
else ""
),
"old_identifier": (
str(
issue_activity.get(
"old_identifier"
)
)
if issue_activity.get(
"old_identifier"
)
else None
),
"new_identifier": (
str(
issue_activity.get(
"new_identifier"
)
)
if issue_activity.get(
"new_identifier"
)
else None
),
"activity_time": issue_activity.get(
"created_at"
),
@@ -572,6 +624,28 @@ def notifications(
"old_value": str(
issue_activity.get("old_value")
),
"old_identifier": (
str(
issue_activity.get(
"old_identifier"
)
)
if issue_activity.get(
"old_identifier"
)
else None
),
"new_identifier": (
str(
issue_activity.get(
"new_identifier"
)
)
if issue_activity.get(
"new_identifier"
)
else None
),
"activity_time": issue_activity.get(
"created_at"
),
@@ -627,6 +701,28 @@ def notifications(
"old_value": str(
last_activity.old_value
),
"old_identifier": (
str(
issue_activity.get(
"old_identifier"
)
)
if issue_activity.get(
"old_identifier"
)
else None
),
"new_identifier": (
str(
issue_activity.get(
"new_identifier"
)
)
if issue_activity.get(
"new_identifier"
)
else None
),
},
},
)
@@ -662,7 +758,31 @@ def notifications(
"old_value": str(
last_activity.old_value
),
"activity_time": str(last_activity.created_at),
"old_identifier": (
str(
issue_activity.get(
"old_identifier"
)
)
if issue_activity.get(
"old_identifier"
)
else None
),
"new_identifier": (
str(
issue_activity.get(
"new_identifier"
)
)
if issue_activity.get(
"new_identifier"
)
else None
),
"activity_time": str(
last_activity.created_at
),
},
},
)
@@ -719,6 +839,28 @@ def notifications(
"old_value"
)
),
"old_identifier": (
str(
issue_activity.get(
"old_identifier"
)
)
if issue_activity.get(
"old_identifier"
)
else None
),
"new_identifier": (
str(
issue_activity.get(
"new_identifier"
)
)
if issue_activity.get(
"new_identifier"
)
else None
),
"activity_time": issue_activity.get(
"created_at"
),
@@ -10,6 +10,7 @@ from bs4 import BeautifulSoup
# Module imports
from plane.db.models import Page, PageLog
from celery import shared_task
from plane.utils.exception_logger import log_exception
def extract_components(value, tag):
@@ -34,42 +35,48 @@ def extract_components(value, tag):
@shared_task
def page_transaction(new_value, old_value, page_id):
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
try:
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
old_value = json.loads(old_value) if old_value else {}
old_value = json.loads(old_value) if old_value else {}
new_transactions = []
deleted_transaction_ids = set()
new_transactions = []
deleted_transaction_ids = set()
# TODO - Add "issue-embed-component", "img", "todo" components
components = ["mention-component"]
for component in components:
old_mentions = extract_components(old_value, component)
new_mentions = extract_components(new_value, component)
# TODO - Add "issue-embed-component", "img", "todo" components
components = ["mention-component"]
for component in components:
old_mentions = extract_components(old_value, component)
new_mentions = extract_components(new_value, component)
new_mentions_ids = {mention["id"] for mention in new_mentions}
old_mention_ids = {mention["id"] for mention in old_mentions}
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
new_mentions_ids = {mention["id"] for mention in new_mentions}
old_mention_ids = {mention["id"] for mention in old_mentions}
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
new_transactions.extend(
PageLog(
transaction=mention["id"],
page_id=page_id,
entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"],
workspace_id=page.workspace_id,
created_at=timezone.now(),
updated_at=timezone.now(),
new_transactions.extend(
PageLog(
transaction=mention["id"],
page_id=page_id,
entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"],
workspace_id=page.workspace_id,
created_at=timezone.now(),
updated_at=timezone.now(),
)
for mention in new_mentions
if mention["id"] not in old_mention_ids or not new_page_mention
)
for mention in new_mentions
if mention["id"] not in old_mention_ids or not new_page_mention
# Create new PageLog objects for new transactions
PageLog.objects.bulk_create(
new_transactions, batch_size=10, ignore_conflicts=True
)
# Create new PageLog objects for new transactions
PageLog.objects.bulk_create(
new_transactions, batch_size=10, ignore_conflicts=True
)
# Delete the removed transactions
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
# Delete the removed transactions
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
except Page.DoesNotExist:
return
except Exception as e:
log_exception(e)
return
+33 -24
View File
@@ -6,6 +6,7 @@ from celery import shared_task
# Module imports
from plane.db.models import Page, PageVersion
from plane.utils.exception_logger import log_exception
@shared_task
@@ -14,31 +15,39 @@ def page_version(
existing_instance,
user_id,
):
# Get the page
page = Page.objects.get(id=page_id)
try:
# Get the page
page = Page.objects.get(id=page_id)
# Get the current instance
current_instance = (
json.loads(existing_instance) if existing_instance is not None else {}
)
# Create a version if description_html is updated
if current_instance.get("description_html") != page.description_html:
# Create a new page version
PageVersion.objects.create(
page_id=page_id,
workspace_id=page.workspace_id,
description_html=page.description_html,
description_binary=page.description_binary,
owned_by_id=user_id,
last_saved_at=page.updated_at,
# Get the current instance
current_instance = (
json.loads(existing_instance)
if existing_instance is not None
else {}
)
# If page versions are greater than 20 delete the oldest one
if PageVersion.objects.filter(page_id=page_id).count() > 20:
# Delete the old page version
PageVersion.objects.filter(page_id=page_id).order_by(
"last_saved_at"
).first().delete()
# Create a version if description_html is updated
if current_instance.get("description_html") != page.description_html:
# Create a new page version
PageVersion.objects.create(
page_id=page_id,
workspace_id=page.workspace_id,
description_html=page.description_html,
description_binary=page.description_binary,
owned_by_id=user_id,
last_saved_at=page.updated_at,
)
return
# If page versions are greater than 20 delete the oldest one
if PageVersion.objects.filter(page_id=page_id).count() > 20:
# Delete the old page version
PageVersion.objects.filter(page_id=page_id).order_by(
"last_saved_at"
).first().delete()
return
except Page.DoesNotExist:
return
except Exception as e:
log_exception(e)
return
+2
View File
@@ -152,6 +152,8 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
retry_count=str(self.request.retries),
)
except Webhook.DoesNotExist:
return
except requests.RequestException as e:
# Log the failed webhook request
WebhookLog.objects.create(
@@ -0,0 +1,28 @@
# Generated by Django 4.2.11 on 2024-08-29 09:40
import django.core.validators
from django.db import migrations, models
import plane.db.models.asset
class Migration(migrations.Migration):
dependencies = [
("db", "0074_deploy_board_and_project_issues"),
]
operations = [
migrations.AlterField(
model_name="fileasset",
name="asset",
field=models.FileField(
upload_to=plane.db.models.asset.get_upload_path,
validators=[
django.core.validators.FileExtensionValidator(
allowed_extensions=["jpg", "jpeg", "png"]
),
plane.db.models.asset.file_size,
],
),
),
]
+3 -2
View File
@@ -1,11 +1,11 @@
# Python imports
from uuid import uuid4
# Django import
from django.conf import settings
from django.core.exceptions import ValidationError
# Django import
from django.db import models
from django.core.validators import FileExtensionValidator
# Module import
from .base import BaseModel
@@ -32,6 +32,7 @@ class FileAsset(BaseModel):
asset = models.FileField(
upload_to=get_upload_path,
validators=[
FileExtensionValidator(allowed_extensions=["jpg", "jpeg", "png"]),
file_size,
],
)
+13 -8
View File
@@ -2,10 +2,8 @@
# Python imports
import os
import ssl
from urllib.parse import urlparse
import certifi
# Third party imports
import dj_database_url
@@ -254,18 +252,25 @@ if AWS_S3_ENDPOINT_URL and USE_MINIO:
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
# RabbitMQ connection settings
RABBITMQ_HOST = os.environ.get("RABBITMQ_HOST", "localhost")
RABBITMQ_PORT = os.environ.get("RABBITMQ_PORT", "5672")
RABBITMQ_USER = os.environ.get("RABBITMQ_USER", "guest")
RABBITMQ_PASSWORD = os.environ.get("RABBITMQ_PASSWORD", "guest")
RABBITMQ_VHOST = os.environ.get("RABBITMQ_VHOST", "/")
AMQP_URL = os.environ.get("AMQP_URL")
# Celery Configuration
if AMQP_URL:
CELERY_BROKER_URL = AMQP_URL
else:
CELERY_BROKER_URL = f"amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}"
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_ACCEPT_CONTENT = ["application/json"]
if REDIS_SSL:
redis_url = os.environ.get("REDIS_URL")
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
CELERY_BROKER_URL = broker_url
else:
CELERY_BROKER_URL = REDIS_URL
CELERY_IMPORTS = (
# scheduled tasks
+78
View File
@@ -0,0 +1,78 @@
# constants
PAGINATOR_MAX_LIMIT = 1000
class PaginateCursor:
def __init__(self, current_page_size: int, current_page: int, offset: int):
self.current_page_size = current_page_size
self.current_page = current_page
self.offset = offset
def __str__(self):
return f"{self.current_page_size}:{self.current_page}:{self.offset}"
@classmethod
def from_string(self, value):
"""Return the cursor value from string format"""
try:
bits = value.split(":")
if len(bits) != 3:
raise ValueError(
"Cursor must be in the format 'value:offset:is_prev'"
)
return self(int(bits[0]), int(bits[1]), int(bits[2]))
except (TypeError, ValueError) as e:
raise ValueError(f"Invalid cursor format: {e}")
def paginate(base_queryset, queryset, cursor, on_result):
# validating for cursor
if cursor is None:
cursor_object = PaginateCursor(PAGINATOR_MAX_LIMIT, 0, 0)
else:
cursor_object = PaginateCursor.from_string(cursor)
# getting the issues count
total_results = base_queryset.count()
page_size = min(cursor_object.current_page_size, PAGINATOR_MAX_LIMIT)
# Calculate the start and end index for the paginated data
start_index = 0
if cursor_object.current_page > 0:
start_index = cursor_object.current_page * page_size
end_index = min(start_index + page_size, total_results)
# Get the paginated data
paginated_data = queryset[start_index:end_index]
# Create the pagination info object
prev_cursor = f"{page_size}:{cursor_object.current_page-1}:0"
cursor = f"{page_size}:{cursor_object.current_page}:0"
next_cursor = None
if end_index < total_results:
next_cursor = f"{page_size}:{cursor_object.current_page+1}:0"
prev_page_results = False
if cursor_object.current_page > 0:
prev_page_results = True
next_page_results = False
if next_cursor:
next_page_results = True
if on_result:
paginated_data = on_result(paginated_data)
# returning the result
paginated_data = {
"prev_cursor": prev_cursor,
"cursor": cursor,
"next_cursor": next_cursor,
"prev_page_results": prev_page_results,
"next_page_results": next_page_results,
"page_count": len(paginated_data),
"total_results": total_results,
"results": paginated_data,
}
return paginated_data
+2 -2
View File
@@ -17,6 +17,7 @@ django-cors-headers==4.3.1
# celery
celery==5.4.0
django_celery_beat==2.6.0
django-celery-results==2.5.1
# file serve
whitenoise==6.6.0
# fake data
@@ -50,7 +51,7 @@ beautifulsoup4==4.12.3
# analytics
posthog==3.5.0
# crypto
cryptography==42.0.5
cryptography==43.0.1
# html validator
lxml==5.2.1
# s3
@@ -61,4 +62,3 @@ zxcvbn==4.4.28
pytz==2024.1
# jwt
PyJWT==2.8.0
+39 -6
View File
@@ -3,9 +3,9 @@ x-app-env: &app-env
- NGINX_PORT=${NGINX_PORT:-80}
- WEB_URL=${WEB_URL:-http://localhost}
- DEBUG=${DEBUG:-0}
- SENTRY_DSN=${SENTRY_DSN}
- SENTRY_DSN=${SENTRY_DSN:-""}
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
# Gunicorn Workers
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-1}
#DB SETTINGS
@@ -21,6 +21,15 @@ x-app-env: &app-env
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/}
# RabbitMQ Settings
- RABBITMQ_HOST=${RABBITMQ_HOST:-plane-mq}
- RABBITMQ_PORT=${RABBITMQ_PORT:-5672}
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER:-plane}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD:-plane}
- RABBITMQ_DEFAULT_VHOST=${RABBITMQ_VHOST:-plane}
- RABBITMQ_VHOST=${RABBITMQ_VHOST:-plane}
- AMQP_URL=${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
# Application secret
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS
@@ -34,10 +43,8 @@ x-app-env: &app-env
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"}
- BUCKET_NAME=${BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
# Admin and Space URLs
- ADMIN_BASE_URL=${ADMIN_BASE_URL}
- SPACE_BASE_URL=${SPACE_BASE_URL}
- APP_BASE_URL=${APP_BASE_URL}
# Live server env
- API_BASE_URL=${API_BASE_URL:-http://api:8000}
services:
web:
@@ -80,6 +87,19 @@ services:
- api
- web
live:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
command: node live/dist/server.js live
deploy:
replicas: ${LIVE_REPLICAS:-1}
depends_on:
- api
- web
api:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
@@ -94,6 +114,8 @@ services:
depends_on:
- plane-db
- plane-redis
- plane-mq
worker:
<<: *app-env
@@ -108,6 +130,7 @@ services:
- api
- plane-db
- plane-redis
- plane-mq
beat-worker:
<<: *app-env
@@ -122,6 +145,7 @@ services:
- api
- plane-db
- plane-redis
- plane-mq
migrator:
<<: *app-env
@@ -153,6 +177,13 @@ services:
volumes:
- redisdata:/data
plane-mq:
<<: *app-env
image: rabbitmq:3.13.6-management-alpine
restart: always
volumes:
- rabbitmq_data:/var/lib/rabbitmq
plane-minio:
<<: *app-env
image: minio/minio:latest
@@ -179,8 +210,10 @@ services:
volumes:
pgdata:
redisdata:
uploads:
logs_api:
logs_worker:
logs_beat-worker:
logs_migrator:
rabbitmq_data:
+2 -2
View File
@@ -442,10 +442,10 @@ function backupData() {
local BACKUP_FOLDER=$PLANE_INSTALL_DIR/backup/$datetime
mkdir -p "$BACKUP_FOLDER"
volumes=$(docker volume ls -f "name=plane-app" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
volumes=$(docker volume ls -f "name=$SERVICE_FOLDER" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
# Check if there are any matching volumes
if [ -z "$volumes" ]; then
echo "No volumes found starting with 'plane-app'"
echo "No volumes found starting with '$SERVICE_FOLDER'"
exit 1
fi
+9
View File
@@ -12,6 +12,7 @@ DEBUG=0
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
API_BASE_URL=http://api:8000
#DB SETTINGS
PGHOST=plane-db
@@ -28,6 +29,14 @@ REDIS_HOST=plane-redis
REDIS_PORT=6379
REDIS_URL=
# RabbitMQ Settings
RABBITMQ_HOST=plane-mq
RABBITMQ_PORT=5672
RABBITMQ_USER=plane
RABBITMQ_PASSWORD=plane
RABBITMQ_VHOST=plane
AMQP_URL=
# Secret Key
SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
+38 -9
View File
@@ -1,12 +1,3 @@
networks:
dev_env:
driver: bridge
volumes:
redisdata:
uploads:
pgdata:
services:
plane-redis:
image: valkey/valkey:7.2.5-alpine
@@ -16,6 +7,20 @@ services:
volumes:
- redisdata:/data
plane-mq:
image: rabbitmq:3.13.6-management-alpine
restart: unless-stopped
networks:
- dev_env
volumes:
- rabbitmq_data:/var/lib/rabbitmq
env_file:
- .env
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST}
plane-minio:
image: minio/minio
restart: unless-stopped
@@ -86,6 +91,20 @@ services:
- worker
- web
live:
build:
context: .
dockerfile: ./live/Dockerfile.dev
restart: unless-stopped
networks:
- dev_env
volumes:
- ./live:/app/live
depends_on:
- api
- worker
- web
api:
build:
context: ./apiserver
@@ -179,3 +198,13 @@ services:
- api
- space
- admin
volumes:
redisdata:
uploads:
pgdata:
rabbitmq_data:
networks:
dev_env:
driver: bridge
+24
View File
@@ -99,6 +99,16 @@ services:
- plane-db
- plane-redis
live:
container_name: plane-live
build:
context: .
dockerfile: ./live/Dockerfile.live
args:
DOCKER_BUILDKIT: 1
restart: always
command: node live/dist/server.js
plane-db:
container_name: plane-db
image: postgres:15.7-alpine
@@ -121,6 +131,19 @@ services:
volumes:
- redisdata:/data
plane-mq:
container_name: plane-mq
image: rabbitmq:3.13.6-management-alpine
restart: always
env_file:
- .env
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
plane-minio:
container_name: plane-minio
image: minio/minio
@@ -154,3 +177,4 @@ volumes:
pgdata:
redisdata:
uploads:
rabbitmq_data:
+23
View File
@@ -0,0 +1,23 @@
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
],
"@babel/preset-typescript"
],
"plugins": [
[
"module-resolver",
{
"root": ["./src"],
"alias": {
"@/core": "./src/core",
"@/plane-live": "./src/ce"
}
}
]
]
}
+7
View File
@@ -0,0 +1,7 @@
API_BASE_URL="http://api:8000"
LIVE_BASE_PATH="/live"
REDIS_URL="redis://localhost:6379"
# If you prefer not to provide a Redis URL, you can set the REDIS_HOST and REDIS_PORT environment variables instead.
REDIS_PORT=6379
REDIS_HOST=localhost
+13
View File
@@ -0,0 +1,13 @@
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 3003
VOLUME [ "/app/node_modules", "/app/live/node_modules"]
CMD ["yarn","dev", "--filter=live"]
+39
View File
@@ -0,0 +1,39 @@
FROM node:18-alpine AS base
# The web Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker.
# Make sure you update this Dockerfile, the Dockerfile in the web workspace and copy that over to Dockerfile in the docs.
FROM base AS builder
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
RUN yarn global add turbo
COPY . .
RUN turbo prune live --docker
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
# First install 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
# Build the project and its dependencies
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN yarn turbo build --filter=live
FROM base AS runner
WORKDIR /app
COPY --from=installer /app .
# COPY --from=installer /app/live/node_modules ./node_modules
EXPOSE 3000
+59
View File
@@ -0,0 +1,59 @@
{
"name": "live",
"version": "0.22.0",
"description": "",
"main": "./src/server.ts",
"private": true,
"type": "module",
"scripts": {
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.11.3",
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^8.28.0",
"@sentry/profiling-node": "^8.28.0",
"@tiptap/core": "^2.4.0",
"@tiptap/html": "^2.3.0",
"axios": "^1.7.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.20.0",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
"lodash": "^4.17.21",
"morgan": "^1.10.0",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"y-prosemirror": "^1.2.9",
"y-protocols": "^1.0.6",
"yjs": "^13.6.14"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
"@types/express-ws": "^3.0.4",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"nodemon": "^3.1.0",
"ts-node": "^10.9.2",
"tsup": "^7.2.0",
"typescript": "^5.4.5"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { ConnectionConfiguration } from "@hocuspocus/server";
// types
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
connection: ConnectionConfiguration
cookie: string;
documentType: TDocumentTypes | undefined;
params: URLSearchParams;
}
export const authenticateUser = async (args: TArgs): Promise<void> => {
const { documentType } = args;
throw Error(`Authentication failed: Invalid document type ${documentType} provided.`);
}
+14
View File
@@ -0,0 +1,14 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
pageId: string;
params: URLSearchParams;
}
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
const { documentType } = args;
throw Error(`Fetch failed: Invalid document type ${documentType} provided.`);
}
+15
View File
@@ -0,0 +1,15 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
pageId: string;
params: URLSearchParams;
updatedDescription: Uint8Array;
}
export const updateDocument = async (args: TArgs): Promise<void> => {
const { documentType } = args;
throw Error(`Update failed: Invalid document type ${documentType} provided.`);
}
+2
View File
@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/ban-types
export type TAdditionalDocumentTypes = {}
+19
View File
@@ -0,0 +1,19 @@
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
// Ensure to call this before importing any other modules!
Sentry.init({
dsn: process.env.LIVE_SENTRY_DSN,
environment: process.env.LIVE_SENTRY_ENVIRONMENT || "development",
integrations: [
// Add our Profiling integration
nodeProfilingIntegration(),
],
// Add Tracing by setting tracesSampleRate
// We recommend adjusting this value in production
tracesSampleRate: Number(process.env.LIVE_SENTRY_TRACES_SAMPLE_RATE) || 0.5,
// Set sampling rate for profiling
// This is relative to tracesSampleRate
profilesSampleRate: 1.0,
});
+148
View File
@@ -0,0 +1,148 @@
// Third-party libraries
import { Redis } from "ioredis";
// Hocuspocus extensions and core
import { Database } from "@hocuspocus/extension-database";
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
// Core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
// Core libraries
import {
fetchPageDescriptionBinary,
updatePageDescription,
} from "@/core/lib/page.js";
// Core types
import { TDocumentTypes } from "@/core/types/common.js";
// Plane live libraries
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
import { updateDocument } from "@/plane-live/lib/update-document.js";
export const getExtensions: () => Promise<Extension[]> = async () => {
const extensions: Extension[] = [
new Logger({
onChange: false,
log: (message) => {
manualLogger.info(message);
},
}),
new Database({
fetch: async ({
documentName: pageId,
requestHeaders,
requestParameters,
}) => {
// request headers
const cookie = requestHeaders.cookie?.toString();
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
return new Promise(async (resolve) => {
try {
let fetchedData = null;
if (documentType === "project_page") {
fetchedData = await fetchPageDescriptionBinary(
params,
pageId,
cookie,
);
} else {
fetchedData = await fetchDocument({
cookie,
documentType,
pageId,
params,
});
}
resolve(fetchedData);
} catch (error) {
manualLogger.error("Error in fetching document", error);
}
});
},
store: async ({
state,
documentName: pageId,
requestHeaders,
requestParameters,
}) => {
// request headers
const cookie = requestHeaders.cookie?.toString();
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
return new Promise(async () => {
try {
if (documentType === "project_page") {
await updatePageDescription(params, pageId, state, cookie);
} else {
await updateDocument({
cookie,
documentType,
pageId,
params,
updatedDescription: state,
});
}
} catch (error) {
manualLogger.error("Error in updating document:", error);
}
});
},
}),
];
const redisUrl = getRedisUrl();
if (redisUrl) {
try {
const redisClient = new Redis(redisUrl);
await new Promise<void>((resolve, reject) => {
redisClient.on("error", (error: any) => {
if (
error?.code === "ENOTFOUND" ||
error.message.includes("WRONGPASS") ||
error.message.includes("NOAUTH")
) {
redisClient.disconnect();
}
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
reject(error);
});
redisClient.on("ready", () => {
extensions.push(new HocusPocusRedis({ redis: redisClient }));
manualLogger.info("Redis Client connected ✅");
resolve();
});
});
} catch (error) {
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
}
} else {
manualLogger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
}
return extensions;
};
+21
View File
@@ -0,0 +1,21 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
// Log the error
manualLogger.error(err);
// Set the response status
res.status(err.status || 500);
// Send the response
res.json({
error: {
message:
process.env.NODE_ENV === "production"
? "An unexpected error occurred"
: err.message,
...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
},
});
};
+38
View File
@@ -0,0 +1,38 @@
import { pinoHttp } from "pino-http";
const transport = {
target: "pino-pretty",
options: {
colorize: true,
},
};
const hooks = {
logMethod(inputArgs: any, method: any): any {
if (inputArgs.length >= 2) {
const arg1 = inputArgs.shift();
const arg2 = inputArgs.shift();
return method.apply(this, [arg2, arg1, ...inputArgs]);
}
return method.apply(this, inputArgs);
},
};
export const logger = pinoHttp({
level: "info",
transport: transport,
hooks: hooks,
serializers: {
req(req) {
return `${req.method} ${req.url}`;
},
res(res) {
return `${res.statusCode} ${res?.statusMessage || ""}`;
},
responseTime(time) {
return `${time}ms`;
},
},
});
export const manualLogger = logger.logger;
+59
View File
@@ -0,0 +1,59 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs"
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
...DocumentEditorExtensionsWithoutProps,
];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = Buffer.from(description).toString("base64");
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(
type,
documentEditorSchema
).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
}
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
contentBinary: Uint8Array
} => {
// convert HTML to JSON
const contentJSON = generateJSON(
descriptionHTML ?? "<p></p>",
DOCUMENT_EDITOR_EXTENSIONS
);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(
documentEditorSchema,
contentJSON,
"default"
);
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return {
contentBinary: encodedData
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Server } from "@hocuspocus/server";
import { handleAuthentication } from "@/core/lib/authentication.js";
import { getExtensions } from "@/core/extensions/index.js";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
return Server.configure({
onAuthenticate: async ({
requestHeaders,
requestParameters,
connection,
// user id used as token for authentication
token,
}) => {
// request headers
const cookie = requestHeaders.cookie?.toString();
// params
const params = requestParameters;
if (!cookie) {
throw Error("Credentials not provided");
}
try {
await handleAuthentication({
connection,
cookie,
params,
token,
});
} catch (error) {
throw Error("Authentication unsuccessful!");
}
},
extensions,
});
};
+78
View File
@@ -0,0 +1,78 @@
import { ConnectionConfiguration } from "@hocuspocus/server";
// services
import { UserService } from "@/core/services/user.service.js";
// types
import { TDocumentTypes } from "@/core/types/common.js";
// plane live lib
import { authenticateUser } from "@/plane-live/lib/authentication.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
const userService = new UserService();
type Props = {
connection: ConnectionConfiguration;
cookie: string;
params: URLSearchParams;
token: string;
};
export const handleAuthentication = async (props: Props) => {
const { connection, cookie, params, token } = props;
// params
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// fetch current user info
let response;
try {
response = await userService.currentUser(cookie);
} catch (error) {
manualLogger.error("Failed to fetch current user:", error);
throw error;
}
if (response.id !== token) {
throw Error("Authentication failed: Token doesn't match the current user.");
}
if (documentType === "project_page") {
// params
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId) {
throw Error(
"Authentication failed: Incomplete query params. Either workspaceSlug or projectId is missing."
);
}
// fetch current user's project membership info
try {
const projectMembershipInfo = await userService.getUserProjectMembership(
workspaceSlug,
projectId,
cookie
);
const projectRole = projectMembershipInfo.role;
// make the connection read only for roles lower than a member
if (projectRole < 15) {
connection.readOnly = true;
}
} catch (error) {
manualLogger.error("Failed to fetch project membership info:", error);
throw error;
}
} else {
await authenticateUser({
connection,
cookie,
documentType,
params,
});
}
return {
user: {
id: response.id,
name: response.display_name,
},
};
};
+112
View File
@@ -0,0 +1,112 @@
// helpers
import {
getAllDocumentFormatsFromBinaryData,
getBinaryDataFromHTMLString,
} from "@/core/helpers/page.js";
// services
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
const pageService = new PageService();
export const updatePageDescription = async (
params: URLSearchParams,
pageId: string,
updatedDescription: Uint8Array,
cookie: string | undefined,
) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error(
"Invalid updatedDescription: must be an instance of Uint8Array",
);
}
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
cookie,
);
} catch (error) {
manualLogger.error("Update error:", error);
throw error;
}
};
const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string,
) => {
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(
workspaceSlug,
projectId,
pageId,
cookie,
);
const { contentBinary } = getBinaryDataFromHTMLString(
pageDetails.description_html ?? "<p></p>",
);
return contentBinary;
} catch (error) {
manualLogger.error(
"Error while transforming from HTML to Uint8Array",
error,
);
throw error;
}
};
export const fetchPageDescriptionBinary = async (
params: URLSearchParams,
pageId: string,
cookie: string | undefined,
) => {
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return null;
try {
const response = await pageService.fetchDescriptionBinary(
workspaceSlug,
projectId,
pageId,
cookie,
);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(
workspaceSlug,
projectId,
pageId,
cookie,
);
if (binary) {
return binary;
}
}
return binaryData;
} catch (error) {
manualLogger.error("Fetch error:", error);
throw error;
}
};
+15
View File
@@ -0,0 +1,15 @@
export function getRedisUrl() {
const redisUrl = process.env.REDIS_URL?.trim();
const redisHost = process.env.REDIS_HOST?.trim();
const redisPort = process.env.REDIS_PORT?.trim();
if (redisUrl) {
return redisUrl;
}
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
return `redis://${redisHost}:${redisPort}`;
}
return "";
}
+46
View File
@@ -0,0 +1,46 @@
import axios, { AxiosInstance } from "axios";
import { config } from "dotenv";
config();
export const API_BASE_URL = process.env.API_BASE_URL ?? "";
export abstract class APIService {
protected baseURL: string;
private axiosInstance: AxiosInstance;
constructor(baseURL: string) {
this.baseURL = baseURL;
this.axiosInstance = axios.create({
baseURL,
withCredentials: true,
});
}
get(url: string, params = {}, config = {}) {
return this.axiosInstance.get(url, {
...params,
...config,
});
}
post(url: string, data = {}, config = {}) {
return this.axiosInstance.post(url, data, config);
}
put(url: string, data = {}, config = {}) {
return this.axiosInstance.put(url, data, config);
}
patch(url: string, data = {}, config = {}) {
return this.axiosInstance.patch(url, data, config);
}
delete(url: string, data?: any, config = {}) {
return this.axiosInstance.delete(url, { data, ...config });
}
request(config = {}) {
return this.axiosInstance(config);
}
}
+78
View File
@@ -0,0 +1,78 @@
// types
import { TPage } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
export class PageService extends APIService {
constructor() {
super(API_BASE_URL);
}
async fetchDetails(
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
): Promise<TPage> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
{
headers: {
Cookie: cookie,
},
}
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async fetchDescriptionBinary(
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
): Promise<any> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
{
headers: {
"Content-Type": "application/octet-stream",
Cookie: cookie,
},
responseType: "arraybuffer",
}
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateDescription(
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
description_binary: string;
description_html: string;
description: object;
},
cookie: string
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
data,
{
headers: {
Cookie: cookie,
},
}
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
}
+61
View File
@@ -0,0 +1,61 @@
// types
import type { IProjectMember, IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
export class UserService extends APIService {
constructor() {
super(API_BASE_URL);
}
currentUserConfig() {
return {
url: `${this.baseURL}/api/users/me/`,
};
}
async currentUser(cookie: string): Promise<IUser> {
return this.get("/api/users/me/", {
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async getUserWorkspaceMembership(
workspaceSlug: string,
cookie: string
): Promise<IProjectMember> {
return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`,
{
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async getUserProjectMembership(
workspaceSlug: string,
projectId: string,
cookie: string
): Promise<IProjectMember> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`,
{
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}
+4
View File
@@ -0,0 +1,4 @@
// types
import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js";
export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
+1
View File
@@ -0,0 +1 @@
export * from "../../ce/lib/authentication.js"
+1
View File
@@ -0,0 +1 @@
export * from "../../ce/lib/fetch-document.js"
+1
View File
@@ -0,0 +1 @@
export * from "../../ce/lib/update-document.js"
+1
View File
@@ -0,0 +1 @@
export * from "../../ce/types/common.js"
+118
View File
@@ -0,0 +1,118 @@
import "@/core/config/sentry-config.js";
import express from "express";
import expressWs from "express-ws";
import * as Sentry from "@sentry/node";
import compression from "compression";
import helmet from "helmet";
// cors
import cors from "cors";
// core hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
const app = express();
expressWs(app);
app.set("port", process.env.PORT || 3000);
// Security middleware
app.use(helmet());
// Middleware for response compression
app.use(
compression({
level: 6,
threshold: 5 * 1000,
}),
);
// Logging middleware
app.use(logger);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// cors middleware
app.use(cors());
const router = express.Router();
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
router.get("/health", (_req, res) => {
res.status(200).json({ status: "OK" });
});
router.ws("/collaboration", (ws, req) => {
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
ws.close();
}
});
app.use(process.env.LIVE_BASE_PATH || "/live", router);
app.use((_req, res) => {
res.status(404).send("Not Found");
});
Sentry.setupExpressErrorHandler(app);
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
});
const gracefulShutdown = async () => {
manualLogger.info("Starting graceful shutdown...");
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
);
// Close the Express server
liveServer.close(() => {
manualLogger.info("Express server closed gracefully.");
process.exit(1);
});
} catch (err) {
manualLogger.error("Error during shutdown:", err);
process.exit(1);
}
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
manualLogger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
gracefulShutdown();
});
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});
+43
View File
@@ -0,0 +1,43 @@
{
"extends": "tsconfig/base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2015"
],
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@/plane-live/*": [
"./src/ce/*"
]
},
"removeComments": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
// Set `sourceRoot` to "/" to strip the build path prefix
// from generated source code references.
// This improves issue grouping in Sentry.
"sourceRoot": "/"
},
"include": [
"src/**/*.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig, Options } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/server.ts"],
format: ["cjs", "esm"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));
+16 -8
View File
@@ -28,14 +28,6 @@ http {
proxy_pass http://web:3000/;
}
location /god-mode/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://admin:3000/god-mode/;
}
location /api/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
@@ -52,6 +44,22 @@ http {
proxy_pass http://api:8000/auth/;
}
location /god-mode/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://admin:3000/god-mode/;
}
location /live/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://live:3000/;
}
location /spaces/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
+2 -1
View File
@@ -7,6 +7,7 @@
"web",
"space",
"admin",
"live",
"packages/editor",
"packages/eslint-config-custom",
"packages/tailwind-config-custom",
@@ -34,7 +35,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^2.0.14"
"turbo": "^2.1.1"
},
"resolutions": {
"@types/react": "18.2.48"
+8
View File
@@ -14,6 +14,12 @@
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"module": "./dist/index.mjs"
},
"./lib": {
"require": "./dist/lib.js",
"types": "./dist/lib.d.mts",
"import": "./dist/lib.mjs",
"module": "./dist/lib.mjs"
}
},
"scripts": {
@@ -29,9 +35,11 @@
},
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@plane/ui": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",
@@ -1,16 +1,17 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Extensions } from "@tiptap/core";
import { SlashCommand } from "@/extensions";
// hooks
import { TFileHandler } from "@/hooks/use-editor";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions } from "@/types";
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
fileHandler: TFileHandler;
issueEmbedConfig: TIssueEmbedConfig | undefined;
provider: HocuspocusProvider;
userDetails: TUserDetails;
};
export const DocumentEditorAdditionalExtensions = (props: Props) => {
@@ -1,111 +0,0 @@
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
export interface CompleteCollaboratorProviderConfiguration {
/**
* The identifier/name of your document
*/
name: string;
/**
* The actual Y.js document
*/
document: Y.Doc;
/**
* onChange callback
*/
onChange: (updates: Uint8Array, source?: string) => void;
/**
* Whether connection to the database has been established and all available content has been loaded or not.
*/
hasIndexedDBSynced: boolean;
}
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
Partial<CompleteCollaboratorProviderConfiguration>;
export class CollaborationProvider {
public configuration: CompleteCollaboratorProviderConfiguration = {
name: "",
document: new Y.Doc(),
onChange: () => {},
hasIndexedDBSynced: false,
};
unsyncedChanges = 0;
private initialSync = false;
constructor(configuration: CollaborationProviderConfiguration) {
this.setConfiguration(configuration);
this.indexeddbProvider = new IndexeddbPersistence(`page-${this.configuration.name}`, this.document);
this.indexeddbProvider.on("synced", () => {
this.configuration.hasIndexedDBSynced = true;
});
this.document.on("update", this.documentUpdateHandler.bind(this));
this.document.on("destroy", this.documentDestroyHandler.bind(this));
}
private indexeddbProvider: IndexeddbPersistence;
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
this.configuration = {
...this.configuration,
...configuration,
};
}
get document() {
return this.configuration.document;
}
public hasUnsyncedChanges(): boolean {
return this.unsyncedChanges > 0;
}
private resetUnsyncedChanges() {
this.unsyncedChanges = 0;
}
private incrementUnsyncedChanges() {
this.unsyncedChanges += 1;
}
public setSynced() {
this.resetUnsyncedChanges();
}
public async hasIndexedDBSynced() {
await this.indexeddbProvider.whenSynced;
return this.configuration.hasIndexedDBSynced;
}
async documentUpdateHandler(_update: Uint8Array, origin: any) {
await this.indexeddbProvider.whenSynced;
// return if the update is from the provider itself
if (origin === this) return;
// call onChange with the update
const stateVector = Y.encodeStateAsUpdate(this.document);
if (!this.initialSync) {
this.configuration.onChange?.(stateVector, "initialSync");
this.initialSync = true;
return;
}
this.configuration.onChange?.(stateVector);
this.incrementUnsyncedChanges();
}
getUpdateFromIndexedDB(): Uint8Array {
const update = Y.encodeStateAsUpdate(this.document);
return update;
}
documentDestroyHandler() {
this.document.off("update", this.documentUpdateHandler);
this.document.off("destroy", this.documentDestroyHandler);
}
}
@@ -1 +0,0 @@
export * from "./collaboration-provider";
@@ -0,0 +1,90 @@
import React from "react";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { IssueWidget } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// types
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
const {
aiHandler,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
} = props;
const extensions = [];
if (embedHandler?.issue) {
extensions.push(
IssueWidget({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
}
// use document editor
const { editor } = useCollaborativeEditor({
disabledExtensions,
editorClassName,
embedHandler,
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
});
const editorContainerClassNames = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
containerClassName,
});
if (!editor) return null;
return (
<PageRenderer
displayConfig={displayConfig}
aiHandler={aiHandler}
editor={editor}
editorContainerClassName={editorContainerClassNames}
id={id}
tabIndex={tabIndex}
/>
);
};
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
(props, ref) => (
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
)
);
CollaborativeDocumentEditorWithRef.displayName = "CollaborativeDocumentEditorWithRef";
export { CollaborativeDocumentEditorWithRef };
@@ -0,0 +1,74 @@
import { forwardRef, MutableRefObject } from "react";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { IssueWidget } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyCollaborativeEditor } from "@/hooks/use-read-only-collaborative-editor";
// types
import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types";
const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => {
const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
realtimeConfig,
serverHandler,
user,
} = props;
const extensions = [];
if (embedHandler?.issue) {
extensions.push(
IssueWidget({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
}
const { editor } = useReadOnlyCollaborativeEditor({
editorClassName,
extensions,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
realtimeConfig,
serverHandler,
user,
});
const editorContainerClassName = getEditorClassNames({
containerClassName,
});
if (!editor) return null;
return (
<PageRenderer
displayConfig={displayConfig}
id={id}
editor={editor}
editorContainerClassName={editorContainerClassName}
/>
);
};
const CollaborativeDocumentReadOnlyEditorWithRef = forwardRef<
EditorReadOnlyRefApi,
ICollaborativeDocumentReadOnlyEditor
>((props, ref) => (
<CollaborativeDocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
));
CollaborativeDocumentReadOnlyEditorWithRef.displayName = "CollaborativeDocumentReadOnlyEditorWithRef";
export { CollaborativeDocumentReadOnlyEditorWithRef };
@@ -1,105 +0,0 @@
import React from "react";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useDocumentEditor } from "@/hooks/use-document-editor";
// plane editor types
import { TEmbedConfig } from "@/plane-editor/types";
// types
import {
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
TAIHandler,
TDisplayConfig,
TExtensions,
TFileHandler,
} from "@/types";
interface IDocumentEditor {
aiHandler?: TAIHandler;
containerClassName?: string;
disabledExtensions?: TExtensions[];
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: TEmbedConfig;
fileHandler: TFileHandler;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
handleEditorReady?: (value: boolean) => void;
id: string;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions: () => Promise<IMentionSuggestion[]>;
};
onChange: (updates: Uint8Array) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
value: Uint8Array;
}
const DocumentEditor = (props: IDocumentEditor) => {
const {
aiHandler,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
onChange,
placeholder,
tabIndex,
value,
} = props;
// use document editor
const { editor, isIndexedDbSynced } = useDocumentEditor({
disabledExtensions,
id,
editorClassName,
embedHandler,
fileHandler,
value,
onChange,
handleEditorReady,
forwardedRef,
mentionHandler,
placeholder,
tabIndex,
});
const editorContainerClassNames = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
containerClassName,
});
if (!editor || !isIndexedDbSynced) return null;
return (
<PageRenderer
displayConfig={displayConfig}
aiHandler={aiHandler}
editor={editor}
editorContainerClassName={editorContainerClassNames}
id={id}
tabIndex={tabIndex}
/>
);
};
const DocumentEditorWithRef = React.forwardRef<EditorRefApi, IDocumentEditor>((props, ref) => (
<DocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
export { DocumentEditorWithRef };
@@ -1,19 +0,0 @@
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@/extensions";
/**
* @description return an object with contentJSON and editorSchema
* @description contentJSON- ProseMirror JSON from HTML content
* @description editorSchema- editor schema from extensions
* @param {string} html
* @returns {object} {contentJSON, editorSchema}
*/
export const generateJSONfromHTMLForDocumentEditor = (html: string) => {
const extensions = [...CoreEditorExtensionsWithoutProps(), ...DocumentEditorExtensionsWithoutProps()];
const contentJSON = generateJSON(html ?? "<p></p>", extensions as Extensions);
const editorSchema = getSchema(extensions as Extensions);
return {
contentJSON,
editorSchema,
};
};

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