Compare commits

...
Author SHA1 Message Date
Palanikannan1437 94ce5ce332 color support added for row and columns and fixed naming conventions 2023-10-13 14:42:04 +05:30
Palanikannan1437 020e5fe922 working tables with row/column selectors 2023-10-12 21:27:20 +05:30
Palanikannan1437 b6f1cb71d8 changed readme title of core package 2023-10-10 18:18:46 +05:30
Palanikannan1437 2ce9f3be98 Added rich text editor documentation 2023-10-10 18:07:24 +05:30
Palanikannan1437 8ba39dc1ea Completed readme for rich-text-editor 2023-10-10 17:48:26 +05:30
Palanikannan1437 67fd3e296a fixed tsconfig by removing path aliases 2023-10-10 17:29:24 +05:30
Palanikannan1437 3ea7ab3151 completed core package's documentation 2023-10-10 17:25:45 +05:30
Palanikannan1437 ede27b7b17 added usage of common dependance for slash commands 2023-10-10 16:17:26 +05:30
Palanikannan1437 d9c3407eed fixed ts issues with link plugin 2023-10-10 14:35:07 +05:30
Palanikannan1437 8552ef24a6 added editor/core readme and fixed imports 2023-10-09 17:56:35 +05:30
Palanikannan1437 6cb9f73006 updated bubble menu to use core package's utilities 2023-10-09 17:19:24 +05:30
Palanikannan1437 37d5e83e92 fixed toggle Underline behvaiour 2023-10-05 10:32:24 +05:30
Palanikannan1437 cfadb5a793 fixed bugs returning empty editor boxes 2023-10-04 23:07:14 +05:30
Palanikannan1437 a6b0ef221f removed tiptap instance from web 2023-10-04 21:51:18 +05:30
Palanikannan1437 0cf5ad684b Merge branch 'develop' into comment-editor 2023-10-04 21:50:09 +05:30
Palanikannan1437 a9d197cda7 removed extra margin from access specifiers 2023-10-04 21:47:42 +05:30
1c43d313d1 chore: remove unnecessary OAuth envs (#2378)
* chore: remove unnecessary oauth envs

* merge conflicts resolved

* fix: adding new service

---------

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

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

* dev: remove print logs

* dev: formatting

---------

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

* chore: opacity removed from member setting page

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

* fix: next_path redirection not working

* fix: authentication workflow update in plane deploy

---------

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

* dev: configuration enable magic and email/password signup

* dev: update unsplash keys

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

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

* dev: add email and minio redirection urls
2023-10-04 17:39:12 +05:30
Luis CruzandGitHub 981e2aafdd Update index.tsx (#2343)
Fixes #2342
2023-10-04 17:37:01 +05:30
sriram veeraghantaandGitHub 4017f6bc55 fix: sync job pr description escaped values fix (#2366) 2023-10-04 15:29:45 +05:30
Palanikannan1437 696a2e9251 fixed shift enter behavior for lists 2023-10-04 15:28:55 +05:30
sriram veeraghantaandGitHub db1bcdb54f fix: sync workflow fixes (#2365) 2023-10-04 15:07:01 +05:30
Dakshesh JainandGitHub cecdf890de fix: issue relation mutation and draft issue (#2340)
* fix: issue relation mutation and draft issue

* fix: 'New Issue' in gantt view

fix: emoji select going under

* fix: profile page typo
2023-10-04 14:55:41 +05:30
Palanikannan1437 2fd9198fcf modified turbo config for build pipeline of space and web projects 2023-10-04 01:37:49 +05:30
Palanikannan1437 18c9e64f48 added support for range commands 2023-10-04 01:26:40 +05:30
Palanikannan1437 95439fbbef added comment editor with fixed menu 2023-10-04 01:06:16 +05:30
Palanikannan1437 99618e93a7 fixed modal form issue 2023-10-03 19:59:09 +05:30
Palanikannan1437 ff29c1c87e Enter key behaviour added for Comments 2023-10-03 19:51:23 +05:30
Palanikannan1437 fc8284d458 fixed tailwind config for editor 2023-10-03 19:26:48 +05:30
d9bd07886f dev: update sync workflow to run only when the source repo is configured (#2346)
* dev: update sync workflow to run only when the source repo is configured

* fix: naming convention changes

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2023-10-03 18:41:26 +05:30
Palanikannan1437 f408eb3055 fixed dependencies in editor package 2023-10-03 10:01:10 +05:30
Palanikannan1437 95e5a30614 removed absolute imports from rich-text-editor 2023-10-03 09:32:16 +05:30
Palanikannan1437 0080880852 removed absolute imports from lite-text-editor 2023-10-03 09:30:11 +05:30
Palanikannan1437 857fa18719 removed absolute imports from editor/core package 2023-10-03 09:27:52 +05:30
Palanikannan1437 b479718ece onSubmit on enterkey captured 2023-10-02 20:10:05 +05:30
Palanikannan1437 2c804c8d6c trimming html at start and end of content added 2023-10-02 17:44:56 +05:30
Palanikannan1437 51e281592f replaced all read-only instances 2023-10-02 16:35:54 +05:30
Palanikannan1437 de07f63089 read only editor support added 2023-10-02 16:24:57 +05:30
sriram veeraghantaandGitHub 0d0cf3052a fix: updated readme fixes (#2339) 2023-10-02 16:13:27 +05:30
Palanikannan1437 3d87a56e3b refactoring tiptap to core/lite/rich text editor 2023-10-02 14:52:38 +05:30
NikhilandGitHub 6d463ded1c dev: rename token variables (#2338) 2023-10-02 14:50:30 +05:30
NikhilandGitHub de33e6775f dev: update add permissions to the action (#2337) 2023-10-02 14:40:31 +05:30
NikhilandGitHub c96960955b fix: variable name for token (#2336) 2023-10-02 14:31:25 +05:30
Palanikannan1437 b5228bb0ef fixed global css issue with highlight js 2023-10-02 14:15:16 +05:30
sriram veeraghantaandGitHub 7eba4a5032 fix: ui package readme added (#2334) 2023-10-02 13:40:28 +05:30
NikhilandGitHub c56aacdd13 dev: create action to sync PR changes to the repo (#2333) 2023-10-02 13:28:43 +05:30
Palanikannan1437 da86f1ad03 refactoring to LiteTextEditor and RichTextEditor 2023-09-30 17:20:59 +05:30
Anmol Singh BhatiaandGitHub ebf79ee2aa fix: workspace view add issue mutation fix (#2317) 2023-09-29 20:02:56 +05:30
NikhilandGitHub 6b5fe0fff1 fix: update build workflow for the deploy app (#2315) 2023-09-29 19:14:07 +05:30
Anmol Singh BhatiaandGitHub f73135c504 fix: workspace view redirection fix, style: spreadsheet view shadow scroll fix (#2314)
* fix: workspace view redirection fix

* style: spreadsheet view scroll shadow fix
2023-09-29 19:03:36 +05:30
sriram veeraghantaandGitHub 843ba5bb63 fix: unsplash api fix (#2310) 2023-09-29 18:06:33 +05:30
Anmol Singh BhatiaandGitHub 4cab00ec79 fix: workspace view filters count fix (#2307) 2023-09-29 16:40:02 +05:30
Bavisetti NarayanandGitHub f1879a404d dev: updated migrations for 0.13-dev (#2305)
* chore: epoch migration batch size changed

* chore: reoredered the migration files

* dev: updated migrations for 0.13-dev

* chore: added epoch field

* dev: merged the migration files
2023-09-29 16:12:54 +05:30
Anmol Singh BhatiaandGitHub 1b28125919 chore: workspace view order by removed (#2303) 2023-09-29 15:29:50 +05:30
Anmol Singh BhatiaandGitHub 2a770e4a95 chore: workspace view mutation fix ,bug fixes and code refactor (#2301)
* chore: workspace view mutation fix ,bug fixes and code refactor

* chore: update workspace view toast alert added
2023-09-29 15:05:27 +05:30
Anmol Singh BhatiaandGitHub 459999e8c9 chore: global issues ui improvement and bug fixes (#2300) 2023-09-29 12:36:38 +05:30
NikhilandGitHub 6cb4b222d0 chore: label create error (#2299) 2023-09-29 12:27:19 +05:30
a048e513b7 chore: workspace view display filters and properties , code refactor (#2295)
* chore: spreadsheet view context

* chore: spreadsheet context provider

* chore: spreadsheet view context

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

* fix: build error fix

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

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-09-28 20:34:57 +05:30
Palanikannan1437 a754300116 Merge branch 'develop' into comment-editor 2023-09-28 20:25:52 +05:30
Palanikannan1437 efcc7f20cc added seperation of extensions and props 2023-09-28 20:25:31 +05:30
Palanikannan1437 4298b0500e refactored editor to not require workspace slug 2023-09-28 16:05:52 +05:30
NikhilandGitHub 4503810aeb fix: notifications for created by me and assigned to me (#2292) 2023-09-28 15:37:42 +05:30
Dakshesh JainandGitHub ec91a0d2e5 fix: ui and bugs (#2289)
* fix: 24 character limit on first & last name in onboarding page

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

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

* fix: issue type showing in archive issue

* fix: custom menu overflowing

* fix: changing subscriber in filters has no effect

* style: border in quick-add

* fix: on onboarding member role overflowing

* fix: inconsistent icons in issue detail

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

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

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

* chore: update permissions for global views

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-09-28 13:16:31 +05:30
NikhilandGitHub 32d2f912f7 fix: inbox issue deletes (#2290) 2023-09-28 13:14:37 +05:30
Palanikannan1437 d639a0126d new structure of editor components 2023-09-27 19:49:15 +05:30
Bavisetti NarayanandGitHub 9dd22f07f4 chore: removed project filter (#2284) 2023-09-27 19:16:22 +05:30
Bavisetti NarayanandGitHub 7404fe71b1 chore: sort order and issue props for global views (#2283) 2023-09-27 17:30:52 +05:30
NikhilandGitHub 7b453dd6b5 chore: make target dates inclusive when filtering (#2276) 2023-09-27 15:06:23 +05:30
Bavisetti NarayanandGitHub 191aecaaac chore: updated created at in draft issue (#2278) 2023-09-27 15:05:49 +05:30
Dakshesh JainandGitHub e00ae0b48a style: calender quick-add same width as single date (#2280)
* style: calender quick-add same width as single date

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

* fix: quick add opening in list-layout

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

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

* feat: scroll to the bottom when issue is created

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

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

* fix: spacing for issue card in spreadsheet view

* style: gantt layout quick-add padding

style: removed 'State group' from draft issue

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

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

* style: spreadsheet view revamp and code refactor

* feat: workspace view

* fix: build fix

* chore: sidebar workspace issues redirection updated
2023-09-26 19:56:59 +05:30
Aaryan KhandelwalandGitHub a187e7765c fix: user dashboard greeting timezone (#2267)
* chore: user greeting timezone

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

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

---------

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

* feat: scroll to the bottom when issue is created

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

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

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

* dev: resplit migrations into two different files

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

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

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

* chore: routed home to login route as login page

* chore: updated nginx config to route to login

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

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

* fix: inline create issue for draft issue

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

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

* fix: build error due to dynamic import
2023-09-25 19:08:26 +05:30
Anmol Singh BhatiaandGitHub de7a672b79 fix: bug fixes and ui improvement (#2250) 2023-09-25 16:15:49 +05:30
Palanikannan1437 063f4c774a comment editor basic version added 2023-09-25 14:05:17 +05:30
0e96eddb57 rename view to layout (#2255)
Co-authored-by: Your Name <you@example.com>
2023-09-25 13:38:49 +05:30
Anmol Singh BhatiaandGitHub afa10d7195 fix: sub issue state and member select build error (#2254) 2023-09-25 13:18:03 +05:30
Anmol Singh BhatiaandGitHub 68c8741f93 fix: bug fix related to fetching dropdown options for the profile issue (#2246) 2023-09-25 12:18:35 +05:30
Henit Chobisa 4981c3dbfe chore: updated pulls build CI for using turbo builds 2023-09-22 17:04:56 +00:00
PalanikannanM bb13f35465 fixed dependency issue with react type resolution 2023-09-22 16:23:27 +00:00
Bavisetti NarayanandGitHub e8d303dd10 chore: changed priority props in workspace and project (#2253) 2023-09-22 19:48:07 +05:30
c9a6380636 style: settings page improvement (#2211)
* style: settings page improvement

* style: toggle switch styling

---------

Co-authored-by: Anmol Singh Bhatia <asb@Anmols-MacBook-Pro.local>
2023-09-22 18:47:10 +05:30
Palanikannan1437 cbb1c1884f resolved lock life and missing deps 2023-09-22 18:07:07 +05:30
guru_sainathandGitHub 1aadbee7e2 fix: resolved pending issue graph in analytics, user wishes in dashboard, and typo in projects list (#2247) 2023-09-22 17:43:23 +05:30
Palanikannan1437 9079fa4766 improved dev experience to build the tiptap package before starting dev server 2023-09-22 17:35:08 +05:30
M. PalanikannanandGitHub 0f9aae6ff9 Merge branch 'develop' into packaging-tiptap 2023-09-22 17:26:31 +05:30
Palanikannan1437 36fd719f7f removed old tiptap dependency 2023-09-22 17:15:19 +05:30
Palanikannan1437 fa01dfc652 migrated all components to use the common package 2023-09-22 16:45:09 +05:30
Bavisetti NarayanandGitHub 02060f654c chore: added epoch in draft (#2244)
* chore: added epoch in draft

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

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

* fix: added draft filter

---------

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

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

* dev: updated docker container name

---------

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

* feat: updated UI for sub issues

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

* chore: removed repeated code
2023-09-21 15:39:45 +05:30
Palanikannan1437 5f8a0c3f94 Merge branch 'develop' into packaging-tiptap 2023-09-20 19:52:05 +05:30
Palanikannan1437 8e71ce0c50 finally fixed import errors 2023-09-20 19:03:06 +05:30
Bavisetti NarayanandGitHub 60ae940d40 chore: sub issues count in individual issue (#2221) 2023-09-20 17:00:03 +05:30
Dakshesh JainandGitHub cdfff12f4f fix: fields not getting selected in the create issue form (#2212)
* fix: hydration error and draft issue workflow

* fix: build error

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

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

* style: state select dropdown styling

* fix: state icon attribute names

* chore: state select dynamic dropdown

* chore: member select dynamic dropdown

* chore: label select dynamic dropdown

* chore: priority select dynamic dropdown

* chore: label select dropdown improvement

* refactor: state dropdown location

* chore: dropdown improvement and code refactor

* chore: dynamic dropdown hook type added

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-09-20 12:24:52 +05:30
Palanikannan1437 28697e3c0e updated package name to @plane/editor 2023-09-20 12:19:35 +05:30
Palanikannan1437 3b9c29cfd4 exported tiptap editor with and without ref 2023-09-20 11:26:16 +05:30
Palanikannan1437 7ef1745e38 removed old tailwind config and fixed plane package imports 2023-09-20 10:10:11 +05:30
Bavisetti NarayanandGitHub 63c4792e70 fix: changed time to timestamp (#2217) 2023-09-19 21:36:39 +05:30
Bavisetti NarayanandGitHub ce562fa3ea fix: migration files (#2215) 2023-09-19 20:15:02 +05:30
Palanikannan1437 46c761429f Merge branch 'develop' into packaging-tiptap 2023-09-19 19:54:06 +05:30
Palanikannan1437 e35848718d fixed types 2023-09-19 19:49:15 +05:30
Bavisetti NarayanandGitHub a6a0eb9774 chore: added epoch in issue activity (#2187) 2023-09-19 19:46:57 +05:30
Bavisetti NarayanandGitHub d603c1e8f0 fix: tracking logs for issue activity (#2213) 2023-09-19 19:46:03 +05:30
Bavisetti NarayanandGitHub 405ef9314f feat: workspace views (#2005)
* feat: workspace views

* fix: added project member filter

* fix: added pagination in workspace views

* fix: filters and group up by for workspace issues

* fix: changed name workspace view to global view

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

* dev: add analytics and dockerized variable in settings

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

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

* updated docs to remove tiptap pro setup instructions

* chore: removed pro extension promt from setup.sh

* chore: Removed Pro Extension Setup from CI

---------

Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
2023-09-19 16:44:12 +05:30
Palanikannan1437 35ffb850b7 removed tiptap pro extension 2023-09-19 16:37:40 +05:30
Palanikannan1437 8e76809c9b abstracted upload and delete functions 2023-09-19 13:42:54 +05:30
Dakshesh JainandGitHub f6b92fc953 fix: activity not coming for blocking/blocked, 'related to' and duplicate (#2189)
* fix: activity not coming for duplicate, relatesd to and for blocked/blocking

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

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

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

* fix: build error
2023-09-19 12:56:32 +05:30
Palanikannan1437 e9e6f439b4 merge develop into packaging-tiptap 2023-09-19 08:39:23 +05:30
Anmol Singh BhatiaandGitHub 5d331477ef chore: settings bug fixes and ui improvement (#2198)
* fix: settings bug fixes and ui improvement

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

* style: workspace settings ui revamp

* style: project settings and image upload modal improvement

* style: profile setting ui revamp

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

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

* fix: build issue

* fix: draft issue modal opening in kanban card
2023-09-15 12:51:10 +05:30
Dakshesh JainandGitHub eda4da8aed feat: draft issues (#2188)
* feat: draft issue

issues can be saved as draft

* style: modal position
2023-09-14 18:38:31 +05:30
sriram veeraghantaandGitHub 759a604cb8 fix: posthog integration (#2186) 2023-09-14 16:38:41 +05:30
sriram veeraghantaandGitHub 6659cfc8b0 fix: track events issue and env variables fixes (#2184)
* fix: track event fixes

* fix: adding env variables to trubo
2023-09-14 16:05:31 +05:30
Bavisetti NarayanandGitHub a53b428bbd chore: endpoints and history logs for issue draft (#2180)
* chore: history logs for issue draft

* fix: created seperated endpoints for issue drafts

* fix: fixed the typo
2023-09-14 15:38:11 +05:30
Bavisetti NarayanandGitHub 4e0e02522f fix: changed payload for issue subgroups (#2181)
* fix: sub groups in cycle module and my issues

* fix: changed payload for issue subgroups
2023-09-14 15:29:35 +05:30
sriram veeraghantaandGitHub f983d787b4 env and docker fixes (#2182) 2023-09-14 12:26:07 +05:30
Anmol Singh BhatiaandGitHub 87abf3ccb1 style: project setting ui revamp (#2177)
* style: project settings navigation sidebar added

* chore: emoji and image picker close on outside click added

* style: project setting general page revamp

* style: project setting member page revamp

* style: project setting features page revamp

* style: project setting state page revamp

* style: project setting integrations page revamp

* style: project setting estimates page revamp

* style: project setting automation page revamp

* style: project setting label page revamp

* chore: member select improvement for member setting page

* chore: toggle switch component improvement

* style: project automation setting ui improvement

* style: module icon added

* style: toggle switch improvement

* style: ui and spacing consistency

* style: project label setting revamp

* style: project state setting ui improvement

* chore: integration setting repo select validation

* chore: code refactor

* fix: build fix
2023-09-13 23:09:55 +05:30
d0f6ca3bac [chore] Update setup.sh, with removed replacement script & added project-level ENVs (#2115)
* chore: Updated Setup Script for Splitting Env File

* chore: updated dockerfile for using inproject env varaibles

* chore: removed husky replacement script

* chore: updated shell script using sed

* chore: updated dockerfiles with removed cp statement

* chore: added example env for apiserver

* chore: refactored secret generation for backend

* chore: removed replacement script

* chore: updated docker-compose with removed env variables

* chore: resolved comments in setup.sh and docker-compose

* chore: removed secret key placeholder in apiserver example env

* chore: updated root env for project less env variables

* chore: removed project level env update from root env logic

* chore: updated API_BASE_URL in .env.example

* chore: restored docker argument as env NEXT_PUBLIC_API_BASE_URL

* chore: added pg missing env variables

* [chore] Updated web and deploy backend configuration for reverse proxy & decoupled Plane Deploy URL generation for web (#2135)

* chore: removed api url build arg from compose

* chore: set public api default argument to black string for self hosted

* chore: updated web services to accept blank string as API URL

* chore: added env variables for pg compose service

* chore: modified space app services to use accept empty string as api base

* chore: conditionally trigger web url value based on argument

* fix: made web to use identical host with spaces suffix on absense of Deploy URL for deploy

* chore: added example env for PUBLIC_DEPLOY Env

* chore: updated web dockerfile with addition as PLANE_DEPLOY Argument

* API BASE URL global update

* API BASE URL replace with api server

* api base url fixes

* typo  fixes

---------

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

* dev: remove API_BASE_URL from environment variable

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-09-13 20:21:02 +05:30
af73bbe718 typo: changed customize to customise in project automation settings (#2153)
Co-authored-by: Neo <neo@Neos-MacBook-Pro.local>
2023-09-13 20:15:48 +05:30
Bavisetti NarayanandGitHub 9033ceb03c fix: sub groups in cycle module and my issues (#2176) 2023-09-13 19:50:34 +05:30
Dakshesh JainandGitHub 9bac7cb036 feat: issue link to create relation between issues (#2171)
* feat: issue linking

* fix: search params to filter out selected issue

* style: changed icons

* fix: build error on web-view

* fix: build error

* fix: build error on web-view component
2023-09-13 19:41:11 +05:30
Anmol Singh BhatiaandGitHub 32d08570e7 chore: peek overview for issue view and my issue view (#2172)
* chore: peak overview for issue view and my issue view

* fix: profile issue peak overview mutation fix

* chore: code refactor

* fix: image prefix url fix
2023-09-13 19:33:58 +05:30
Bavisetti NarayanandGitHub 1b1ed37405 chore: changed default props for worskpace and project members (#2175) 2023-09-13 19:13:31 +05:30
Bavisetti NarayanandGitHub 42d38f7531 feat: changed payload for swimlanes (#2173) 2023-09-13 18:25:57 +05:30
Bavisetti NarayanandGitHub 61672f47ac fix: migration files (#2169) 2023-09-13 13:23:40 +05:30
Dakshesh JainandGitHub 23e62c83eb refactor: switched priority null -> 'none' (#2166) 2023-09-13 13:22:31 +05:30
sriram veeraghantaandGitHub e58b76c8a6 fix: tailwind common config (#2168) 2023-09-13 12:50:04 +05:30
Anmol Singh BhatiaandGitHub 4ce01ca4f8 fix: calendar issues display filters loop fix (#2167) 2023-09-13 12:37:58 +05:30
Bavisetti NarayanandGitHub a34b0b059d feat: add a relation to an issue (#1995)
* feat: add issue relation to an issue

* fix: deleted the migration file

* fix: changed link to relates to in choice fields

* fix: added the migration file

* fix: changed migration file

* fix: project id issue fixed

* fix: added issue in the payload

* fix: changed the query param for blocker
2023-09-13 12:25:10 +05:30
Bavisetti NarayanandGitHub 164e0b9301 chore: changed view props (#2146)
* chore: changed view props

* fix: changed the keywords
2023-09-13 12:12:21 +05:30
Bavisetti NarayanandGitHub 5a91031243 feat: issue drafts (#2161) 2023-09-13 12:10:22 +05:30
Bavisetti NarayanandGitHub 47bec7704b chore: priority migration (#2162) 2023-09-13 12:06:38 +05:30
sriram veeraghantaandGitHub b9c935092a chore: eslint config package fixes (#2165)
* eslint fixes

* lint rules added
2023-09-13 12:06:17 +05:30
Aaryan KhandelwalandGitHub 3a2a329000 fix: view props undefined (#2160) 2023-09-12 22:51:13 +05:30
Aaryan KhandelwalandGitHub 8e9a4dca78 refactor: view props structure (#2159)
* chore: update view_props types

* refactor: view props structure
2023-09-12 22:27:15 +05:30
sriram veeraghantaandGitHub cdb888c23e fix: selfhosted fixes (#2154)
* fix: selfhosted fixes

* fix: updated env example
2023-09-12 20:32:26 +05:30
Dakshesh JainandGitHub 2186db8bba feat: users can select timezone during onboarding (#2148)
feat: using Intl timezone will be automatically selected but they have the option to change it
2023-09-12 13:35:15 +05:30
Bavisetti NarayanandGitHub 9bff10de6d chore: changed issue priority from NULL to none (#2142)
* chore: changed issue priority from NULL to none

* fix: deleted the migration file
2023-09-12 13:06:49 +05:30
Henit ChobisaandGitHub 6867154963 chore: added pre-release tag for workflow publications (#2133)
* chore: added pre-release tag for workflow publications

* chore: added backend services under a single image

* chore: exposed backend port for compose hub

* chore: removed backend exposed ports
2023-09-11 18:02:56 +05:30
Aaryan KhandelwalandGitHub 7bb73b74ba refactor: priority icon component (#2132) 2023-09-11 14:35:58 +05:30
Dakshesh JainandGitHub 991258084e fix: query checking (#2137)
fix: the logic should be to check if object exist not if it's true or false
2023-09-11 13:21:50 +05:30
ThomasandGitHub 1a37668f0b fix: husky was removed in commit #2086, but prepare still uses it (#2128) 2023-09-11 13:05:09 +05:30
4447a4b519 fix: Tiptap comment card fix for space (#2129)
* fix:(space) fixed comment card's editor integration

* regression: removed content being set twice

* chore: added controller to manage tiptap editor

* chore: remove unused functions

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-09-11 12:54:19 +05:30
Dakshesh JainandGitHub 7842c4b2ea fix: authorize editor (#2122) 2023-09-11 12:24:46 +05:30
Aaryan KhandelwalandGitHub 8de93d0081 chore: remove getServerSideProps (#2130) 2023-09-11 12:13:00 +05:30
Aaryan KhandelwalandGitHub 5b228bd1eb chore: update state icons and colors (#2126)
* chore: update state icons and colors

* chore: update icons
2023-09-11 11:45:28 +05:30
Dakshesh JainandGitHub ad8a011bb9 fix: issue activity (#2127) 2023-09-11 11:44:16 +05:30
Palanikannan1437 b841df6729 added common tailwind config to web 2023-09-08 18:42:05 +05:30
Palanikannan1437 0887b69149 initialized tiptap component with common tailwind config 2023-09-08 18:41:40 +05:30
Aaryan KhandelwalandGitHub 49d0b3f4a1 fix: handleClose function of the export modal (#2124) 2023-09-08 13:29:06 +05:30
Aaryan KhandelwalandGitHub 1872dff00d fix: custom date filter not working on my issues and profile issues (#2123) 2023-09-08 13:28:32 +05:30
Dakshesh JainandGitHub faa6a2bcbc feat: select blocker, blocking, and parent (#2121)
* feat: update, delete link

refactor: using old fetch-key

* feat: issue activity with ability to view & add comment

feat: click on view more to view more options in the issue detail

* fix: upload image not working on mobile

* feat: select blocker, blocking, and parent

dev: auth layout for web-view, console.log callback for web-view

* style: made design consistant

* fix: displaying page only on web-view

* style: removed overflow hidden
2023-09-07 18:42:24 +05:30
sriram veeraghantaandGitHub 6d52707ff5 editor fixes for space (#2119) 2023-09-07 15:09:16 +05:30
NikhilandGitHub 8ba482bc9c chore: response status for project views update (#2111)
* chore: response status for project views update

* dev: remove 200 OK response from empty contents
2023-09-07 14:49:45 +05:30
Aaryan KhandelwalandGitHub 5989f2476a fix: update plane logo (#2118) 2023-09-07 13:41:29 +05:30
Aaryan KhandelwalandGitHub 8ea6dd4e84 fix: onboarding role select dropdown text color (#2117) 2023-09-07 13:40:50 +05:30
NikhilandGitHub 39bc975994 fix: remove triage issue status from public boards (#2110) 2023-09-07 13:21:58 +05:30
NikhilandGitHub 866eead35f fix: issue comment ordering for public boards (#2108) 2023-09-07 13:21:05 +05:30
NikhilandGitHub 9c3510851d dev: update python packages (#2095) 2023-09-07 13:20:32 +05:30
Aaryan KhandelwalandGitHub 81436902a3 chore: option to switch access of a comment (#2116) 2023-09-07 12:54:30 +05:30
Aaryan KhandelwalandGitHub d26aa1b2da chore: render proper icons for due dates (#2114) 2023-09-07 12:53:49 +05:30
M. PalanikannanandGitHub b47c7d363f fix: Improved Image Deletion Logic, Image ID Issue in Modals and Performance Optimization in Editor (#2092)
* added improved delete logic in modals

* added better ts support

* impoved complexity to O(1) from O(n) for large docs

* regression: removed ts nocheck
2023-09-07 12:22:02 +05:30
Aaryan KhandelwalandGitHub 85f797058d fix: edit issue comment mutation (#2109) 2023-09-06 19:02:59 +05:30
Dakshesh JainandGitHub 1655d0cb1c feat: view, create, update and delete comment (#2106)
* feat: update, delete link

refactor: using old fetch-key

* feat: issue activity with ability to view & add comment

feat: click on view more to view more options in the issue detail

* fix: upload image not working on mobile
2023-09-06 17:08:19 +05:30
Anmol Singh BhatiaandGitHub 58562dc4b7 fix: ui improvement and bug fixes (#2105)
* chore: workspace level typo fix

* fix: setting opacity fix
2023-09-06 16:15:21 +05:30
NikhilandGitHub 2ad46d7bfa fix: public issue list endpoint n+1 (#2099) 2023-09-06 16:04:12 +05:30
NikhilandGitHub 4f0cac37db fix: issue object for filtering (#2102) 2023-09-06 16:03:41 +05:30
sriram veeraghantaandGitHub b46a7481ae Merge pull request #2101 from makeplane/fix/gantt_issues
fix: don't render invalid dated blocks on the gantt chart
2023-09-06 15:00:03 +05:30
sriram veeraghantaandGitHub f11ae00201 Merge pull request #2100 from makeplane/feat/comment_reactions
feat: plane space comment reactions
2023-09-06 14:52:55 +05:30
Aaryan Khandelwal c5612ee7a3 fix: don't render invalid dated cycles and modules 2023-09-06 12:26:51 +05:30
Aaryan Khandelwal 0dd336aec8 fix: don't render invalid dated issues 2023-09-06 12:25:34 +05:30
sriram veeraghantaandGitHub 4b364f72b5 Merge pull request #2096 from makeplane/fix/login_redirection
fix: redirection after signing in on space
2023-09-06 12:21:08 +05:30
Aaryan Khandelwal 6d13332818 style: add shadow to reaction selector 2023-09-06 12:17:47 +05:30
Aaryan Khandelwal ac4127c93d chore: add tooltip for user info 2023-09-06 12:04:18 +05:30
Aaryan Khandelwal 60c3d1a6e9 feat: comment reactions 2023-09-06 11:59:57 +05:30
sriram veeraghantaandGitHub 70ed3c1fdf Merge pull request #2097 from makeplane/feat/issue_detail_for_webview
feat: issue detail for web-view
2023-09-05 17:56:33 +05:30
dakshesh14 b40059ea21 feat: add links and permission to perform actions
refactor: divided file into components
2023-09-05 17:06:17 +05:30
Bavisetti NarayanandGitHub 90276073cd fix: validation in automation task (#2094) 2023-09-05 16:53:53 +05:30
Aaryan Khandelwal 8d5ff1a628 fix: redirection after signing in on space 2023-09-05 16:12:17 +05:30
dakshesh14 065a4a3cf7 feat: issue detail for web view 2023-09-05 14:42:34 +05:30
MengYXandGitHub 928ae775f4 fix: replace Completion with ChatCompletion to use gpt-3.5-turbo model (#2066)
According to https://platform.openai.com/docs/guides/gpt/chat-completions-vs-completions , GPT-3.5 Turbo & GPT-4 is not working on **Legacy** Completions API
2023-09-05 13:11:31 +05:30
595 changed files with 29405 additions and 11940 deletions
-52
View File
@@ -1,36 +1,3 @@
# Frontend
# Extra image domains that need to be added for Next Image
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
# Github ID for Github OAuth
NEXT_PUBLIC_GITHUB_ID=""
# Github App Name for GitHub Integration
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Sentry DSN for error monitoring
NEXT_PUBLIC_SENTRY_DSN=""
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0
# Enable/Disable sentry
NEXT_PUBLIC_ENABLE_SENTRY=0
# Enable/Disable session recording
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
# Enable/Disable event tracking
NEXT_PUBLIC_TRACK_EVENTS=0
# Slack for Slack Integration
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# public boards deploy url
NEXT_PUBLIC_DEPLOY_URL=""
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
# Error logs
SENTRY_DSN=""
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
@@ -43,15 +10,6 @@ REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
@@ -67,9 +25,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
OPENAI_API_KEY="sk-" # add your openai key here
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# Settings related to Docker
DOCKERIZED=1
# set to 1 If using the pre-configured minio setup
@@ -78,10 +33,3 @@ USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Default Creds
DEFAULT_EMAIL="captain@plane.so"
DEFAULT_PASSWORD="password123"
# SignUps
ENABLE_SIGNUP="1"
# Auto generated and Required that will be generated from setup.sh
@@ -33,23 +33,16 @@ jobs:
deploy:
- space/**
- name: Setup .npmrc for repository
run: |
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
- name: Build Plane's Main App
if: steps.changed-files.outputs.web_any_changed == 'true'
run: |
mv ./.npmrc ./web
cd web
yarn
yarn build
yarn build --filter=web
- name: Build Plane's Deploy App
if: steps.changed-files.outputs.deploy_any_changed == 'true'
run: |
cd space
yarn
yarn build
yarn build --filter=space
+77
View File
@@ -0,0 +1,77 @@
name: Create PR in Plane EE Repository to sync the changes
on:
pull_request:
types:
- closed
jobs:
create_pr:
# Only run the job when a PR is merged
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Check SOURCE_REPO
id: check_repo
env:
SOURCE_REPO: ${{ secrets.SOURCE_REPO_NAME }}
run: |
echo "::set-output name=is_correct_repo::$(if [[ "$SOURCE_REPO" == "makeplane/plane" ]]; then echo 'true'; else echo 'false'; fi)"
- name: Checkout Code
if: steps.check_repo.outputs.is_correct_repo == 'true'
uses: actions/checkout@v2
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Branch Name
if: steps.check_repo.outputs.is_correct_repo == 'true'
run: |
echo "SOURCE_BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
- name: Setup GH CLI
if: steps.check_repo.outputs.is_correct_repo == 'true'
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
- name: Create Pull Request
if: steps.check_repo.outputs.is_correct_repo == 'true'
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ secrets.TARGET_REPO_NAME }}"
TARGET_BRANCH="${{ secrets.TARGET_REPO_BRANCH }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target $SOURCE_BRANCH:$SOURCE_BRANCH
PR_TITLE="${{ github.event.pull_request.title }}"
PR_BODY="${{ github.event.pull_request.body }}"
# Remove double quotes
PR_TITLE_CLEANED="${PR_TITLE//\"/}"
PR_BODY_CLEANED="${PR_BODY//\"/}"
# Construct PR_BODY_CONTENT using a here-document
PR_BODY_CONTENT=$(cat <<EOF
$PR_BODY_CLEANED
EOF
)
gh pr create \
--base $TARGET_BRANCH \
--head $SOURCE_BRANCH \
--title "[SYNC] $PR_TITLE_CLEANED" \
--body "$PR_BODY_CONTENT" \
--repo $TARGET_REPO
@@ -2,7 +2,7 @@ name: Update Docker Images for Plane on Release
on:
release:
types: [released]
types: [released, prereleased]
jobs:
build_push_backend:
@@ -22,10 +22,6 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup .npmrc for repository
run: |
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaFrontend
uses: docker/metadata-action@v4.3.0
@@ -43,10 +39,10 @@ jobs:
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaDeploy
id: metaSpace
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-deploy
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space
tags: |
type=ref,event=tag
@@ -91,7 +87,7 @@ jobs:
file: ./space/Dockerfile.space
platforms: linux/amd64
push: true
tags: ${{ steps.metaDeploy.outputs.tags }}
tags: ${{ steps.metaSpace.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+4
View File
@@ -73,3 +73,7 @@ pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
## packages
dist
+58 -16
View File
@@ -8,8 +8,8 @@ Before submitting a new issue, please search the [issues](https://github.com/mak
While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like:
- 3rd-party libraries being used and their versions
- a use-case that fails
- 3rd-party libraries being used and their versions
- a use-case that fails
Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved.
@@ -19,10 +19,10 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
### Requirements
- Node.js version v16.18.0
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
- Node.js version v16.18.0
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
### Setup the project
@@ -30,6 +30,48 @@ The project is a monorepo, with backend api and frontend in a single repo.
The backend is a django project which is kept inside apiserver
1. Clone the repo
```bash
git clone https://github.com/makeplane/plane
cd plane
chmod +x setup.sh
```
2. Run setup.sh
```bash
./setup.sh
```
3. Define `NEXT_PUBLIC_API_BASE_URL=http://localhost` in **web/.env** and **space/.env** file
```bash
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./web/.env
```
```bash
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./space/.env
```
4. Run Docker compose up
```bash
docker compose up -d
```
5. Install dependencies
```bash
yarn install
```
6. Run the web app in development mode
```bash
yarn dev
```
## Missing a Feature?
If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository.
@@ -39,8 +81,8 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
## Need help? Questions and suggestions
@@ -48,11 +90,11 @@ Questions, suggestions, and thoughts are most welcome. We can also be reached in
## Ways to contribute
- Try Plane Cloud and the self hosting platform and give feedback
- Add new integrations
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
- Share your thoughts and suggestions with us
- Help create tutorials and blog posts
- Request a feature by submitting a proposal
- Report a bug
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
- Try Plane Cloud and the self hosting platform and give feedback
- Add new integrations
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
- Share your thoughts and suggestions with us
- Help create tutorials and blog posts
- Request a feature by submitting a proposal
- Report a bug
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
+1 -12
View File
@@ -54,22 +54,11 @@ chmod +x setup.sh
- Run setup.sh
```bash
./setup.sh http://localhost
./setup.sh
```
> If running in a cloud env replace localhost with public facing IP address of the VM
- Setup Tiptap Pro
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
```
@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
```
- Run Docker compose up
```bash
+72
View File
@@ -0,0 +1,72 @@
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
DJANGO_SETTINGS_MODULE="plane.settings.production"
# Error logs
SENTRY_DSN=""
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
# Changing this requires change in the nginx.conf for uploads if using minio setup
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
OPENAI_API_KEY="sk-" # add your openai key here
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# Settings related to Docker
DOCKERIZED=1
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Default Creds
DEFAULT_EMAIL="captain@plane.so"
DEFAULT_PASSWORD="password123"
# SignUps
ENABLE_SIGNUP="1"
# Enable Email/Password Signup
ENABLE_EMAIL_PASSWORD="1"
# Enable Magic link Login
ENABLE_MAGIC_LINK_LOGIN="0"
# Email redirections and minio domain settings
WEB_URL="http://localhost"
+10 -1
View File
@@ -58,8 +58,17 @@ class WorkspaceEntityPermission(BasePermission):
if request.user.is_anonymous:
return False
## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS:
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
).exists()
return WorkspaceMember.objects.filter(
member=request.user, workspace__slug=view.workspace_slug
member=request.user,
workspace__slug=view.workspace_slug,
role__in=[Owner, Admin],
).exists()
+3 -3
View File
@@ -23,7 +23,7 @@ from .project import (
ProjectPublicMemberSerializer
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer
from .asset import FileAssetSerializer
from .issue import (
@@ -31,8 +31,6 @@ from .issue import (
IssueActivitySerializer,
IssueCommentSerializer,
IssuePropertySerializer,
BlockerIssueSerializer,
BlockedIssueSerializer,
IssueAssigneeSerializer,
LabelSerializer,
IssueSerializer,
@@ -45,6 +43,8 @@ from .issue import (
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
)
+1 -21
View File
@@ -34,7 +34,6 @@ class CycleSerializer(BaseSerializer):
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
assignees = serializers.SerializerMethodField(read_only=True)
labels = serializers.SerializerMethodField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
@@ -50,11 +49,10 @@ class CycleSerializer(BaseSerializer):
members = [
{
"avatar": assignee.avatar,
"first_name": assignee.first_name,
"display_name": assignee.display_name,
"id": assignee.id,
}
for issue_cycle in obj.issue_cycle.all()
for issue_cycle in obj.issue_cycle.prefetch_related("issue__assignees").all()
for assignee in issue_cycle.issue.assignees.all()
]
# Use a set comprehension to return only the unique objects
@@ -64,24 +62,6 @@ class CycleSerializer(BaseSerializer):
unique_list = [dict(item) for item in unique_objects]
return unique_list
def get_labels(self, obj):
labels = [
{
"name": label.name,
"color": label.color,
"id": label.id,
}
for issue_cycle in obj.issue_cycle.all()
for label in issue_cycle.issue.labels.all()
]
# Use a set comprehension to return only the unique objects
unique_objects = {frozenset(item.items()) for item in labels}
# Convert the set back to a list of dictionaries
unique_list = [dict(item) for item in unique_objects]
return unique_list
class Meta:
model = Cycle
+28 -106
View File
@@ -17,12 +17,10 @@ from plane.db.models import (
IssueActivity,
IssueComment,
IssueProperty,
IssueBlocker,
IssueAssignee,
IssueSubscriber,
IssueLabel,
Label,
IssueBlocker,
CycleIssue,
Cycle,
Module,
@@ -32,6 +30,7 @@ from plane.db.models import (
IssueReaction,
CommentReaction,
IssueVote,
IssueRelation,
)
@@ -50,6 +49,7 @@ class IssueFlatSerializer(BaseSerializer):
"target_date",
"sequence_id",
"sort_order",
"is_draft",
]
@@ -81,25 +81,12 @@ class IssueCreateSerializer(BaseSerializer):
required=False,
)
# List of issues that are blocking this issue
blockers_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()),
write_only=True,
required=False,
)
labels_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
)
# List of issues that are blocked by this issue
blocks_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()),
write_only=True,
required=False,
)
class Meta:
model = Issue
fields = "__all__"
@@ -122,10 +109,8 @@ class IssueCreateSerializer(BaseSerializer):
return data
def create(self, validated_data):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
@@ -137,22 +122,6 @@ class IssueCreateSerializer(BaseSerializer):
created_by_id = issue.created_by_id
updated_by_id = issue.updated_by_id
if blockers is not None and len(blockers):
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=issue,
blocked_by=blocker,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for blocker in blockers
],
batch_size=10,
)
if assignees is not None and len(assignees):
IssueAssignee.objects.bulk_create(
[
@@ -196,29 +165,11 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
if blocks is not None and len(blocks):
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=block,
blocked_by=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for block in blocks
],
batch_size=10,
)
return issue
def update(self, instance, validated_data):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
# Related models
project_id = instance.project_id
@@ -226,23 +177,6 @@ class IssueCreateSerializer(BaseSerializer):
created_by_id = instance.created_by_id
updated_by_id = instance.updated_by_id
if blockers is not None:
IssueBlocker.objects.filter(block=instance).delete()
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=instance,
blocked_by=blocker,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for blocker in blockers
],
batch_size=10,
)
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create(
@@ -277,23 +211,6 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
if blocks is not None:
IssueBlocker.objects.filter(blocked_by=instance).delete()
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=block,
blocked_by=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for block in blocks
],
batch_size=10,
)
# Time updation occues even when other related models are updated
instance.updated_at = timezone.now()
return super().update(instance, validated_data)
@@ -375,32 +292,39 @@ class IssueLabelSerializer(BaseSerializer):
]
class BlockedIssueSerializer(BaseSerializer):
blocked_issue_detail = IssueProjectLiteSerializer(source="block", read_only=True)
class IssueRelationSerializer(BaseSerializer):
issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
class Meta:
model = IssueBlocker
model = IssueRelation
fields = [
"blocked_issue_detail",
"blocked_by",
"block",
"issue_detail",
"relation_type",
"related_issue",
"issue",
"id"
]
read_only_fields = [
"workspace",
"project",
]
read_only_fields = fields
class BlockerIssueSerializer(BaseSerializer):
blocker_issue_detail = IssueProjectLiteSerializer(
source="blocked_by", read_only=True
)
class RelatedIssueSerializer(BaseSerializer):
issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue")
class Meta:
model = IssueBlocker
model = IssueRelation
fields = [
"blocker_issue_detail",
"blocked_by",
"block",
"issue_detail",
"relation_type",
"related_issue",
"issue",
"id"
]
read_only_fields = [
"workspace",
"project",
]
read_only_fields = fields
class IssueAssigneeSerializer(BaseSerializer):
@@ -617,10 +541,8 @@ class IssueSerializer(BaseSerializer):
parent_detail = IssueStateFlatSerializer(read_only=True, source="parent")
label_details = LabelSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
# List of issues blocked by this issue
blocked_issues = BlockedIssueSerializer(read_only=True, many=True)
# List of issues that block this issue
blocker_issues = BlockerIssueSerializer(read_only=True, many=True)
related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True)
issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True)
issue_cycle = IssueCycleDetailSerializer(read_only=True)
issue_module = IssueModuleDetailSerializer(read_only=True)
issue_link = IssueLinkSerializer(read_only=True, many=True)
+30 -1
View File
@@ -5,10 +5,39 @@ from rest_framework import serializers
from .base import BaseSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import IssueView, IssueViewFavorite
from plane.db.models import GlobalView, IssueView, IssueViewFavorite
from plane.utils.issue_filters import issue_filters
class GlobalViewSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = GlobalView
fields = "__all__"
read_only_fields = [
"workspace",
"query",
]
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
return GlobalView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
class IssueViewSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
+109 -15
View File
@@ -70,6 +70,7 @@ from plane.api.views import (
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
## End Projects
# Issues
IssueViewSet,
@@ -90,7 +91,9 @@ from plane.api.views import (
IssueSubscriberViewSet,
IssueCommentPublicViewSet,
IssueReactionViewSet,
IssueRelationViewSet,
CommentReactionViewSet,
IssueDraftViewSet,
## End Issues
# States
StateViewSet,
@@ -100,6 +103,8 @@ from plane.api.views import (
BulkEstimatePointEndpoint,
## End Estimates
# Views
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewViewSet,
ViewIssuesEndpoint,
IssueViewFavoriteViewSet,
@@ -146,12 +151,11 @@ from plane.api.views import (
GlobalSearchEndpoint,
IssueSearchEndpoint,
## End Search
# Gpt
# External
GPTIntegrationEndpoint,
## End Gpt
# Release Notes
ReleaseNotesEndpoint,
## End Release Notes
UnsplashEndpoint,
## End External
# Inbox
InboxViewSet,
InboxIssueViewSet,
@@ -182,7 +186,9 @@ from plane.api.views import (
## Exporter
ExportIssuesEndpoint,
## End Exporter
# Configuration
ConfigurationEndpoint,
## End Configuration
)
@@ -239,7 +245,11 @@ urlpatterns = [
UpdateUserTourCompletedEndpoint.as_view(),
name="user-tour",
),
path("users/workspaces/<str:slug>/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
path(
"users/workspaces/<str:slug>/activities/",
UserActivityEndpoint.as_view(),
name="user-activities",
),
# user workspaces
path(
"users/me/workspaces/",
@@ -566,6 +576,11 @@ urlpatterns = [
LeaveProjectEndpoint.as_view(),
name="project",
),
path(
"project-covers/",
ProjectPublicCoverImagesEndpoint.as_view(),
name="project-covers",
),
# End Projects
# States
path(
@@ -647,6 +662,37 @@ urlpatterns = [
ViewIssuesEndpoint.as_view(),
name="project-view-issues",
),
path(
"workspaces/<str:slug>/views/",
GlobalViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/",
GlobalViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/issues/",
GlobalViewIssuesViewSet.as_view(
{
"get": "list",
}
),
name="global-view-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
IssueViewFavoriteViewSet.as_view(
@@ -765,11 +811,6 @@ urlpatterns = [
),
name="project-issue",
),
path(
"workspaces/<str:slug>/issues/",
WorkSpaceIssuesEndpoint.as_view(),
name="workspace-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelViewSet.as_view(
@@ -1010,6 +1051,49 @@ urlpatterns = [
name="project-issue-archive",
),
## End Issue Archives
## Issue Relation
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-relation/",
IssueRelationViewSet.as_view(
{
"post": "create",
}
),
name="issue-relation",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-relation/<uuid:pk>/",
IssueRelationViewSet.as_view(
{
"delete": "destroy",
}
),
name="issue-relation",
),
## End Issue Relation
## Issue Drafts
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-drafts/",
IssueDraftViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-drafts/<uuid:pk>/",
IssueDraftViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-draft",
),
## End Issue Drafts
## File Assets
path(
"workspaces/<str:slug>/file-assets/",
@@ -1370,20 +1454,23 @@ urlpatterns = [
name="project-issue-search",
),
## End Search
# Gpt
# External
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
GPTIntegrationEndpoint.as_view(),
name="importer",
),
## End Gpt
# Release Notes
path(
"release-notes/",
ReleaseNotesEndpoint.as_view(),
name="release-notes",
),
## End Release Notes
path(
"unsplash/",
UnsplashEndpoint.as_view(),
name="release-notes",
),
## End External
# Inbox
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
@@ -1652,4 +1739,11 @@ urlpatterns = [
name="workspace-project-boards",
),
## End Public Boards
# Configuration
path(
"configs/",
ConfigurationEndpoint.as_view(),
name="configuration",
),
## End Configuration
]
+8 -8
View File
@@ -17,6 +17,7 @@ from .project import (
ProjectMemberEndpoint,
WorkspaceProjectDeployBoardEndpoint,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
)
from .user import (
UserEndpoint,
@@ -56,7 +57,7 @@ from .workspace import (
LeaveWorkspaceEndpoint,
)
from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .cycle import (
CycleViewSet,
CycleIssueViewSet,
@@ -86,8 +87,10 @@ from .issue import (
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
IssueRelationViewSet,
IssueRetrievePublicEndpoint,
ProjectIssuesPublicEndpoint,
IssueDraftViewSet,
)
from .auth_extended import (
@@ -145,16 +148,13 @@ from .page import (
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .gpt import GPTIntegrationEndpoint
from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint
from .estimate import (
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
)
from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
from .analytic import (
@@ -167,6 +167,6 @@ from .analytic import (
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
from .exporter import (
ExportIssuesEndpoint,
)
from .exporter import ExportIssuesEndpoint
from .config import ConfigurationEndpoint
+40
View File
@@ -0,0 +1,40 @@
# Python imports
import os
# Django imports
from django.conf import settings
# Third party imports
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from .base import BaseAPIView
class ConfigurationEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request):
try:
data = {}
data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None)
data["github"] = os.environ.get("GITHUB_CLIENT_ID", None)
data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None)
data["magic_login"] = (
bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD)
) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1"
data["email_password_login"] = (
os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1"
)
return Response(data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+119 -21
View File
@@ -80,6 +80,7 @@ class CycleViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -101,48 +102,84 @@ class CycleViewSet(BaseViewSet):
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="backlog"),
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="completed"),
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.prefetch_related(
@@ -195,17 +232,30 @@ class CycleViewSet(BaseViewSet):
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(total_issues=Count("assignee_id"))
.annotate(
total_issues=Count(
"assignee_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
@@ -221,17 +271,30 @@ class CycleViewSet(BaseViewSet):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id"))
.annotate(
total_issues=Count(
"label_id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"label_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
@@ -384,17 +447,30 @@ class CycleViewSet(BaseViewSet):
.values(
"first_name", "last_name", "assignee_id", "avatar", "display_name"
)
.annotate(total_issues=Count("assignee_id"))
.annotate(
total_issues=Count(
"assignee_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
@@ -411,17 +487,30 @@ class CycleViewSet(BaseViewSet):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id"))
.annotate(
total_issues=Count(
"label_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"label_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
@@ -487,6 +576,7 @@ class CycleIssueViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -517,6 +607,7 @@ class CycleIssueViewSet(BaseViewSet):
try:
order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
@@ -555,9 +646,15 @@ class CycleIssueViewSet(BaseViewSet):
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues_data, group_by),
group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK,
)
@@ -655,6 +752,7 @@ class CycleIssueViewSet(BaseViewSet):
),
}
),
epoch=int(timezone.now().timestamp())
)
# Return all Cycle Issues
@@ -2,9 +2,10 @@
import requests
# Third party imports
import openai
from rest_framework.response import Response
from rest_framework import status
import openai
from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception
# Django imports
@@ -15,6 +16,7 @@ from .base import BaseAPIView
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Workspace, Project
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.utils.integrations.github import get_release_notes
class GPTIntegrationEndpoint(BaseAPIView):
@@ -41,9 +43,9 @@ class GPTIntegrationEndpoint(BaseAPIView):
final_text = task + "\n" + prompt
openai.api_key = settings.OPENAI_API_KEY
response = openai.Completion.create(
response = openai.ChatCompletion.create(
model=settings.GPT_ENGINE,
prompt=final_text,
messages=[{"role": "user", "content": final_text}],
temperature=0.7,
max_tokens=1024,
)
@@ -51,7 +53,7 @@ class GPTIntegrationEndpoint(BaseAPIView):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=project_id)
text = response.choices[0].text.strip()
text = response.choices[0].message.content.strip()
text_html = text.replace("\n", "<br/>")
return Response(
{
@@ -73,3 +75,44 @@ class GPTIntegrationEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ReleaseNotesEndpoint(BaseAPIView):
def get(self, request):
try:
release_notes = get_release_notes()
return Response(release_notes, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
try:
query = request.GET.get("query", False)
page = request.GET.get("page", 1)
per_page = request.GET.get("per_page", 20)
url = (
f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
if query
else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
)
headers = {
"Content-Type": "application/json",
}
resp = requests.get(url=url, headers=headers)
return Response(resp.json(), status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+1 -1
View File
@@ -384,7 +384,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
sort_order=largest_sort_order,
start_date=issue_data.get("start_date", None),
target_date=issue_data.get("target_date", None),
priority=issue_data.get("priority", None),
priority=issue_data.get("priority", "none"),
created_by=request.user,
)
)
+13 -4
View File
@@ -173,12 +173,12 @@ class InboxIssueViewSet(BaseViewSet):
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [
if not request.data.get("issue", {}).get("priority", "none") in [
"low",
"medium",
"high",
"urgent",
None,
"none",
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
@@ -213,6 +213,7 @@ class InboxIssueViewSet(BaseViewSet):
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
# create an inbox issue
InboxIssue.objects.create(
@@ -277,6 +278,7 @@ class InboxIssueViewSet(BaseViewSet):
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
issue_serializer.save()
else:
@@ -368,6 +370,11 @@ class InboxIssueViewSet(BaseViewSet):
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
# Check the issue status
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id).delete()
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except InboxIssue.DoesNotExist:
@@ -478,12 +485,12 @@ class InboxIssuePublicViewSet(BaseViewSet):
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [
if not request.data.get("issue", {}).get("priority", "none") in [
"low",
"medium",
"high",
"urgent",
None,
"none",
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
@@ -518,6 +525,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
# create an inbox issue
InboxIssue.objects.create(
@@ -582,6 +590,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
issue_serializer.save()
return Response(issue_serializer.data, status=status.HTTP_200_OK)
+433 -15
View File
@@ -4,6 +4,7 @@ import random
from itertools import chain
# Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
@@ -23,7 +24,7 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError
from django.conf import settings
from django.db import IntegrityError
# Third Party imports
from rest_framework.response import Response
@@ -51,10 +52,11 @@ from plane.api.serializers import (
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
)
from plane.api.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
WorkSpaceAdminPermission,
ProjectMemberPermission,
@@ -76,6 +78,7 @@ from plane.db.models import (
CommentReaction,
ProjectDeployBoard,
IssueVote,
IssueRelation,
ProjectPublicMember,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -125,6 +128,7 @@ class IssueViewSet(BaseViewSet):
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@@ -145,6 +149,7 @@ class IssueViewSet(BaseViewSet):
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -178,7 +183,7 @@ class IssueViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
@@ -266,9 +271,16 @@ class IssueViewSet(BaseViewSet):
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
@@ -304,6 +316,7 @@ class IssueViewSet(BaseViewSet):
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -315,7 +328,12 @@ class IssueViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, pk=None):
try:
issue = Issue.issue_objects.get(
issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
).get(
workspace__slug=slug, project_id=project_id, pk=pk
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
@@ -331,7 +349,7 @@ class UserWorkSpaceIssues(BaseAPIView):
try:
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
@@ -443,9 +461,16 @@ class UserWorkSpaceIssues(BaseAPIView):
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
@@ -491,7 +516,7 @@ class IssueActivityEndpoint(BaseAPIView):
issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id)
.filter(
~Q(field__in=["comment", "vote", "reaction"]),
~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user,
)
.select_related("actor", "workspace", "issue", "project")
@@ -550,6 +575,7 @@ class IssueCommentViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
def perform_update(self, serializer):
@@ -568,6 +594,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@@ -589,6 +616,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -684,10 +712,18 @@ class LabelViewSet(BaseViewSet):
ProjectMemberPermission,
]
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
)
def create(self, request, slug, project_id):
try:
serializer = LabelSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response({"error": "Label with the same name already exists in the project"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
def get_queryset(self):
return self.filter_queryset(
@@ -872,6 +908,7 @@ class IssueLinkViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
def perform_update(self, serializer):
@@ -890,6 +927,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@@ -911,6 +949,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -989,6 +1028,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
serializer.data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1011,6 +1051,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1068,7 +1109,7 @@ class IssueArchiveViewSet(BaseViewSet):
show_sub_issues = request.GET.get("show_sub_issues", "true")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
@@ -1213,6 +1254,7 @@ class IssueArchiveViewSet(BaseViewSet):
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
@@ -1417,6 +1459,7 @@ class IssueReactionViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
@@ -1440,6 +1483,7 @@ class IssueReactionViewSet(BaseViewSet):
"identifier": str(issue_reaction.id),
}
),
epoch=int(timezone.now().timestamp())
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1488,6 +1532,7 @@ class CommentReactionViewSet(BaseViewSet):
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
@@ -1512,6 +1557,7 @@ class CommentReactionViewSet(BaseViewSet):
"comment_id": str(comment_id),
}
),
epoch=int(timezone.now().timestamp())
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1575,7 +1621,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
)
)
.distinct()
)
).order_by("created_at")
else:
return IssueComment.objects.none()
except ProjectDeployBoard.DoesNotExist:
@@ -1608,6 +1654,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
if not ProjectMember.objects.filter(
project_id=project_id,
@@ -1657,6 +1704,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1690,6 +1738,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1764,6 +1813,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1808,6 +1858,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
"identifier": str(issue_reaction.id),
}
),
epoch=int(timezone.now().timestamp())
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1881,6 +1932,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1932,6 +1984,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
"comment_id": str(comment_id),
}
),
epoch=int(timezone.now().timestamp())
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1995,6 +2048,7 @@ class IssueVotePublicViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -2029,6 +2083,7 @@ class IssueVotePublicViewSet(BaseViewSet):
"identifier": str(issue_vote.id),
}
),
epoch=int(timezone.now().timestamp())
)
issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -2040,6 +2095,109 @@ class IssueVotePublicViewSet(BaseViewSet):
)
class IssueRelationViewSet(BaseViewSet):
serializer_class = IssueRelationSerializer
model = IssueRelation
permission_classes = [
ProjectEntityPermission,
]
def perform_destroy(self, instance):
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
type="issue_relation.activity.deleted",
requested_data=json.dumps({"related_list": None}),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueRelationSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
def create(self, request, slug, project_id, issue_id):
try:
related_list = request.data.get("related_list", [])
relation = request.data.get("relation", None)
project = Project.objects.get(pk=project_id)
issue_relation = IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=related_issue["issue"],
related_issue_id=related_issue["related_issue"],
relation_type=related_issue["relation_type"],
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for related_issue in related_list
],
batch_size=10,
ignore_conflicts=True,
)
issue_activity.delay(
type="issue_relation.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
if relation == "blocking":
return Response(
RelatedIssueSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
else:
return Response(
IssueRelationSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"name": "The issue is already taken"},
status=status.HTTP_410_GONE,
)
else:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
class IssueRetrievePublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
@@ -2078,7 +2236,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
@@ -2100,6 +2258,12 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
queryset=IssueReaction.objects.select_related("actor"),
)
)
.prefetch_related(
Prefetch(
"votes",
queryset=IssueVote.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
@@ -2189,6 +2353,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=slug,
project_id=project_id,
)
@@ -2233,3 +2398,256 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueDraftViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = IssueFlatSerializer
model = Issue
def perform_destroy(self, instance):
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
type="issue_draft.activity.deleted",
requested_data=json.dumps(
{"issue_id": str(self.kwargs.get("pk", None))}
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
def get_queryset(self):
return (
Issue.objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
try:
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def create(self, request, slug, project_id):
try:
project = Project.objects.get(pk=project_id)
serializer = IssueCreateSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
},
)
if serializer.is_valid():
serializer.save(is_draft=True)
# Track the issue
issue_activity.delay(
type="issue_draft.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Project.DoesNotExist:
return Response(
{"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND
)
def partial_update(self, request, slug, project_id, pk):
try:
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
serializer = IssueSerializer(
issue, data=request.data, partial=True
)
if serializer.is_valid():
if(request.data.get("is_draft") is not None and not request.data.get("is_draft")):
serializer.save(created_at=timezone.now(), updated_at=timezone.now())
else:
serializer.save()
issue_activity.delay(
type="issue_draft.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(issue).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Issue.DoesNotExist:
return Response(
{"error": "Issue does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, pk=None):
try:
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
except Issue.DoesNotExist:
return Response(
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND
)
+88 -15
View File
@@ -2,6 +2,7 @@
import json
# Django Imports
from django.utils import timezone
from django.db import IntegrityError
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
from django.core import serializers
@@ -39,6 +40,7 @@ from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.utils.analytics_plot import burndown_plot
class ModuleViewSet(BaseViewSet):
model = Module
permission_classes = [
@@ -77,35 +79,63 @@ class ModuleViewSet(BaseViewSet):
queryset=ModuleLink.objects.select_related("module", "created_by"),
)
)
.annotate(total_issues=Count("issue_module"))
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="completed"),
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="cancelled"),
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="started"),
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="unstarted"),
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="backlog"),
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.order_by(order_by, "name")
@@ -129,6 +159,7 @@ class ModuleViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -177,18 +208,36 @@ class ModuleViewSet(BaseViewSet):
.annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.annotate(total_issues=Count("assignee_id"))
.values(
"first_name", "last_name", "assignee_id", "avatar", "display_name"
)
.annotate(
total_issues=Count(
"assignee_id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
@@ -204,17 +253,33 @@ class ModuleViewSet(BaseViewSet):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id"))
.annotate(
total_issues=Count(
"label_id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
"label_id",
filter=Q(completed_at__isnull=False),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(completed_at__isnull=True),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
@@ -277,6 +342,7 @@ class ModuleIssueViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -308,6 +374,7 @@ class ModuleIssueViewSet(BaseViewSet):
try:
order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id)
@@ -346,9 +413,15 @@ class ModuleIssueViewSet(BaseViewSet):
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues_data, group_by),
group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK,
)
@@ -437,6 +510,7 @@ class ModuleIssueViewSet(BaseViewSet):
),
}
),
epoch=int(timezone.now().timestamp())
)
return Response(
@@ -483,7 +557,6 @@ class ModuleLinkViewSet(BaseViewSet):
class ModuleFavoriteViewSet(BaseViewSet):
serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite
+42 -6
View File
@@ -1,5 +1,6 @@
# Python imports
import jwt
import boto3
from datetime import datetime
# Django imports
@@ -482,7 +483,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
# Delete joined project invites
project_invitations.delete()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
@@ -495,7 +496,7 @@ class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberAdminSerializer
model = ProjectMember
permission_classes = [
ProjectBasePermission,
ProjectMemberPermission,
]
search_fields = [
@@ -617,7 +618,8 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
except ProjectMember.DoesNotExist:
return Response(
{"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Project Member does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
@@ -924,8 +926,7 @@ class ProjectUserViewsEndpoint(BaseAPIView):
project_member.save()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Project.DoesNotExist:
return Response(
{"error": "The requested resource does not exists"},
@@ -1095,7 +1096,7 @@ class ProjectMemberEndpoint(BaseAPIView):
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member")
).select_related("project", "member", "workspace")
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
@@ -1210,3 +1211,38 @@ class LeaveProjectEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request):
try:
files = []
s3 = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
params = {
"Bucket": settings.AWS_S3_BUCKET_NAME,
"Prefix": "static/project-cover/",
}
response = s3.list_objects_v2(**params)
# Extracting file keys from the response
if "Contents" in response:
for content in response["Contents"]:
if not content["Key"].endswith(
"/"
): # This line ensures we're only getting files, not "sub-folders"
files.append(
f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
return Response(files, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response([], status=status.HTTP_200_OK)
-21
View File
@@ -1,21 +0,0 @@
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from .base import BaseAPIView
from plane.utils.integrations.github import get_release_notes
class ReleaseNotesEndpoint(BaseAPIView):
def get(self, request):
try:
release_notes = get_release_notes()
return Response(release_notes, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+4 -4
View File
@@ -220,7 +220,7 @@ class IssueSearchEndpoint(BaseAPIView):
query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false")
parent = request.query_params.get("parent", "false")
blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false")
issue_relation = request.query_params.get("issue_relation", "false")
cycle = request.query_params.get("cycle", "false")
module = request.query_params.get("module", "false")
sub_issue = request.query_params.get("sub_issue", "false")
@@ -247,12 +247,12 @@ class IssueSearchEndpoint(BaseAPIView):
"parent_id", flat=True
)
)
if blocker_blocked_by == "true" and issue_id:
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id),
~Q(blocked_issues__block=issue),
~Q(blocker_issues__blocked_by=issue),
~Q(issue_related__issue=issue),
~Q(issue_relation__related_issue=issue),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
+189 -1
View File
@@ -1,4 +1,18 @@
# Django imports
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError
from django.db.models import Prefetch, OuterRef, Exists
@@ -10,18 +24,192 @@ from sentry_sdk import capture_exception
# Module imports
from . import BaseViewSet, BaseAPIView
from plane.api.serializers import (
GlobalViewSerializer,
IssueViewSerializer,
IssueLiteSerializer,
IssueViewFavoriteSerializer,
)
from plane.api.permissions import ProjectEntityPermission
from plane.api.permissions import WorkspaceEntityPermission, ProjectEntityPermission
from plane.db.models import (
Workspace,
GlobalView,
IssueView,
Issue,
IssueViewFavorite,
IssueReaction,
IssueLink,
IssueAttachment,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class GlobalViewViewSet(BaseViewSet):
serializer_class = GlobalViewSerializer
model = GlobalView
permission_classes = [
WorkspaceEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(workspace_id=workspace.id)
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace")
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
class GlobalViewIssuesViewSet(BaseViewSet):
permission_classes = [
WorkspaceEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
@method_decorator(gzip_page)
def list(self, request, slug):
try:
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueViewViewSet(BaseViewSet):
+26 -9
View File
@@ -116,7 +116,7 @@ class WorkSpaceViewSet(BaseViewSet):
)
issue_count = (
Issue.objects.filter(workspace=OuterRef("id"))
Issue.issue_objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -203,7 +203,7 @@ class UserWorkSpacesEndpoint(BaseAPIView):
)
issue_count = (
Issue.objects.filter(workspace=OuterRef("id"))
Issue.issue_objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -532,7 +532,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
# Delete joined workspace invites
workspace_invitations.delete()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
@@ -846,7 +846,7 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
workspace_member.view_props = request.data.get("view_props", {})
workspace_member.save()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "User not a member of workspace"},
@@ -1072,10 +1072,10 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
.order_by("state_group")
)
priority_order = ["urgent", "high", "medium", "low", None]
priority_order = ["urgent", "high", "medium", "low", "none"]
priority_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
@@ -1197,7 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter(
~Q(field__in=["comment", "vote", "reaction"]),
~Q(field__in=["comment", "vote", "reaction", "draft"]),
workspace__slug=slug,
project__project_projectmember__member=request.user,
actor=user_id,
@@ -1239,13 +1239,21 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
.annotate(
created_issues=Count(
"project_issue",
filter=Q(project_issue__created_by_id=user_id),
filter=Q(
project_issue__created_by_id=user_id,
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
),
)
)
.annotate(
assigned_issues=Count(
"project_issue",
filter=Q(project_issue__assignees__in=[user_id]),
filter=Q(
project_issue__assignees__in=[user_id],
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
),
)
)
.annotate(
@@ -1254,6 +1262,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
filter=Q(
project_issue__completed_at__isnull=False,
project_issue__assignees__in=[user_id],
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
),
)
)
@@ -1267,6 +1277,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
"started",
],
project_issue__assignees__in=[user_id],
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
),
)
)
@@ -1317,6 +1329,11 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.filter(
@@ -32,7 +32,7 @@ def delete_old_s3_link():
else:
s3 = boto3.client(
"s3",
region_name="ap-south-1",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
+264 -188
View File
@@ -39,6 +39,7 @@ def track_name(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("name") != requested_data.get("name"):
issue_activities.append(
@@ -52,6 +53,7 @@ def track_name(
project=project,
workspace=project.workspace,
comment=f"updated the name to {requested_data.get('name')}",
epoch=epoch,
)
)
@@ -64,6 +66,7 @@ def track_parent(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("parent") != requested_data.get("parent"):
if requested_data.get("parent") == None:
@@ -81,6 +84,7 @@ def track_parent(
comment=f"updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
epoch=epoch,
)
)
else:
@@ -101,6 +105,7 @@ def track_parent(
comment=f"updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id,
epoch=epoch,
)
)
@@ -113,36 +118,23 @@ def track_priority(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("priority") != requested_data.get("priority"):
if requested_data.get("priority") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=None,
field="priority",
project=project,
workspace=project.workspace,
comment=f"updated the priority to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=requested_data.get("priority"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"updated the priority to {requested_data.get('priority')}",
)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=requested_data.get("priority"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"updated the priority to {requested_data.get('priority')}",
epoch=epoch,
)
)
# Track chnages in state of the issue
@@ -153,6 +145,7 @@ def track_state(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None))
@@ -171,6 +164,7 @@ def track_state(
comment=f"updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
epoch=epoch,
)
)
@@ -183,6 +177,7 @@ def track_description(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("description_html") != requested_data.get(
"description_html"
@@ -203,6 +198,7 @@ def track_description(
project=project,
workspace=project.workspace,
comment=f"updated the description to {requested_data.get('description_html')}",
epoch=epoch,
)
)
@@ -215,6 +211,7 @@ def track_target_date(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("target_date") != requested_data.get("target_date"):
if requested_data.get("target_date") == None:
@@ -229,6 +226,7 @@ def track_target_date(
project=project,
workspace=project.workspace,
comment=f"updated the target date to None",
epoch=epoch,
)
)
else:
@@ -243,6 +241,7 @@ def track_target_date(
project=project,
workspace=project.workspace,
comment=f"updated the target date to {requested_data.get('target_date')}",
epoch=epoch,
)
)
@@ -255,6 +254,7 @@ def track_start_date(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("start_date") != requested_data.get("start_date"):
if requested_data.get("start_date") == None:
@@ -269,6 +269,7 @@ def track_start_date(
project=project,
workspace=project.workspace,
comment=f"updated the start date to None",
epoch=epoch,
)
)
else:
@@ -283,6 +284,7 @@ def track_start_date(
project=project,
workspace=project.workspace,
comment=f"updated the start date to {requested_data.get('start_date')}",
epoch=epoch,
)
)
@@ -295,6 +297,7 @@ def track_labels(
project,
actor,
issue_activities,
epoch
):
# Label Addition
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
@@ -314,6 +317,7 @@ def track_labels(
comment=f"added label {label.name}",
new_identifier=label.id,
old_identifier=None,
epoch=epoch,
)
)
@@ -335,6 +339,7 @@ def track_labels(
comment=f"removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
epoch=epoch,
)
)
@@ -347,6 +352,7 @@ def track_assignees(
project,
actor,
issue_activities,
epoch
):
# Assignee Addition
if len(requested_data.get("assignees_list")) > len(
@@ -367,6 +373,7 @@ def track_assignees(
workspace=project.workspace,
comment=f"added assignee {assignee.display_name}",
new_identifier=assignee.id,
epoch=epoch,
)
)
@@ -389,151 +396,29 @@ def track_assignees(
workspace=project.workspace,
comment=f"removed assignee {assignee.display_name}",
old_identifier=assignee.id,
)
)
# Track changes in blocking issues
def track_blocks(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues")
):
for block in requested_data.get("blocks_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocked_issues")
if blocked.get("block") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blocks_list")) < len(
current_instance.get("blocked_issues")
):
for blocked in current_instance.get("blocked_issues"):
if blocked.get("block") not in requested_data.get("blocks_list"):
issue = Issue.objects.get(pk=blocked.get("block"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Track changes in blocked_by issues
def track_blockings(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues")
):
for block in requested_data.get("blockers_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocker_issues")
if blocked.get("blocked_by") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blockers_list")) < len(
current_instance.get("blocker_issues")
):
for blocked in current_instance.get("blocker_issues"):
if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
issue = Issue.objects.get(pk=blocked.get("blocked_by"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
epoch=epoch,
)
)
def create_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"created the issue",
verb="created",
actor=actor,
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"created the issue",
verb="created",
actor=actor,
epoch=epoch,
)
)
)
def track_estimate_points(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
if current_instance.get("estimate_point") != requested_data.get("estimate_point"):
if requested_data.get("estimate_point") == None:
@@ -548,6 +433,7 @@ def track_estimate_points(
project=project,
workspace=project.workspace,
comment=f"updated the estimate point to None",
epoch=epoch,
)
)
else:
@@ -562,12 +448,13 @@ def track_estimate_points(
project=project,
workspace=project.workspace,
comment=f"updated the estimate point to {requested_data.get('estimate_point')}",
epoch=epoch,
)
)
def track_archive_at(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
if requested_data.get("archived_at") is None:
issue_activities.append(
@@ -581,6 +468,7 @@ def track_archive_at(
field="archived_at",
old_value="archive",
new_value="restore",
epoch=epoch,
)
)
else:
@@ -595,12 +483,13 @@ def track_archive_at(
field="archived_at",
old_value=None,
new_value="archive",
epoch=epoch,
)
)
def track_closed_to(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
if requested_data.get("closed_to") is not None:
updated_state = State.objects.get(
@@ -620,12 +509,13 @@ def track_closed_to(
comment=f"Plane updated the state to {updated_state.name}",
old_identifier=None,
new_identifier=updated_state.id,
epoch=epoch,
)
)
def update_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
ISSUE_ACTIVITY_MAPPER = {
"name": track_name,
@@ -637,8 +527,6 @@ def update_issue_activity(
"start_date": track_start_date,
"labels_list": track_labels,
"assignees_list": track_assignees,
"blocks_list": track_blocks,
"blockers_list": track_blockings,
"estimate_point": track_estimate_points,
"archived_at": track_archive_at,
"closed_to": track_closed_to,
@@ -659,11 +547,12 @@ def update_issue_activity(
project,
actor,
issue_activities,
epoch
)
def delete_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@@ -673,12 +562,13 @@ def delete_issue_activity(
verb="deleted",
actor=actor,
field="issue",
epoch=epoch,
)
)
def create_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -697,12 +587,13 @@ def create_comment_activity(
new_value=requested_data.get("comment_html", ""),
new_identifier=requested_data.get("id", None),
issue_comment_id=requested_data.get("id", None),
epoch=epoch,
)
)
def update_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -724,12 +615,13 @@ def update_comment_activity(
new_value=requested_data.get("comment_html", ""),
new_identifier=current_instance.get("id", None),
issue_comment_id=current_instance.get("id", None),
epoch=epoch,
)
)
def delete_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@@ -740,12 +632,13 @@ def delete_comment_activity(
verb="deleted",
actor=actor,
field="comment",
epoch=epoch,
)
)
def create_cycle_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -777,6 +670,7 @@ def create_cycle_issue_activity(
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
epoch=epoch,
)
)
@@ -797,12 +691,13 @@ def create_cycle_issue_activity(
workspace=project.workspace,
comment=f"added cycle {cycle.name}",
new_identifier=cycle.id,
epoch=epoch,
)
)
def delete_cycle_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -826,12 +721,13 @@ def delete_cycle_issue_activity(
workspace=project.workspace,
comment=f"removed this issue from {cycle.name if cycle is not None else None}",
old_identifier=cycle.id if cycle is not None else None,
epoch=epoch,
)
)
def create_module_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -863,6 +759,7 @@ def create_module_issue_activity(
comment=f"updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id,
new_identifier=new_module.id,
epoch=epoch,
)
)
@@ -882,12 +779,13 @@ def create_module_issue_activity(
workspace=project.workspace,
comment=f"added module {module.name}",
new_identifier=module.id,
epoch=epoch,
)
)
def delete_module_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -911,12 +809,13 @@ def delete_module_issue_activity(
workspace=project.workspace,
comment=f"removed this issue from {module.name if module is not None else None}",
old_identifier=module.id if module is not None else None,
epoch=epoch,
)
)
def create_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -934,12 +833,13 @@ def create_link_activity(
field="link",
new_value=requested_data.get("url", ""),
new_identifier=requested_data.get("id", None),
epoch=epoch,
)
)
def update_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -960,12 +860,13 @@ def update_link_activity(
old_identifier=current_instance.get("id"),
new_value=requested_data.get("url", ""),
new_identifier=current_instance.get("id", None),
epoch=epoch,
)
)
def delete_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
@@ -982,13 +883,14 @@ def delete_link_activity(
actor=actor,
field="link",
old_value=current_instance.get("url", ""),
new_value=""
new_value="",
epoch=epoch,
)
)
def create_attachment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@@ -1006,12 +908,13 @@ def create_attachment_activity(
field="attachment",
new_value=current_instance.get("asset", ""),
new_identifier=current_instance.get("id", None),
epoch=epoch,
)
)
def delete_attachment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@@ -1022,11 +925,12 @@ def delete_attachment_activity(
verb="deleted",
actor=actor,
field="attachment",
epoch=epoch,
)
)
def create_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
@@ -1045,12 +949,13 @@ def create_issue_reaction_activity(
comment="added the reaction",
old_identifier=None,
new_identifier=issue_reaction,
epoch=epoch,
)
)
def delete_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
@@ -1069,12 +974,13 @@ def delete_issue_reaction_activity(
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
epoch=epoch,
)
)
def create_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
@@ -1094,12 +1000,13 @@ def create_comment_reaction_activity(
comment="added the reaction",
old_identifier=None,
new_identifier=comment_reaction_id,
epoch=epoch,
)
)
def delete_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
@@ -1120,12 +1027,13 @@ def delete_comment_reaction_activity(
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
epoch=epoch,
)
)
def create_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("vote") is not None:
@@ -1142,12 +1050,13 @@ def create_issue_vote_activity(
comment="added the vote",
old_identifier=None,
new_identifier=None,
epoch=epoch,
)
)
def delete_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
@@ -1166,10 +1075,170 @@ def delete_issue_vote_activity(
comment="removed the vote",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
epoch=epoch,
)
)
def create_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance is None and requested_data.get("related_list") is not None:
for issue_relation in requested_data.get("related_list"):
if issue_relation.get("relation_type") == "blocked_by":
relation_type = "blocking"
else:
relation_type = issue_relation.get("relation_type")
issue = Issue.objects.get(pk=issue_relation.get("issue"))
issue_activities.append(
IssueActivity(
issue_id=issue_relation.get("related_issue"),
actor=actor,
verb="created",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field=relation_type,
project=project,
workspace=project.workspace,
comment=f'added {relation_type} relation',
old_identifier=issue_relation.get("issue"),
)
)
issue = Issue.objects.get(pk=issue_relation.get("related_issue"))
issue_activities.append(
IssueActivity(
issue_id=issue_relation.get("issue"),
actor=actor,
verb="created",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field=f'{issue_relation.get("relation_type")}',
project=project,
workspace=project.workspace,
comment=f'added {issue_relation.get("relation_type")} relation',
old_identifier=issue_relation.get("related_issue"),
epoch=epoch,
)
)
def delete_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance is not None and requested_data.get("related_list") is None:
if current_instance.get("relation_type") == "blocked_by":
relation_type = "blocking"
else:
relation_type = current_instance.get("relation_type")
issue = Issue.objects.get(pk=current_instance.get("issue"))
issue_activities.append(
IssueActivity(
issue_id=current_instance.get("related_issue"),
actor=actor,
verb="deleted",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field=relation_type,
project=project,
workspace=project.workspace,
comment=f'deleted {relation_type} relation',
old_identifier=current_instance.get("issue"),
epoch=epoch,
)
)
issue = Issue.objects.get(pk=current_instance.get("related_issue"))
issue_activities.append(
IssueActivity(
issue_id=current_instance.get("issue"),
actor=actor,
verb="deleted",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field=f'{current_instance.get("relation_type")}',
project=project,
workspace=project.workspace,
comment=f'deleted {current_instance.get("relation_type")} relation',
old_identifier=current_instance.get("related_issue"),
epoch=epoch,
)
)
def create_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"drafted the issue",
field="draft",
verb="created",
actor=actor,
epoch=epoch,
)
)
def update_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if requested_data.get("is_draft") is not None and requested_data.get("is_draft") == False:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"created the issue",
verb="updated",
actor=actor,
epoch=epoch,
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"updated the draft issue",
field="draft",
verb="updated",
actor=actor,
epoch=epoch,
)
)
def delete_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
project=project,
workspace=project.workspace,
comment=f"deleted the draft issue",
field="draft",
verb="deleted",
actor=actor,
epoch=epoch,
)
)
# Receive message from room group
@shared_task
def issue_activity(
@@ -1179,6 +1248,7 @@ def issue_activity(
issue_id,
actor_id,
project_id,
epoch,
subscriber=True,
):
try:
@@ -1233,12 +1303,17 @@ def issue_activity(
"link.activity.deleted": delete_link_activity,
"attachment.activity.created": create_attachment_activity,
"attachment.activity.deleted": delete_attachment_activity,
"issue_relation.activity.created": create_issue_relation_activity,
"issue_relation.activity.deleted": delete_issue_relation_activity,
"issue_reaction.activity.created": create_issue_reaction_activity,
"issue_reaction.activity.deleted": delete_issue_reaction_activity,
"comment_reaction.activity.created": create_comment_reaction_activity,
"comment_reaction.activity.deleted": delete_comment_reaction_activity,
"issue_vote.activity.created": create_issue_vote_activity,
"issue_vote.activity.deleted": delete_issue_vote_activity,
"issue_draft.activity.created": create_draft_issue_activity,
"issue_draft.activity.updated": update_draft_issue_activity,
"issue_draft.activity.deleted": delete_draft_issue_activity,
}
func = ACTIVITY_MAPPER.get(type)
@@ -1250,6 +1325,7 @@ def issue_activity(
project,
actor,
issue_activities,
epoch,
)
# Save all the values to database
@@ -1313,7 +1389,7 @@ def issue_activity(
):
issue_subscribers = issue_subscribers + [issue.created_by_id]
for subscriber in issue_subscribers:
for subscriber in list(set(issue_subscribers)):
for issue_activity in issue_activities_created:
bulk_notifications.append(
Notification(
@@ -32,7 +32,7 @@ def archive_old_issues():
archive_in = project.archive_in
# Get all the issues whose updated_at in less that the archive_in month
issues = Issue.objects.filter(
issues = Issue.issue_objects.filter(
Q(
project=project_id,
archived_at__isnull=True,
@@ -58,27 +58,32 @@ def archive_old_issues():
# Check if Issues
if issues:
# Set the archive time to current time
archive_at = timezone.now()
issues_to_update = []
for issue in issues:
issue.archived_at = timezone.now()
issue.archived_at = archive_at
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
updated_issues = Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100
)
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
subscriber=False,
if issues_to_update:
Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100
)
for issue in updated_issues
]
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(archive_at)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
subscriber=False,
epoch=int(timezone.now().timestamp())
)
for issue in issues_to_update
]
return
except Exception as e:
if settings.DEBUG:
@@ -99,7 +104,7 @@ def close_old_issues():
close_in = project.close_in
# Get all the issues whose updated_at in less that the close_in month
issues = Issue.objects.filter(
issues = Issue.issue_objects.filter(
Q(
project=project_id,
archived_at__isnull=True,
@@ -136,19 +141,21 @@ def close_old_issues():
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
subscriber=False,
)
for issue in updated_issues
]
if issues_to_update:
Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
subscriber=False,
epoch=int(timezone.now().timestamp())
)
for issue in issues_to_update
]
return
except Exception as e:
if settings.DEBUG:
@@ -0,0 +1,83 @@
# Generated by Django 4.2.3 on 2023-09-12 07:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from plane.db.models import IssueRelation
from sentry_sdk import capture_exception
import uuid
def create_issue_relation(apps, schema_editor):
try:
IssueBlockerModel = apps.get_model("db", "IssueBlocker")
updated_issue_relation = []
for blocked_issue in IssueBlockerModel.objects.all():
updated_issue_relation.append(
IssueRelation(
issue_id=blocked_issue.block_id,
related_issue_id=blocked_issue.blocked_by_id,
relation_type="blocked_by",
project_id=blocked_issue.project_id,
workspace_id=blocked_issue.workspace_id,
created_by_id=blocked_issue.created_by_id,
updated_by_id=blocked_issue.updated_by_id,
)
)
IssueRelation.objects.bulk_create(updated_issue_relation, batch_size=100)
except Exception as e:
print(e)
capture_exception(e)
def update_issue_priority_choice(apps, schema_editor):
IssueModel = apps.get_model("db", "Issue")
updated_issues = []
for obj in IssueModel.objects.filter(priority=None):
obj.priority = "none"
updated_issues.append(obj)
IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('db', '0042_alter_analyticview_created_by_and_more'),
]
operations = [
migrations.CreateModel(
name='IssueRelation',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('relation_type', models.CharField(choices=[('duplicate', 'Duplicate'), ('relates_to', 'Relates To'), ('blocked_by', 'Blocked By')], default='blocked_by', max_length=20, verbose_name='Issue Relation Type')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_relation', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('related_issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_related', to='db.issue')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Relation',
'verbose_name_plural': 'Issue Relations',
'db_table': 'issue_relations',
'ordering': ('-created_at',),
'unique_together': {('issue', 'related_issue')},
},
),
migrations.AddField(
model_name='issue',
name='is_draft',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='issue',
name='priority',
field=models.CharField(choices=[('urgent', 'Urgent'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')], default='none', max_length=30, verbose_name='Issue Priority'),
),
migrations.RunPython(create_issue_relation),
migrations.RunPython(update_issue_priority_choice),
]
@@ -0,0 +1,138 @@
# Generated by Django 4.2.3 on 2023-09-13 07:09
from django.db import migrations
def workspace_member_props(old_props):
new_props = {
"filters": {
"priority": old_props.get("filters", {}).get("priority", None),
"state": old_props.get("filters", {}).get("state", None),
"state_group": old_props.get("filters", {}).get("state_group", None),
"assignees": old_props.get("filters", {}).get("assignees", None),
"created_by": old_props.get("filters", {}).get("created_by", None),
"labels": old_props.get("filters", {}).get("labels", None),
"start_date": old_props.get("filters", {}).get("start_date", None),
"target_date": old_props.get("filters", {}).get("target_date", None),
"subscriber": old_props.get("filters", {}).get("subscriber", None),
},
"display_filters": {
"group_by": old_props.get("groupByProperty", None),
"order_by": old_props.get("orderBy", "-created_at"),
"type": old_props.get("filters", {}).get("type", None),
"sub_issue": old_props.get("showSubIssues", True),
"show_empty_groups": old_props.get("showEmptyGroups", True),
"layout": old_props.get("issueView", "list"),
"calendar_date_range": old_props.get("calendarDateRange", ""),
},
"display_properties": {
"assignee": old_props.get("properties", {}).get("assignee", True),
"attachment_count": old_props.get("properties", {}).get("attachment_count", True),
"created_on": old_props.get("properties", {}).get("created_on", True),
"due_date": old_props.get("properties", {}).get("due_date", True),
"estimate": old_props.get("properties", {}).get("estimate", True),
"key": old_props.get("properties", {}).get("key", True),
"labels": old_props.get("properties", {}).get("labels", True),
"link": old_props.get("properties", {}).get("link", True),
"priority": old_props.get("properties", {}).get("priority", True),
"start_date": old_props.get("properties", {}).get("start_date", True),
"state": old_props.get("properties", {}).get("state", True),
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", True),
"updated_on": old_props.get("properties", {}).get("updated_on", True),
},
}
return new_props
def project_member_props(old_props):
new_props = {
"filters": {
"priority": old_props.get("filters", {}).get("priority", None),
"state": old_props.get("filters", {}).get("state", None),
"state_group": old_props.get("filters", {}).get("state_group", None),
"assignees": old_props.get("filters", {}).get("assignees", None),
"created_by": old_props.get("filters", {}).get("created_by", None),
"labels": old_props.get("filters", {}).get("labels", None),
"start_date": old_props.get("filters", {}).get("start_date", None),
"target_date": old_props.get("filters", {}).get("target_date", None),
"subscriber": old_props.get("filters", {}).get("subscriber", None),
},
"display_filters": {
"group_by": old_props.get("groupByProperty", None),
"order_by": old_props.get("orderBy", "-created_at"),
"type": old_props.get("filters", {}).get("type", None),
"sub_issue": old_props.get("showSubIssues", True),
"show_empty_groups": old_props.get("showEmptyGroups", True),
"layout": old_props.get("issueView", "list"),
"calendar_date_range": old_props.get("calendarDateRange", ""),
},
}
return new_props
def cycle_module_props(old_props):
new_props = {
"filters": {
"priority": old_props.get("filters", {}).get("priority", None),
"state": old_props.get("filters", {}).get("state", None),
"state_group": old_props.get("filters", {}).get("state_group", None),
"assignees": old_props.get("filters", {}).get("assignees", None),
"created_by": old_props.get("filters", {}).get("created_by", None),
"labels": old_props.get("filters", {}).get("labels", None),
"start_date": old_props.get("filters", {}).get("start_date", None),
"target_date": old_props.get("filters", {}).get("target_date", None),
"subscriber": old_props.get("filters", {}).get("subscriber", None),
},
}
return new_props
def update_workspace_member_view_props(apps, schema_editor):
WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember")
updated_workspace_member = []
for obj in WorkspaceMemberModel.objects.all():
obj.view_props = workspace_member_props(obj.view_props)
obj.default_props = workspace_member_props(obj.default_props)
updated_workspace_member.append(obj)
WorkspaceMemberModel.objects.bulk_update(updated_workspace_member, ["view_props", "default_props"], batch_size=100)
def update_project_member_view_props(apps, schema_editor):
ProjectMemberModel = apps.get_model("db", "ProjectMember")
updated_project_member = []
for obj in ProjectMemberModel.objects.all():
obj.view_props = project_member_props(obj.view_props)
obj.default_props = project_member_props(obj.default_props)
updated_project_member.append(obj)
ProjectMemberModel.objects.bulk_update(updated_project_member, ["view_props", "default_props"], batch_size=100)
def update_cycle_props(apps, schema_editor):
CycleModel = apps.get_model("db", "Cycle")
updated_cycle = []
for obj in CycleModel.objects.all():
if "filter" in obj.view_props:
obj.view_props = cycle_module_props(obj.view_props)
updated_cycle.append(obj)
CycleModel.objects.bulk_update(updated_cycle, ["view_props"], batch_size=100)
def update_module_props(apps, schema_editor):
ModuleModel = apps.get_model("db", "Module")
updated_module = []
for obj in ModuleModel.objects.all():
if "filter" in obj.view_props:
obj.view_props = cycle_module_props(obj.view_props)
updated_module.append(obj)
ModuleModel.objects.bulk_update(updated_module, ["view_props"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('db', '0043_alter_analyticview_created_by_and_more'),
]
operations = [
migrations.RunPython(update_workspace_member_view_props),
migrations.RunPython(update_project_member_view_props),
migrations.RunPython(update_cycle_props),
migrations.RunPython(update_module_props),
]
@@ -0,0 +1,79 @@
# Generated by Django 4.2.5 on 2023-09-29 10:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.workspace
import uuid
def update_issue_activity_priority(apps, schema_editor):
IssueActivity = apps.get_model("db", "IssueActivity")
updated_issue_activity = []
for obj in IssueActivity.objects.filter(field="priority"):
# Set the old and new value to none if it is empty for Priority
obj.new_value = obj.new_value or "none"
obj.old_value = obj.old_value or "none"
updated_issue_activity.append(obj)
IssueActivity.objects.bulk_update(
updated_issue_activity,
["new_value", "old_value"],
batch_size=2000,
)
def update_issue_activity_blocked(apps, schema_editor):
IssueActivity = apps.get_model("db", "IssueActivity")
updated_issue_activity = []
for obj in IssueActivity.objects.filter(field="blocks"):
# Set the field to blocked_by
obj.field = "blocked_by"
updated_issue_activity.append(obj)
IssueActivity.objects.bulk_update(
updated_issue_activity,
["field"],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
('db', '0044_auto_20230913_0709'),
]
operations = [
migrations.CreateModel(
name='GlobalView',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=255, verbose_name='View Name')),
('description', models.TextField(blank=True, verbose_name='View Description')),
('query', models.JSONField(verbose_name='View Query')),
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
('query_data', models.JSONField(default=dict)),
('sort_order', models.FloatField(default=65535)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')),
],
options={
'verbose_name': 'Global View',
'verbose_name_plural': 'Global Views',
'db_table': 'global_views',
'ordering': ('-created_at',),
},
),
migrations.AddField(
model_name='workspacemember',
name='issue_props',
field=models.JSONField(default=plane.db.models.workspace.get_issue_props),
),
migrations.AddField(
model_name='issueactivity',
name='epoch',
field=models.FloatField(null=True),
),
migrations.RunPython(update_issue_activity_priority),
migrations.RunPython(update_issue_activity_blocked),
]
+2 -1
View File
@@ -32,6 +32,7 @@ from .issue import (
IssueAssignee,
Label,
IssueBlocker,
IssueRelation,
IssueLink,
IssueSequence,
IssueAttachment,
@@ -49,7 +50,7 @@ from .state import State
from .cycle import Cycle, CycleIssue, CycleFavorite
from .view import IssueView, IssueViewFavorite
from .view import GlobalView, IssueView, IssueViewFavorite
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
+36 -2
View File
@@ -29,6 +29,7 @@ class IssueManager(models.Manager):
| models.Q(issue_inbox__isnull=True)
)
.exclude(archived_at__isnull=False)
.exclude(is_draft=True)
)
@@ -38,6 +39,7 @@ class Issue(ProjectBaseModel):
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None")
)
parent = models.ForeignKey(
"self",
@@ -64,8 +66,7 @@ class Issue(ProjectBaseModel):
max_length=30,
choices=PRIORITY_CHOICES,
verbose_name="Issue Priority",
null=True,
blank=True,
default="none",
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
@@ -83,6 +84,7 @@ class Issue(ProjectBaseModel):
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
is_draft = models.BooleanField(default=False)
objects = models.Manager()
issue_objects = IssueManager()
@@ -178,6 +180,37 @@ class IssueBlocker(ProjectBaseModel):
return f"{self.block.name} {self.blocked_by.name}"
class IssueRelation(ProjectBaseModel):
RELATION_CHOICES = (
("duplicate", "Duplicate"),
("relates_to", "Relates To"),
("blocked_by", "Blocked By"),
)
issue = models.ForeignKey(
Issue, related_name="issue_relation", on_delete=models.CASCADE
)
related_issue = models.ForeignKey(
Issue, related_name="issue_related", on_delete=models.CASCADE
)
relation_type = models.CharField(
max_length=20,
choices=RELATION_CHOICES,
verbose_name="Issue Relation Type",
default="blocked_by",
)
class Meta:
unique_together = ["issue", "related_issue"]
verbose_name = "Issue Relation"
verbose_name_plural = "Issue Relations"
db_table = "issue_relations"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.related_issue.name}"
class IssueAssignee(ProjectBaseModel):
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_assignee"
@@ -276,6 +309,7 @@ class IssueActivity(ProjectBaseModel):
)
old_identifier = models.UUIDField(null=True)
new_identifier = models.UUIDField(null=True)
epoch = models.FloatField(null=True)
class Meta:
verbose_name = "Issue Activity"
+20 -7
View File
@@ -25,13 +25,26 @@ ROLE_CHOICES = (
def get_default_props():
return {
"filters": {"type": None},
"orderBy": "-created_at",
"collapsed": True,
"issueView": "list",
"filterIssue": None,
"groupByProperty": None,
"showEmptyGroups": True,
"filters": {
"priority": None,
"state": None,
"state_group": None,
"assignees": None,
"created_by": None,
"labels": None,
"start_date": None,
"target_date": None,
"subscriber": None,
},
"display_filters": {
"group_by": None,
"order_by": '-created_at',
"type": None,
"sub_issue": True,
"show_empty_groups": True,
"layout": "list",
"calendar_date_range": "",
},
}
+35 -1
View File
@@ -3,7 +3,41 @@ from django.db import models
from django.conf import settings
# Module import
from . import ProjectBaseModel
from . import ProjectBaseModel, BaseModel
class GlobalView(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="global_views"
)
name = models.CharField(max_length=255, verbose_name="View Name")
description = models.TextField(verbose_name="View Description", blank=True)
query = models.JSONField(verbose_name="View Query")
access = models.PositiveSmallIntegerField(
default=1, choices=((0, "Private"), (1, "Public"))
)
query_data = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
verbose_name = "Global View"
verbose_name_plural = "Global Views"
db_table = "global_views"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
largest_sort_order = GlobalView.objects.filter(
workspace=self.workspace
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(GlobalView, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.workspace.name}>"
class IssueView(ProjectBaseModel):
+37 -12
View File
@@ -16,26 +16,50 @@ ROLE_CHOICES = (
def get_default_props():
return {
"filters": {"type": None},
"groupByProperty": None,
"issueView": "list",
"orderBy": "-created_at",
"properties": {
"filters": {
"priority": None,
"state": None,
"state_group": None,
"assignees": None,
"created_by": None,
"labels": None,
"start_date": None,
"target_date": None,
"subscriber": None,
},
"display_filters": {
"group_by": None,
"order_by": "-created_at",
"type": None,
"sub_issue": True,
"show_empty_groups": True,
"layout": "list",
"calendar_date_range": "",
},
"display_properties": {
"assignee": True,
"attachment_count": True,
"created_on": True,
"due_date": True,
"estimate": True,
"key": True,
"labels": True,
"link": True,
"priority": True,
"start_date": True,
"state": True,
"sub_issue_count": True,
"attachment_count": True,
"link": True,
"estimate": True,
"created_on": True,
"updated_on": True,
"start_date": True,
},
"showEmptyGroups": True,
}
}
def get_issue_props():
return {
"subscribed": True,
"assigned": True,
"created": True,
"all_issues": True,
}
@@ -74,6 +98,7 @@ class WorkspaceMember(BaseModel):
company_role = models.TextField(null=True, blank=True)
view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props)
issue_props = models.JSONField(default=get_issue_props)
class Meta:
unique_together = ["workspace", "member"]
+3
View File
@@ -114,3 +114,6 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL")
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
+7 -5
View File
@@ -1,14 +1,13 @@
"""Production settings and globals."""
from urllib.parse import urlparse
import ssl
import certifi
import dj_database_url
from urllib.parse import urlparse
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from urllib.parse import urlparse
from .common import * # noqa
@@ -197,7 +196,6 @@ else:
STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage",
}
# AWS Settings End
# Enable Connection Pooling (if desired)
@@ -265,15 +263,19 @@ if DOCKERIZED:
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
else:
CELERY_RESULT_BACKEND = broker_url
CELERY_BROKER_URL = broker_url
CELERY_RESULT_BACKEND = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
# Enable or Disable signups
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Scout Settings
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
SCOUT_NAME = "Plane"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
+129
View File
@@ -0,0 +1,129 @@
"""Self hosted settings and globals."""
from urllib.parse import urlparse
import dj_database_url
from urllib.parse import urlparse
from .common import * # noqa
# Database
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
# Docker configurations
DOCKERIZED = 1
USE_MINIO = 1
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "plane",
"USER": os.environ.get("PGUSER", ""),
"PASSWORD": os.environ.get("PGPASSWORD", ""),
"HOST": os.environ.get("PGHOST", ""),
}
}
# Parse database configuration from $DATABASE_URL
DATABASES["default"] = dj_database_url.config()
SITE_ID = 1
# File size limit
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
CORS_ALLOW_METHODS = [
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
]
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = True
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
INSTALLED_APPS += ("storages",)
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
# The name of the bucket to store files in.
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
)
# Default permissions
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
# Custom Domain settings
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Allow all host headers
ALLOWED_HOSTS = [
"*",
]
# Security settings
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Redis URL
REDIS_URL = os.environ.get("REDIS_URL")
# Caches
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
# URL used for email redirects
WEB_URL = os.environ.get("WEB_URL", "http://localhost")
# Celery settings
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
# Enable or Disable signups
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Analytics
ANALYTICS_BASE_API = False
# OPEN AI Settings
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
+4
View File
@@ -218,3 +218,7 @@ CELERY_BROKER_URL = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
+4 -4
View File
@@ -74,10 +74,10 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
sorted_data = grouped_data
if temp_axis == "priority":
order = ["low", "medium", "high", "urgent", "None"]
order = ["low", "medium", "high", "urgent", "none"]
sorted_data = {key: grouped_data[key] for key in order if key in grouped_data}
else:
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "none", x[0])))
return sorted_data
@@ -96,7 +96,7 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
@@ -118,7 +118,7 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,
+134 -32
View File
@@ -15,7 +15,7 @@ def resolve_keys(group_keys, value):
return value
def group_results(results_data, group_by):
def group_results(results_data, group_by, sub_group_by=False):
"""group results data into certain group_by
Args:
@@ -25,38 +25,140 @@ def group_results(results_data, group_by):
Returns:
obj: grouped results
"""
response_dict = dict()
if sub_group_by:
main_responsive_dict = dict()
if group_by == "priority":
response_dict = {
"urgent": [],
"high": [],
"medium": [],
"low": [],
"None": [],
}
if sub_group_by == "priority":
main_responsive_dict = {
"urgent": {},
"high": {},
"medium": {},
"low": {},
"none": {},
}
for value in results_data:
group_attribute = resolve_keys(group_by, value)
if isinstance(group_attribute, list):
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in response_dict:
response_dict[str(attrib)].append(value)
else:
response_dict[str(attrib)] = []
response_dict[str(attrib)].append(value)
else:
if str(None) in response_dict:
response_dict[str(None)].append(value)
for value in results_data:
main_group_attribute = resolve_keys(sub_group_by, value)
group_attribute = resolve_keys(group_by, value)
if isinstance(main_group_attribute, list) and not isinstance(group_attribute, list):
if len(main_group_attribute):
for attrib in main_group_attribute:
if str(attrib) not in main_responsive_dict:
main_responsive_dict[str(attrib)] = {}
if str(group_attribute) in main_responsive_dict[str(attrib)]:
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
else:
main_responsive_dict[str(attrib)][str(group_attribute)] = []
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
else:
response_dict[str(None)] = []
response_dict[str(None)].append(value)
else:
if str(group_attribute) in response_dict:
response_dict[str(group_attribute)].append(value)
else:
response_dict[str(group_attribute)] = []
response_dict[str(group_attribute)].append(value)
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
return response_dict
if str(group_attribute) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(group_attribute)].append(value)
else:
main_responsive_dict[str(None)][str(group_attribute)] = []
main_responsive_dict[str(None)][str(group_attribute)].append(value)
elif isinstance(group_attribute, list) and not isinstance(main_group_attribute, list):
if str(main_group_attribute) not in main_responsive_dict:
main_responsive_dict[str(main_group_attribute)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(main_group_attribute)]:
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
else:
main_responsive_dict[str(main_group_attribute)][str(attrib)] = []
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(main_group_attribute)]:
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
else:
main_responsive_dict[str(main_group_attribute)][str(None)] = []
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
elif isinstance(group_attribute, list) and isinstance(main_group_attribute, list):
if len(main_group_attribute):
for main_attrib in main_group_attribute:
if str(main_attrib) not in main_responsive_dict:
main_responsive_dict[str(main_attrib)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(main_attrib)]:
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
else:
main_responsive_dict[str(main_attrib)][str(attrib)] = []
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(main_attrib)]:
main_responsive_dict[str(main_attrib)][str(None)].append(value)
else:
main_responsive_dict[str(main_attrib)][str(None)] = []
main_responsive_dict[str(main_attrib)][str(None)].append(value)
else:
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(attrib)].append(value)
else:
main_responsive_dict[str(None)][str(attrib)] = []
main_responsive_dict[str(None)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(None)].append(value)
else:
main_responsive_dict[str(None)][str(None)] = []
main_responsive_dict[str(None)][str(None)].append(value)
else:
main_group_attribute = resolve_keys(sub_group_by, value)
group_attribute = resolve_keys(group_by, value)
if str(main_group_attribute) not in main_responsive_dict:
main_responsive_dict[str(main_group_attribute)] = {}
if str(group_attribute) in main_responsive_dict[str(main_group_attribute)]:
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
else:
main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = []
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
return main_responsive_dict
else:
response_dict = dict()
if group_by == "priority":
response_dict = {
"urgent": [],
"high": [],
"medium": [],
"low": [],
"none": [],
}
for value in results_data:
group_attribute = resolve_keys(group_by, value)
if isinstance(group_attribute, list):
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in response_dict:
response_dict[str(attrib)].append(value)
else:
response_dict[str(attrib)] = []
response_dict[str(attrib)].append(value)
else:
if str(None) in response_dict:
response_dict[str(None)].append(value)
else:
response_dict[str(None)] = []
response_dict[str(None)].append(value)
else:
if str(group_attribute) in response_dict:
response_dict[str(group_attribute)].append(value)
else:
response_dict[str(group_attribute)] = []
response_dict[str(group_attribute)].append(value)
return response_dict
+7 -26
View File
@@ -1,6 +1,7 @@
from django.utils.timezone import make_aware
from django.utils.dateparse import parse_datetime
def filter_state(params, filter, method):
if method == "GET":
states = params.get("state").split(",")
@@ -23,7 +24,6 @@ def filter_state_group(params, filter, method):
return filter
def filter_estimate_point(params, filter, method):
if method == "GET":
estimate_points = params.get("estimate_point").split(",")
@@ -39,25 +39,7 @@ def filter_priority(params, filter, method):
if method == "GET":
priorities = params.get("priority").split(",")
if len(priorities) and "" not in priorities:
if len(priorities) == 1 and "null" in priorities:
filter["priority__isnull"] = True
elif len(priorities) > 1 and "null" in priorities:
filter["priority__isnull"] = True
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
if params.get("priority", None) and len(params.get("priority")):
priorities = params.get("priority")
if len(priorities) == 1 and "null" in priorities:
filter["priority__isnull"] = True
elif len(priorities) > 1 and "null" in priorities:
filter["priority__isnull"] = True
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
filter["priority__in"] = [p for p in priorities if p != "null"]
filter["priority__in"] = priorities
return filter
@@ -181,17 +163,17 @@ def filter_target_date(params, filter, method):
for query in target_dates:
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gt"] = target_date_query[0]
filter["target_date__gte"] = target_date_query[0]
else:
filter["target_date__lt"] = target_date_query[0]
filter["target_date__lte"] = target_date_query[0]
else:
if params.get("target_date", None) and len(params.get("target_date")):
for query in params.get("target_date"):
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gt"] = target_date_query[0]
filter["target_date__gte"] = target_date_query[0]
else:
filter["target_date__lt"] = target_date_query[0]
filter["target_date__lte"] = target_date_query[0]
return filter
@@ -229,7 +211,6 @@ def filter_issue_state_type(params, filter, method):
return filter
def filter_project(params, filter, method):
if method == "GET":
projects = params.get("project").split(",")
@@ -329,7 +310,7 @@ def issue_filters(query_params, method):
"module": filter_module,
"inbox_status": filter_inbox_status,
"sub_issue": filter_sub_issue_toggle,
"subscriber": filter_subscribed_issues,
"subscriber": filter_subscribed_issues,
"start_target_date": filter_start_target_date_issues,
}
+13 -13
View File
@@ -1,36 +1,36 @@
# base requirements
Django==4.2.3
Django==4.2.5
django-braces==1.15.0
django-taggit==4.0.0
psycopg==3.1.9
psycopg==3.1.10
django-oauth-toolkit==2.3.0
mistune==3.0.1
djangorestframework==3.14.0
redis==4.6.0
django-nested-admin==4.0.2
django-cors-headers==4.1.0
django-cors-headers==4.2.0
whitenoise==6.5.0
django-allauth==0.54.0
django-allauth==0.55.2
faker==18.11.2
django-filter==23.2
jsonmodels==2.6.0
djangorestframework-simplejwt==5.2.2
sentry-sdk==1.27.0
djangorestframework-simplejwt==5.3.0
sentry-sdk==1.30.0
django-s3-storage==0.14.0
django-crum==0.7.9
django-guardian==2.4.0
dj_rest_auth==2.2.5
google-auth==2.21.0
google-api-python-client==2.92.0
google-auth==2.22.0
google-api-python-client==2.97.0
django-redis==5.3.0
uvicorn==0.22.0
uvicorn==0.23.2
channels==4.0.0
openai==0.27.8
openai==0.28.0
slack-sdk==3.21.3
celery==5.3.1
celery==5.3.4
django_celery_beat==2.5.0
psycopg-binary==3.1.9
psycopg-c==3.1.9
psycopg-binary==3.1.10
psycopg-c==3.1.10
scout-apm==2.26.1
openpyxl==3.1.2
+5 -5
View File
@@ -1,11 +1,11 @@
-r base.txt
dj-database-url==2.0.0
gunicorn==20.1.0
dj-database-url==2.1.0
gunicorn==21.2.0
whitenoise==6.5.0
django-storages==1.13.2
boto3==1.27.0
django-anymail==10.0
django-storages==1.14
boto3==1.28.40
django-anymail==10.1
django-debug-toolbar==4.1.0
gevent==23.7.0
psycogreen==1.0.2
+30 -81
View File
@@ -1,113 +1,61 @@
version: "3.8"
x-api-and-worker-env:
&api-and-worker-env
DEBUG: ${DEBUG}
SENTRY_DSN: ${SENTRY_DSN}
DJANGO_SETTINGS_MODULE: plane.settings.production
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
REDIS_URL: redis://plane-redis:6379/
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
EMAIL_PORT: ${EMAIL_PORT}
EMAIL_FROM: ${EMAIL_FROM}
EMAIL_USE_TLS: ${EMAIL_USE_TLS}
EMAIL_USE_SSL: ${EMAIL_USE_SSL}
AWS_REGION: ${AWS_REGION}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
WEB_URL: ${WEB_URL}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
DISABLE_COLLECTSTATIC: 1
DOCKERIZED: 1
OPENAI_API_BASE: ${OPENAI_API_BASE}
OPENAI_API_KEY: ${OPENAI_API_KEY}
GPT_ENGINE: ${GPT_ENGINE}
SECRET_KEY: ${SECRET_KEY}
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
USE_MINIO: ${USE_MINIO}
ENABLE_SIGNUP: ${ENABLE_SIGNUP}
services:
plane-web:
container_name: planefrontend
web:
container_name: web
image: makeplane/plane-frontend:latest
restart: always
command: /usr/local/bin/start.sh web/server.js web
env_file:
- .env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
NEXT_PUBLIC_GITHUB_ID: "0"
NEXT_PUBLIC_SENTRY_DSN: "0"
NEXT_PUBLIC_ENABLE_OAUTH: "0"
NEXT_PUBLIC_ENABLE_SENTRY: "0"
NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0"
NEXT_PUBLIC_TRACK_EVENTS: "0"
- ./web/.env
depends_on:
- plane-api
- plane-worker
- api
- worker
plane-deploy:
container_name: planedeploy
image: makeplane/plane-deploy:latest
space:
container_name: space
image: makeplane/plane-space:latest
restart: always
command: /usr/local/bin/start.sh space/server.js space
env_file:
- .env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
- ./space/.env
depends_on:
- plane-api
- plane-worker
- plane-web
- api
- worker
- web
plane-api:
container_name: planebackend
api:
container_name: api
image: makeplane/plane-backend:latest
restart: always
command: ./bin/takeoff
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-db
- plane-redis
plane-worker:
container_name: planebgworker
image: makeplane/plane-worker:latest
worker:
container_name: bgworker
image: makeplane/plane-backend:latest
restart: always
command: ./bin/worker
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-api
- api
- plane-db
- plane-redis
plane-beat-worker:
container_name: planebeatworker
image: makeplane/plane-worker:latest
beat-worker:
container_name: beatworker
image: makeplane/plane-backend:latest
restart: always
command: ./bin/beat
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-api
- api
- plane-db
- plane-redis
@@ -157,8 +105,8 @@ services:
- plane-minio
# Comment this if you already have a reverse proxy running
plane-proxy:
container_name: planeproxy
proxy:
container_name: proxy
image: makeplane/plane-proxy:latest
ports:
- ${NGINX_PORT}:80
@@ -168,8 +116,9 @@ services:
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
depends_on:
- plane-web
- plane-api
- web
- api
- space
volumes:
pgdata:
+25 -89
View File
@@ -1,88 +1,35 @@
version: "3.8"
x-api-and-worker-env: &api-and-worker-env
DEBUG: ${DEBUG}
SENTRY_DSN: ${SENTRY_DSN}
DJANGO_SETTINGS_MODULE: plane.settings.production
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
REDIS_URL: redis://plane-redis:6379/
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
EMAIL_PORT: ${EMAIL_PORT}
EMAIL_FROM: ${EMAIL_FROM}
EMAIL_USE_TLS: ${EMAIL_USE_TLS}
EMAIL_USE_SSL: ${EMAIL_USE_SSL}
AWS_REGION: ${AWS_REGION}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
WEB_URL: ${WEB_URL}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
DISABLE_COLLECTSTATIC: 1
DOCKERIZED: 1
OPENAI_API_BASE: ${OPENAI_API_BASE}
OPENAI_API_KEY: ${OPENAI_API_KEY}
GPT_ENGINE: ${GPT_ENGINE}
SECRET_KEY: ${SECRET_KEY}
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
USE_MINIO: ${USE_MINIO}
ENABLE_SIGNUP: ${ENABLE_SIGNUP}
services:
plane-web:
container_name: planefrontend
web:
container_name: web
build:
context: .
dockerfile: ./web/Dockerfile.web
args:
DOCKER_BUILDKIT: 1
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
NEXT_PUBLIC_DEPLOY_URL: http://localhost/spaces
restart: always
command: /usr/local/bin/start.sh web/server.js web
env_file:
- .env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
NEXT_PUBLIC_GITHUB_ID: "0"
NEXT_PUBLIC_SENTRY_DSN: "0"
NEXT_PUBLIC_ENABLE_OAUTH: "0"
NEXT_PUBLIC_ENABLE_SENTRY: "0"
NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0"
NEXT_PUBLIC_TRACK_EVENTS: "0"
depends_on:
- plane-api
- plane-worker
- api
- worker
plane-deploy:
container_name: planedeploy
space:
container_name: space
build:
context: .
dockerfile: ./space/Dockerfile.space
args:
DOCKER_BUILDKIT: 1
NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
restart: always
command: /usr/local/bin/start.sh space/server.js space
env_file:
- .env
environment:
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
depends_on:
- plane-api
- plane-worker
- plane-web
- api
- worker
- web
plane-api:
container_name: planebackend
api:
container_name: api
build:
context: ./apiserver
dockerfile: Dockerfile.api
@@ -90,18 +37,14 @@ services:
DOCKER_BUILDKIT: 1
restart: always
command: ./bin/takeoff
ports:
- 8000:8000
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-db
- plane-redis
plane-worker:
container_name: planebgworker
worker:
container_name: bgworker
build:
context: ./apiserver
dockerfile: Dockerfile.api
@@ -110,16 +53,14 @@ services:
restart: always
command: ./bin/worker
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-api
- api
- plane-db
- plane-redis
plane-beat-worker:
container_name: planebeatworker
beat-worker:
container_name: beatworker
build:
context: ./apiserver
dockerfile: Dockerfile.api
@@ -128,11 +69,9 @@ services:
restart: always
command: ./bin/beat
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-api
- api
- plane-db
- plane-redis
@@ -165,8 +104,6 @@ services:
command: server /export --console-address ":9090"
volumes:
- uploads:/export
env_file:
- .env
environment:
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
@@ -181,22 +118,21 @@ services:
- plane-minio
# Comment this if you already have a reverse proxy running
plane-proxy:
container_name: planeproxy
proxy:
container_name: proxy
build:
context: ./nginx
dockerfile: Dockerfile
restart: always
ports:
- ${NGINX_PORT}:80
env_file:
- .env
environment:
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
depends_on:
- plane-web
- plane-api
- web
- api
- space
volumes:
pgdata:
+26 -20
View File
@@ -1,30 +1,36 @@
events { }
events {
}
http {
sendfile on;
sendfile on;
server {
listen 80;
root /www/data/;
access_log /var/log/nginx/access.log;
server {
listen 80;
root /www/data/;
access_log /var/log/nginx/access.log;
client_max_body_size ${FILE_SIZE_LIMIT};
client_max_body_size ${FILE_SIZE_LIMIT};
location / {
proxy_pass http://planefrontend:3000/;
}
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "interest-cohort=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location /api/ {
proxy_pass http://planebackend:8000/api/;
}
location / {
proxy_pass http://web:3000/;
}
location /spaces/ {
proxy_pass http://planedeploy:3000/spaces/;
}
location /api/ {
proxy_pass http://api:8000/api/;
}
location /${BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/uploads/;
location /spaces/ {
rewrite ^/spaces/?$ /spaces/login break;
proxy_pass http://space:3000/spaces/;
}
location /${BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/uploads/;
}
}
}
}
+13 -2
View File
@@ -1,14 +1,18 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.13.2",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
"web",
"space",
"packages/*"
"packages/editor/*",
"packages/eslint-config-custom",
"packages/tailwind-config-custom",
"packages/tsconfig",
"packages/ui"
],
"scripts": {
"prepare": "husky install",
"build": "turbo run build",
"dev": "turbo run dev",
"start": "turbo run start",
@@ -17,9 +21,16 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"autoprefixer": "^10.4.15",
"eslint-config-custom": "*",
"postcss": "^8.4.29",
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "latest"
},
"resolutions": {
"@types/react": "18.2.0"
},
"packageManager": "yarn@1.22.19"
}
+112
View File
@@ -0,0 +1,112 @@
# @plane/editor-core
## Description
The `@plane/editor-core` package serves as the foundation for our editor system. It provides the base functionality for our other editor packages, but it will not be used directly in any of the projects but only for extending other editors.
## Utilities
We provide a wide range of utilities for extending the core itself.
1. Merging classes and custom styling
2. Adding new extensions
3. Adding custom props
4. Base menu items, and their commands
This allows for extensive customization and flexibility in the Editors created using our `editor-core` package.
### Here's a detailed overview of what's exported
1. useEditor - A hook that you can use to extend the Plane editor.
| Prop | Type | Description |
| --- | --- | --- |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert in case of content not being "saved". |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
2. useReadOnlyEditor - A hook that can be used to extend a Read Only instance of the core editor.
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | The initial content of the editor. |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
3. Items and Commands - H1, H2, H3, task list, quote, code block, etc's methods.
4. UI Wrappers
- `EditorContainer` - Wrap your Editor Container with this to apply base classes and styles.
- `EditorContentWrapper` - Use this to get Editor's Content and base menus.
5. Extending with Custom Styles
```ts
const customEditorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
```
## Core features
- **Content Trimming**: The Editors content is now automatically trimmed of empty line breaks from the start and end before submitting it to the backend. This ensures cleaner, more consistent data.
- **Value Cleaning**: The Editors value is cleaned at the editor core level, eliminating the need for additional validation before sending from our app. This results in cleaner code and less potential for errors.
- **Turbo Pipeline**: Added a turbo pipeline for both dev and build tasks for projects depending on the editor package.
```json
"web#develop": {
"cache": false,
"persistent": true,
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build"
]
},
"space#develop": {
"cache": false,
"persistent": true,
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build"
]
},
"web#build": {
"cache": true,
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build"
]
},
"space#build": {
"cache": true,
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build"
]
},
```
## Base extensions included
- BulletList
- OrderedList
- Blockquote
- Code
- Gapcursor
- Link
- Image
- Basic Marks
- Underline
- TextStyle
- Color
- TaskList
- Markdown
- Table
+74
View File
@@ -0,0 +1,74 @@
{
"name": "@plane/editor-core",
"version": "0.0.1",
"description": "Core Editor that powers Plane",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"files": [
"dist/**/*"
],
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"module": "./dist/index.mjs"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "18.2.0",
"next": "12.3.2",
"next-themes": "^0.2.1"
},
"dependencies": {
"@blueprintjs/popover2": "^2.0.10",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-color": "^2.1.11",
"@tiptap/extension-image": "^2.1.7",
"@tiptap/extension-link": "^2.1.7",
"@tiptap/extension-task-item": "^2.1.7",
"@tiptap/extension-task-list": "^2.1.7",
"@tiptap/extension-text-style": "^2.1.11",
"@tiptap/extension-underline": "^2.1.7",
"@tiptap/pm": "^2.1.7",
"@tiptap/react": "^2.1.7",
"@tiptap/starter-kit": "^2.1.10",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
"@types/node": "18.15.3",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eventsource-parser": "^0.1.0",
"lucide-react": "^0.244.0",
"react-markdown": "^8.0.7",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.2",
"use-debounce": "^9.0.4",
"@tiptap/prosemirror-tables": "^1.1.4",
"jsx-dom-cjs": "^8.0.3"
},
"devDependencies": {
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"tailwind-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "4.9.5"
},
"keywords": [
"editor",
"rich-text",
"markdown",
"nextjs",
"react"
]
}
+9
View File
@@ -0,0 +1,9 @@
// If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+21
View File
@@ -0,0 +1,21 @@
// styles
// import "./styles/tailwind.css";
// import "./styles/editor.css";
export * from "./ui/extensions/table/table";
// utils
export * from "./lib/utils";
export { startImageUpload } from "./ui/plugins/upload-image";
// components
export { EditorContainer } from "./ui/components/editor-container";
export { EditorContentWrapper } from "./ui/components/editor-content";
// hooks
export { useEditor } from "./ui/hooks/useEditor";
export { useReadOnlyEditor } from "./ui/hooks/useReadOnlyEditor";
// helper items
export * from "./ui/menus/menu-items";
export * from "./lib/editor-commands";
@@ -0,0 +1,91 @@
import { Editor, Range } from "@tiptap/core";
import { UploadImage } from "../types/upload-image";
import { startImageUpload } from "../ui/plugins/upload-image";
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
else editor.chain().focus().toggleHeading({ level: 1 }).run()
};
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
else editor.chain().focus().toggleHeading({ level: 2 }).run()
};
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
else editor.chain().focus().toggleHeading({ level: 3 }).run()
};
export const toggleBold = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
else editor.chain().focus().toggleBold().run();
};
export const toggleItalic = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleItalic().run();
else editor.chain().focus().toggleItalic().run();
};
export const toggleUnderline = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleUnderline().run();
else editor.chain().focus().toggleUnderline().run();
};
export const toggleCode = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleCode().run();
else editor.chain().focus().toggleCode().run();
};
export const toggleOrderedList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
else editor.chain().focus().toggleOrderedList().run();
};
export const toggleBulletList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
else editor.chain().focus().toggleBulletList().run();
};
export const toggleTaskList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
else editor.chain().focus().toggleTaskList().run()
};
export const toggleStrike = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleStrike().run();
else editor.chain().focus().toggleStrike().run();
};
export const toggleBlockquote = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run();
else editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run();
};
export const insertTableCommand = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
};
export const unsetLinkEditor = (editor: Editor) => {
editor.chain().focus().unsetLink().run();
};
export const setLinkEditor = (editor: Editor, url: string) => {
editor.chain().focus().setLink({ href: url }).run();
};
export const insertImageCommand = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).run();
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting);
}
};
input.click();
};
+45
View File
@@ -0,0 +1,45 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
interface EditorClassNames {
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
}
export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) => cn(
'relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md',
noBorder ? '' : 'border border-custom-border-200',
borderOnFocus ? 'focus:border border-custom-border-300' : 'focus:border-0',
customClassName
);
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const findTableAncestor = (
node: Node | null
): HTMLTableElement | null => {
while (node !== null && node.nodeName !== "TABLE") {
node = node.parentNode;
}
return node as HTMLTableElement;
};
export const getTrimmedHTML = (html: string) => {
html = html.replace(/^(<p><\/p>)+/, '');
html = html.replace(/(<p><\/p>)+$/, '');
return html;
}
export const isValidHttpUrl = (string: string): boolean => {
let url: URL;
try {
url = new URL(string);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
}
+231
View File
@@ -0,0 +1,231 @@
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400));
pointer-events: none;
height: 0;
}
.ProseMirror .is-empty::before {
content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400));
pointer-events: none;
height: 0;
}
/* Custom image styles */
.ProseMirror img {
transition: filter 0.1s ease-in-out;
&:hover {
cursor: pointer;
filter: brightness(90%);
}
&.ProseMirror-selectednode {
outline: 3px solid #5abbf7;
filter: brightness(90%);
}
}
.ProseMirror-gapcursor:after {
border-top: 1px solid rgb(var(--color-text-100)) !important;
}
/* Custom TODO list checkboxes shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */
ul[data-type="taskList"] li > label {
margin-right: 0.2rem;
user-select: none;
}
@media screen and (max-width: 768px) {
ul[data-type="taskList"] li > label {
margin-right: 0.5rem;
}
}
ul[data-type="taskList"] li > label input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
background-color: rgb(var(--color-background-100));
margin: 0;
cursor: pointer;
width: 1.2rem;
height: 1.2rem;
position: relative;
border: 2px solid rgb(var(--color-text-100));
margin-right: 0.3rem;
display: grid;
place-content: center;
&:hover {
background-color: rgb(var(--color-background-80));
}
&:active {
background-color: rgb(var(--color-background-90));
}
&::before {
content: "";
width: 0.65em;
height: 0.65em;
transform: scale(0);
transition: 120ms transform ease-in-out;
box-shadow: inset 1em 1em;
transform-origin: center;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
&:checked::before {
transform: scale(1);
}
}
ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: rgb(var(--color-text-200));
text-decoration: line-through;
text-decoration-thickness: 2px;
}
/* Overwrite tippy-box original max-width */
.tippy-box {
max-width: 400px !important;
}
.ProseMirror {
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
-moz-tab-size: 4;
tab-size: 4;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
outline: none;
cursor: text;
line-height: 1.2;
font-family: inherit;
font-size: 14px;
color: inherit;
-moz-box-sizing: border-box;
box-sizing: border-box;
appearance: textfield;
-webkit-appearance: textfield;
-moz-appearance: textfield;
}
.fadeIn {
opacity: 1;
transition: opacity 0.3s ease-in;
}
.fadeOut {
opacity: 0;
transition: opacity 0.2s ease-out;
}
.img-placeholder {
position: relative;
width: 35%;
&:before {
content: "";
box-sizing: border-box;
position: absolute;
top: 50%;
left: 45%;
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid rgba(var(--color-text-200));
border-top-color: rgba(var(--color-text-800));
animation: spinning 0.6s linear infinite;
}
}
@keyframes spinning {
to {
transform: rotate(360deg);
}
}
#editor-container {
table {
border-collapse: collapse;
table-layout: fixed;
margin: 0;
border: 1px solid rgb(var(--color-border-200));
width: 100%;
td,
th {
min-width: 1em;
border: 1px solid rgb(var(--color-border-200));
padding: 10px 15px;
vertical-align: top;
box-sizing: border-box;
position: relative;
transition: background-color 0.3s ease;
> * {
margin-bottom: 0;
}
}
th {
font-weight: bold;
text-align: left;
background-color: rgb(var(--color-primary-100));
}
td:hover {
background-color: rgba(var(--color-primary-300), 0.1);
}
.selectedCell:after {
z-index: 2;
position: absolute;
content: "";
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(var(--color-primary-300), 0.1);
pointer-events: none;
}
.column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 2px;
background-color: rgb(var(--color-primary-400));
pointer-events: none;
}
}
}
/* .tableWrapper { */
/* overflow-x: auto; */
/* } */
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
.ProseMirror table * p {
padding: 0px 1px;
margin: 6px 2px;
}
.ProseMirror table * .is-empty::before {
opacity: 0;
}
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -0,0 +1 @@
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
@@ -0,0 +1 @@
export type UploadImage = (file: File) => Promise<string>;
@@ -0,0 +1,20 @@
import { Editor } from "@tiptap/react";
import { ReactNode } from "react";
interface EditorContainerProps {
editor: Editor | null;
editorClassNames: string;
children: ReactNode;
}
export const EditorContainer = ({ editor, editorClassNames, children }: EditorContainerProps) => (
<div
id="editor-container"
onClick={() => {
editor?.chain().focus().run();
}}
className={`cursor-text editorContainer ${editorClassNames}`}
>
{children}
</div>
);
@@ -0,0 +1,17 @@
import { Editor, EditorContent } from "@tiptap/react";
import { ReactNode } from "react";
import { ImageResizer } from "../extensions/image/image-resize";
interface EditorContentProps {
editor: Editor | null;
editorContentCustomClassNames: string | undefined;
children?: ReactNode;
}
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => (
<div className={`contentEditor ${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{editor?.isActive("image") && <ImageResizer editor={editor} />}
{children}
</div>
);
@@ -0,0 +1,23 @@
import Image from "@tiptap/extension-image";
import TrackImageDeletionPlugin from "../../plugins/delete-image";
import UploadImagesPlugin from "../../plugins/upload-image";
import { DeleteImage } from "../../../types/delete-image";
const ImageExtension = (deleteImage: DeleteImage) => Image.extend({
addProseMirrorPlugins() {
return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)];
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
};
},
});
export default ImageExtension;
@@ -0,0 +1,17 @@
import Image from "@tiptap/extension-image";
const ReadOnlyImageExtension = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
};
},
});
export default ReadOnlyImageExtension;
@@ -0,0 +1,95 @@
import StarterKit from "@tiptap/starter-kit";
import TiptapLink from "@tiptap/extension-link";
import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Gapcursor from "@tiptap/extension-gapcursor";
import ImageExtension from "./image";
import { DeleteImage } from "../../types/delete-image";
import { isValidHttpUrl } from "../../lib/utils";
import TableHeader from "./table/table-header/table-header";
import Table from "./table/table";
import TableCell from "./table/table-cell/table-cell";
import TableRow from "./table/table-row/table-row";
export const CoreEditorExtensions = (
deleteFile: DeleteImage,
) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtension(deleteFile).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
];
@@ -0,0 +1 @@
export { default as default } from "./table-cell"
@@ -0,0 +1,58 @@
import { mergeAttributes, Node } from "@tiptap/core"
export interface TableCellOptions {
HTMLAttributes: Record<string, any>
}
export default Node.create<TableCellOptions>({
name: "tableCell",
addOptions() {
return {
HTMLAttributes: {}
}
},
content: "paragraph+",
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth")
const value = colwidth ? [parseInt(colwidth, 10)] : null
return value
}
},
background: {
default: "none"
}
}
},
tableRole: "cell",
isolating: true,
parseHTML() {
return [{ tag: "td" }]
},
renderHTML({ node, HTMLAttributes }) {
return [
"td",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`
}),
0
]
}
})
@@ -0,0 +1 @@
export { default as default } from "./table-header"
@@ -0,0 +1,57 @@
import { mergeAttributes, Node } from "@tiptap/core"
export interface TableHeaderOptions {
HTMLAttributes: Record<string, any>
}
export default Node.create<TableHeaderOptions>({
name: "tableHeader",
addOptions() {
return {
HTMLAttributes: {}
}
},
content: "paragraph+",
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth")
const value = colwidth ? [parseInt(colwidth, 10)] : null
return value
}
},
background: {
default: "rgb(var(--color-primary-100))"
}
}
},
tableRole: "header_cell",
isolating: true,
parseHTML() {
return [{ tag: "th" }]
},
renderHTML({ node, HTMLAttributes }) {
return [
"th",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`
}),
0
]
}
})
@@ -0,0 +1 @@
export { default as default } from "./table-row"
@@ -0,0 +1,31 @@
import { mergeAttributes, Node } from "@tiptap/core"
export interface TableRowOptions {
HTMLAttributes: Record<string, any>
}
export default Node.create<TableRowOptions>({
name: "tableRow",
addOptions() {
return {
HTMLAttributes: {}
}
},
content: "(tableCell | tableHeader)*",
tableRole: "row",
parseHTML() {
return [{ tag: "tr" }]
},
renderHTML({ HTMLAttributes }) {
return [
"tr",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0
]
}
})
@@ -0,0 +1,11 @@
const icons = {
insertColumnLeft: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M20 3c.552 0 1 .448 1 1v16c0 .552-.448 1-1 1h-6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2h-4v14h4V5zM6 7c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2H5v1.999L3 11v2l2-.001V15h2v-2.001L9 13v-2l-2-.001V9z"/></svg>`,
insertColumnRight: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M10 3c.552 0 1 .448 1 1v16c0 .552-.448 1-1 1H4c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zM9 5H5v14h4V5zm9 2c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2h-2v1.999L15 11v2l2-.001V15h2v-2.001L21 13v-2l-2-.001V9z"/></svg>`,
insertRowTop: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M20 13c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1H4c-.552 0-1-.448-1-1v-6c0-.552.448-1 1-1h16zm-1 2H5v4h14v-4zM12 1c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2h-2v1.999L9 5v2l2-.001V9h2V6.999L15 7V5l-2-.001V3z"/></svg>`,
insertRowBottom: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M12 13c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2h-2v1.999L9 17v2l2-.001V21h2v-2.001L15 19v-2l-2-.001V15zm7-12c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1H4c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h16zM5 5v4h14V5H5z"/></svg>`,
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: inherit;transform: ;msFilter:;"><path d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`
}
export default icons
@@ -0,0 +1 @@
export { default as default } from "./table"
@@ -0,0 +1,118 @@
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"
import { findParentNode } from "@tiptap/core"
import { DecorationSet, Decoration } from "@tiptap/pm/view"
const key = new PluginKey("tableControls")
export function tableControls() {
return new Plugin({
key,
state: {
init() {
return new TableControlsState()
},
apply(tr, prev) {
return prev.apply(tr)
}
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
const pluginState = key.getState(view.state)
if (
!(event.target as HTMLElement).closest(
".tableWrapper"
) &&
pluginState.values.hoveredTable
) {
return view.dispatch(
view.state.tr.setMeta(key, {
setHoveredTable: null,
setHoveredCell: null
})
)
}
const pos = view.posAtCoords({
left: event.clientX,
top: event.clientY
})
if (!pos) return
const table = findParentNode(
(node) => node.type.name === "table"
)(TextSelection.create(view.state.doc, pos.pos))
const cell = findParentNode(
(node) =>
node.type.name === "tableCell" ||
node.type.name === "tableHeader"
)(TextSelection.create(view.state.doc, pos.pos))
if (!table || !cell) return
if (pluginState.values.hoveredCell?.pos !== cell.pos) {
return view.dispatch(
view.state.tr.setMeta(key, {
setHoveredTable: table,
setHoveredCell: cell
})
)
}
}
},
decorations: (state) => {
const pluginState = key.getState(state)
if (!pluginState) {
return null
}
const { hoveredTable, hoveredCell } = pluginState.values
if (hoveredTable) {
const decorations = [
Decoration.node(
hoveredTable.pos,
hoveredTable.pos + hoveredTable.node.nodeSize,
{},
{
hoveredTable,
hoveredCell
}
)
]
return DecorationSet.create(state.doc, decorations)
}
return null
}
}
})
}
class TableControlsState {
values
constructor(props = {}) {
this.values = {
hoveredTable: null,
hoveredCell: null,
...props
}
}
apply(tr: any) {
const actions = tr.getMeta(key)
if (actions?.setHoveredTable !== undefined) {
this.values.hoveredTable = actions.setHoveredTable
}
if (actions?.setHoveredCell !== undefined) {
this.values.hoveredCell = actions.setHoveredCell
}
return this
}
}
@@ -0,0 +1,500 @@
import { h } from "jsx-dom-cjs"
import { Node as ProseMirrorNode } from "@tiptap/pm/model"
import { Decoration, NodeView } from "@tiptap/pm/view"
import tippy, { Instance, Props } from "tippy.js"
import { Editor } from "@tiptap/core"
import {
CellSelection,
TableMap,
updateColumnsOnResize
} from "@tiptap/prosemirror-tables"
import icons from "./icons"
export function updateColumns(
node: ProseMirrorNode,
colgroup: HTMLElement,
table: HTMLElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: any
) {
let totalWidth = 0
let fixedWidth = true
let nextDOM = colgroup.firstChild as HTMLElement
const row = node.firstChild
if (!row) return
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth =
overrideCol === col ? overrideValue : colwidth && colwidth[j]
const cssWidth = hasWidth ? `${hasWidth}px` : ""
totalWidth += hasWidth || cellMinWidth
if (!hasWidth) {
fixedWidth = false
}
if (!nextDOM) {
colgroup.appendChild(
document.createElement("col")
).style.width = cssWidth
} else {
if (nextDOM.style.width !== cssWidth) {
nextDOM.style.width = cssWidth
}
nextDOM = nextDOM.nextSibling as HTMLElement
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling
nextDOM.parentNode?.removeChild(nextDOM)
nextDOM = after as HTMLElement
}
if (fixedWidth) {
table.style.width = `${totalWidth}px`
table.style.minWidth = ""
} else {
table.style.width = ""
table.style.minWidth = `${totalWidth}px`
}
}
const defaultTippyOptions: Partial<Props> = {
allowHTML: true,
arrow: false,
trigger: "click",
animation: "scale-subtle",
theme: "light-border no-padding",
interactive: true,
hideOnClick: true,
placement: "right"
}
function setCellsBackgroundColor(editor: Editor, backgroundColor) {
return editor
.chain()
.focus()
.updateAttributes("tableCell", {
background: backgroundColor
})
.updateAttributes("tableHeader", {
background: backgroundColor
})
.run()
}
const columnsToolboxItems = [
{
label: "Add Column Before",
icon: icons.insertColumnLeft,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run()
},
{
label: "Add Column After",
icon: icons.insertColumnRight,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run()
},
{
label: "Pick Column Color",
icon: icons.colorPicker,
action: ({ editor, triggerButton, controlsContainer }: { editor: Editor, triggerButton: HTMLElement, controlsContainer }) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color)
})
}
},
{
label: "Delete Column",
icon: icons.deleteColumn,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run()
}
]
const rowsToolboxItems = [
{
label: "Add Row Above",
icon: icons.insertRowTop,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run()
},
{
label: "Add Row Below",
icon: icons.insertRowBottom,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run()
},
{
label: "Pick a Color",
icon: icons.colorPicker,
action: ({ editor, triggerButton, controlsContainer }: { editor: Editor, triggerButton: HTMLButtonElement, controlsContainer: Element | "parent" | ((ref: Element) => Element) | undefined }) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color)
})
}
},
{
label: "Delete Row",
icon: icons.deleteRow,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteRow().run()
}
]
function createToolbox({
triggerButton,
items,
tippyOptions,
onClickItem
}: { triggerButton: HTMLElement, items: { icon: string, label: string }[], tippyOptions: any, onClickItem: any }): Instance<Props> {
const toolbox = tippy(triggerButton, {
content: h(
"div",
{ className: "tableToolbox" },
items.map((item) =>
h(
"div",
{
className: "toolboxItem",
onClick() {
onClickItem(item)
}
},
[
h("div", {
className: "iconContainer",
innerHTML: item.icon
}),
h("div", { className: "label" }, item.label)
]
)
)
),
...tippyOptions
})
return Array.isArray(toolbox) ? toolbox[0] : toolbox
}
function createColorPickerToolbox({
triggerButton,
tippyOptions,
onSelectColor = () => { }
}: {
triggerButton: HTMLElement
tippyOptions: Partial<Props>
onSelectColor?: (color: string) => void
}) {
const items = {
"Default": "rgb(var(--color-primary-100))",
"Light gray": "#e7f3f8",
"Dark gray": "#c7d2d7",
"Light blue": "#e7f3f8",
"Light red": "#ffc4c7",
"Light yellow": "#fbf3db"
}
const colorPicker = tippy(triggerButton, {
...defaultTippyOptions,
content: h(
"div",
{ className: "tableColorPickerToolbox" },
Object.entries(items).map(([key, value]) =>
h(
"div",
{
className: "toolboxItem",
onClick: () => {
onSelectColor(value)
colorPicker.hide()
}
},
[
h("div", {
className: "colorContainer",
style: {
backgroundColor: value
}
}),
h(
"div",
{
className: "label"
},
key
)
]
)
)
),
onHidden: (instance) => {
instance.destroy()
},
showOnCreate: true,
...tippyOptions
})
return colorPicker
}
export class TableView implements NodeView {
node: ProseMirrorNode
cellMinWidth: number
decorations: Decoration[]
editor: Editor
getPos: () => number
hoveredCell
map: TableMap
root: HTMLElement
table: HTMLElement
colgroup: HTMLElement
tbody: HTMLElement
rowsControl?: HTMLElement
columnsControl?: HTMLElement
columnsToolbox?: Instance<Props>
rowsToolbox?: Instance<Props>
controls?: HTMLElement
get dom() {
return this.root
}
get contentDOM() {
return this.tbody
}
constructor(
node: ProseMirrorNode,
cellMinWidth: number,
decorations: Decoration[],
editor: Editor,
getPos: () => number
) {
this.node = node
this.cellMinWidth = cellMinWidth
this.decorations = decorations
this.editor = editor
this.getPos = getPos
this.hoveredCell = null
this.map = TableMap.get(node)
/**
* DOM
*/
// Controllers
if (editor.isEditable) {
this.rowsControl = h(
"div",
{ className: "rowsControl" },
h("button", {
onClick: () => this.selectRow()
})
)
this.columnsControl = h(
"div",
{ className: "columnsControl" },
h("button", {
onClick: () => this.selectColumn()
})
)
this.controls = h(
"div",
{ className: "tableControls", contentEditable: "false" },
this.rowsControl,
this.columnsControl
)
this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector("button"),
items: columnsToolboxItems,
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls
},
onClickItem: (item) => {
item.action({
editor: this.editor,
triggerButton: this.columnsControl?.firstElementChild,
controlsContainer: this.controls
})
this.columnsToolbox?.hide()
}
})
this.rowsToolbox = createToolbox({
triggerButton: this.rowsControl.firstElementChild,
items: rowsToolboxItems,
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls
},
onClickItem: (item) => {
item.action({
editor: this.editor,
triggerButton: this.rowsControl?.firstElementChild,
controlsContainer: this.controls
})
this.rowsToolbox?.hide()
}
})
}
// Table
this.colgroup = h(
"colgroup",
null,
Array.from({ length: this.map.width }, () => 1).map(() => h("col"))
)
this.tbody = h("tbody")
this.table = h("table", null, this.colgroup, this.tbody)
this.root = h(
"div",
{
className: "tableWrapper controls--disabled"
},
this.controls,
this.table
)
this.render()
}
update(node: ProseMirrorNode, decorations) {
if (node.type !== this.node.type) {
return false
}
this.node = node
this.decorations = decorations
this.map = TableMap.get(this.node)
if (this.editor.isEditable) {
this.updateControls()
}
this.render()
return true
}
render() {
if (this.colgroup.children.length !== this.map.width) {
const cols = Array.from({ length: this.map.width }, () => 1).map(
() => h("col")
)
this.colgroup.replaceChildren(...cols)
}
updateColumnsOnResize(
this.node,
this.colgroup,
this.table,
this.cellMinWidth
)
}
ignoreMutation() {
return true
}
updateControls() {
const { hoveredTable: table, hoveredCell: cell } = Object.values(
this.decorations
).reduce((acc, curr) => {
if (curr.spec.hoveredCell !== undefined) {
acc["hoveredCell"] = curr.spec.hoveredCell
}
if (curr.spec.hoveredTable !== undefined) {
acc["hoveredTable"] = curr.spec.hoveredTable
}
return acc
}, {}) as any
if (table === undefined || cell === undefined) {
return this.root.classList.add("controls--disabled")
}
this.root.classList.remove("controls--disabled")
this.hoveredCell = cell
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement
const tableRect = this.table.getBoundingClientRect()
const cellRect = cellDom.getBoundingClientRect()
this.columnsControl.style.left = `${cellRect.left -
tableRect.left -
this.table.parentElement!.scrollLeft
}px`
this.columnsControl.style.width = `${cellRect.width}px`
this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`
this.rowsControl.style.height = `${cellRect.height}px`
}
selectColumn() {
if (!this.hoveredCell) return
const colIndex = this.map.colCount(
this.hoveredCell.pos - (this.getPos() + 1)
)
const anchorCellPos = this.hoveredCell.pos
const headCellPos =
this.map.map[colIndex + this.map.width * (this.map.height - 1)] +
(this.getPos() + 1)
const cellSelection = CellSelection.create(
this.editor.view.state.doc,
anchorCellPos,
headCellPos
)
this.editor.view.dispatch(
// @ts-ignore
this.editor.state.tr.setSelection(cellSelection)
)
}
selectRow() {
if (!this.hoveredCell) return
const anchorCellPos = this.hoveredCell.pos
const anchorCellIndex = this.map.map.indexOf(
anchorCellPos - (this.getPos() + 1)
)
const headCellPos =
this.map.map[anchorCellIndex + (this.map.width - 1)] +
(this.getPos() + 1)
const cellSelection = CellSelection.create(
this.editor.state.doc,
anchorCellPos,
headCellPos
)
this.editor.view.dispatch(
// @ts-ignore
this.editor.view.state.tr.setSelection(cellSelection)
)
}
}
@@ -0,0 +1,298 @@
import { TextSelection } from "@tiptap/pm/state"
import { callOrReturn, getExtensionField, mergeAttributes, Node, ParentConfig } from "@tiptap/core"
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
CellSelection,
columnResizing,
deleteColumn,
deleteRow,
deleteTable,
fixTables,
goToNextCell,
mergeCells,
setCellAttr,
splitCell,
tableEditing,
toggleHeader,
toggleHeaderCell
} from "@tiptap/prosemirror-tables"
import { tableControls } from "./table-controls"
import { TableView } from "./table-view"
import { createTable } from "./utilities/create-table"
import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected"
export interface TableOptions {
HTMLAttributes: Record<string, any>
resizable: boolean
handleWidth: number
cellMinWidth: number
lastColumnResizable: boolean
allowTableNodeSelection: boolean
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
table: {
insertTable: (options?: {
rows?: number
cols?: number
withHeaderRow?: boolean
}) => ReturnType
addColumnBefore: () => ReturnType
addColumnAfter: () => ReturnType
deleteColumn: () => ReturnType
addRowBefore: () => ReturnType
addRowAfter: () => ReturnType
deleteRow: () => ReturnType
deleteTable: () => ReturnType
mergeCells: () => ReturnType
splitCell: () => ReturnType
toggleHeaderColumn: () => ReturnType
toggleHeaderRow: () => ReturnType
toggleHeaderCell: () => ReturnType
mergeOrSplit: () => ReturnType
setCellAttribute: (name: string, value: any) => ReturnType
goToNextCell: () => ReturnType
goToPreviousCell: () => ReturnType
fixTables: () => ReturnType
setCellSelection: (position: {
anchorCell: number
headCell?: number
}) => ReturnType
}
}
interface NodeConfig<Options, Storage> {
tableRole?:
| string
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options>>["tableRole"]
}) => string)
}
}
export default Node.create({
name: "table",
addOptions() {
return {
HTMLAttributes: {},
resizable: true,
handleWidth: 5,
cellMinWidth: 100,
lastColumnResizable: true,
allowTableNodeSelection: true
}
},
content: "tableRow+",
tableRole: "table",
isolating: true,
group: "block",
allowGapCursor: false,
parseHTML() {
return [{ tag: "table" }]
},
renderHTML({ HTMLAttributes }) {
return [
"table",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
["tbody", 0]
]
},
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true} = {}) =>
({ tr, dispatch, editor }) => {
const node = createTable(
editor.schema,
rows,
cols,
withHeaderRow
)
if (dispatch) {
const offset = tr.selection.anchor + 1
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(
TextSelection.near(tr.doc.resolve(offset))
)
}
return true
},
addColumnBefore:
() =>
({ state, dispatch }) => addColumnBefore(state, dispatch),
addColumnAfter:
() =>
({ state, dispatch }) => addColumnAfter(state, dispatch),
deleteColumn:
() =>
({ state, dispatch }) => deleteColumn(state, dispatch),
addRowBefore:
() =>
({ state, dispatch }) => addRowBefore(state, dispatch),
addRowAfter:
() =>
({ state, dispatch }) => addRowAfter(state, dispatch),
deleteRow:
() =>
({ state, dispatch }) => deleteRow(state, dispatch),
deleteTable:
() =>
({ state, dispatch }) => deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) => mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) => splitCell(state, dispatch),
toggleHeaderColumn:
() =>
({ state, dispatch }) => toggleHeader("column")(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) => toggleHeader("row")(state, dispatch),
toggleHeaderCell:
() =>
({ state, dispatch }) => toggleHeaderCell(state, dispatch),
mergeOrSplit:
() =>
({ state, dispatch }) => {
if (mergeCells(state, dispatch)) {
return true
}
return splitCell(state, dispatch)
},
setCellAttribute:
(name, value) =>
({ state, dispatch }) => setCellAttr(name, value)(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) => goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) => goToNextCell(-1)(state, dispatch),
fixTables:
() =>
({ state, dispatch }) => {
if (dispatch) {
fixTables(state)
}
return true
},
setCellSelection:
(position) =>
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(
tr.doc,
position.anchorCell,
position.headCell
)
// @ts-ignore
tr.setSelection(selection)
}
return true
}
}
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.commands.goToNextCell()) {
return true
}
if (!this.editor.can().addRowAfter()) {
return false
}
return this.editor.chain().addRowAfter().goToNextCell().run()
},
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
Backspace: deleteTableWhenAllCellsSelected,
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected
}
},
addNodeView() {
return ({ editor, getPos, node, decorations }) => {
const { cellMinWidth } = this.options
return new TableView(
node,
cellMinWidth,
decorations,
editor,
getPos as () => number
)
}
},
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable
const plugins = [
tableEditing({
allowTableNodeSelection: this.options.allowTableNodeSelection
}),
tableControls()
]
if (isResizable) {
plugins.unshift(
columnResizing({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
// View: TableView,
// @ts-ignore
lastColumnResizable: this.options.lastColumnResizable
})
)
}
return plugins
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage
}
return {
tableRole: callOrReturn(
getExtensionField(extension, "tableRole", context)
)
}
}
})
@@ -0,0 +1,12 @@
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"
export function createCell(
cellType: NodeType,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
): ProsemirrorNode | null | undefined {
if (cellContent) {
return cellType.createChecked(null, cellContent)
}
return cellType.createAndFill()
}
@@ -0,0 +1,45 @@
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"
import { createCell } from "./create-cell"
import { getTableNodeTypes } from "./get-table-node-types"
export function createTable(
schema: Schema,
rowsCount: number,
colsCount: number,
withHeaderRow: boolean,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
): ProsemirrorNode {
const types = getTableNodeTypes(schema)
const headerCells: ProsemirrorNode[] = []
const cells: ProsemirrorNode[] = []
for (let index = 0; index < colsCount; index += 1) {
const cell = createCell(types.cell, cellContent)
if (cell) {
cells.push(cell)
}
if (withHeaderRow) {
const headerCell = createCell(types.header_cell, cellContent)
if (headerCell) {
headerCells.push(headerCell)
}
}
}
const rows: ProsemirrorNode[] = []
for (let index = 0; index < rowsCount; index += 1) {
rows.push(
types.row.createChecked(
null,
withHeaderRow && index === 0 ? headerCells : cells
)
)
}
return types.table.createChecked(null, rows)
}
@@ -0,0 +1,39 @@
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"
import { isCellSelection } from "./is-cell-selection"
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({
editor
}) => {
const { selection } = editor.state
if (!isCellSelection(selection)) {
return false
}
let cellCount = 0
const table = findParentNodeClosestToPos(
selection.ranges[0].$from,
(node) => node.type.name === "table"
)
table?.node.descendants((node) => {
if (node.type.name === "table") {
return false
}
if (["tableCell", "tableHeader"].includes(node.type.name)) {
cellCount += 1
}
})
const allCellsSelected = cellCount === selection.ranges.length
if (!allCellsSelected) {
return false
}
editor.commands.deleteTable()
return true
}
@@ -0,0 +1,21 @@
import { NodeType, Schema } from "prosemirror-model"
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
if (schema.cached.tableNodeTypes) {
return schema.cached.tableNodeTypes
}
const roles: { [key: string]: NodeType } = {}
Object.keys(schema.nodes).forEach((type) => {
const nodeType = schema.nodes[type]
if (nodeType.spec.tableRole) {
roles[nodeType.spec.tableRole] = nodeType
}
})
schema.cached.tableNodeTypes = roles
return roles
}
@@ -0,0 +1,5 @@
import { CellSelection } from "@tiptap/prosemirror-tables"
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection
}
@@ -0,0 +1,69 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { useDebouncedCallback } from "use-debounce";
import { DeleteImage } from '../../types/delete-image';
import { CoreEditorProps } from "../props";
import { CoreEditorExtensions } from "../extensions";
import { EditorProps } from '@tiptap/pm/view';
import { getTrimmedHTML } from "../../lib/utils";
import { UploadImage } from "../../types/upload-image";
const DEBOUNCE_DELAY = 1500;
interface CustomEditorProps {
uploadFile: UploadImage;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
value: string;
deleteFile: DeleteImage;
debouncedUpdatesEnabled?: boolean;
onChange?: (json: any, html: string) => void;
extensions?: any;
editorProps?: EditorProps;
forwardedRef?: any;
}
export const useEditor = ({ uploadFile, deleteFile, editorProps = {}, value, extensions = [], onChange, setIsSubmitting, debouncedUpdatesEnabled, forwardedRef, setShouldShowAlert, }: CustomEditorProps) => {
const editor = useCustomEditor({
editorProps: {
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");
setShouldShowAlert?.(true);
if (debouncedUpdatesEnabled) {
debouncedUpdates({ onChange: onChange, editor });
} else {
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
}
},
});
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
},
}));
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
if (onChange) {
onChange(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
}
}, DEBOUNCE_DELAY);
if (!editor) {
return null;
}
return editor;
};
@@ -0,0 +1,43 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
import { EditorProps } from '@tiptap/pm/view';
interface CustomReadOnlyEditorProps {
value: string;
forwardedRef?: any;
extensions?: any;
editorProps?: EditorProps;
}
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {} }: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor({
editable: false,
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps,
...editorProps,
},
extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
});
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
},
}));
if (!editor) {
return null;
}
return editor;
};
+92
View File
@@ -0,0 +1,92 @@
"use client"
import * as React from 'react';
import { Extension } from "@tiptap/react";
import { UploadImage } from '../types/upload-image';
import { DeleteImage } from '../types/delete-image';
import { getEditorClassNames } from '../lib/utils';
import { EditorProps } from '@tiptap/pm/view';
import { useEditor } from './hooks/useEditor';
import { EditorContainer } from '../ui/components/editor-container';
import { EditorContentWrapper } from '../ui/components/editor-content';
interface ICoreEditor {
value: string;
uploadFile: UploadImage;
deleteFile: DeleteImage;
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
editable?: boolean;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
accessValue: string;
onAccessChange: (accessKey: string) => void;
commentAccess: {
icon: string;
key: string;
label: "Private" | "Public";
}[];
extensions?: Extension[];
editorProps?: EditorProps;
}
interface EditorCoreProps extends ICoreEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
interface EditorHandle {
clearEditor: () => void;
setEditorValue: (content: string) => void;
}
const CoreEditor = ({
onChange,
debouncedUpdatesEnabled,
editable,
setIsSubmitting,
setShouldShowAlert,
editorContentCustomClassNames,
value,
uploadFile,
deleteFile,
noBorder,
borderOnFocus,
customClassName,
forwardedRef,
}: EditorCoreProps) => {
const editor = useEditor({
onChange,
debouncedUpdatesEnabled,
editable,
setIsSubmitting,
setShouldShowAlert,
value,
uploadFile,
deleteFile,
forwardedRef,
});
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
if (!editor) return null;
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
</div>
</EditorContainer >
);
};
const CoreEditorWithRef = React.forwardRef<EditorHandle, ICoreEditor>((props, ref) => (
<CoreEditor {...props} forwardedRef={ref} />
));
CoreEditorWithRef.displayName = "CoreEditorWithRef";
export { CoreEditor, CoreEditorWithRef };
@@ -0,0 +1,109 @@
import { BoldIcon, Heading1, CheckSquare, Heading2, Heading3, QuoteIcon, ImageIcon, TableIcon, ListIcon, ListOrderedIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
import { Editor } from "@tiptap/react";
import { UploadImage } from "../../../types/upload-image";
import { insertImageCommand, insertTableCommand, toggleBlockquote, toggleBold, toggleBulletList, toggleCode, toggleHeadingOne, toggleHeadingThree, toggleHeadingTwo, toggleItalic, toggleOrderedList, toggleStrike, toggleTaskList, toggleUnderline, } from "../../../lib/editor-commands";
export interface EditorMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
icon: typeof BoldIcon;
}
export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
name: "H1",
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
})
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
name: "H2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
})
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
name: "H3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
})
export const BoldItem = (editor: Editor): EditorMenuItem => ({
name: "bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
})
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
name: "italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
})
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
name: "underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
})
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
name: "strike",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
})
export const CodeItem = (editor: Editor): EditorMenuItem => ({
name: "code",
isActive: () => editor?.isActive("code"),
command: () => toggleCode(editor),
icon: CodeIcon,
})
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
name: "bullet-list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
})
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
name: "To-do List",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
})
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
name: "ordered-list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon
})
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
name: "quote",
isActive: () => editor?.isActive("quote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon
})
export const TableItem = (editor: Editor): EditorMenuItem => ({
name: "quote",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon
})
export const ImageItem = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorMenuItem => ({
name: "image",
isActive: () => editor?.isActive("image"),
command: () => insertImageCommand(editor, uploadFile, setIsSubmitting),
icon: ImageIcon,
})
@@ -0,0 +1,68 @@
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { DeleteImage } from "../../types/delete-image";
const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";
interface ImageNode extends ProseMirrorNode {
attrs: {
src: string;
id: string;
};
}
const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const removedImages: ImageNode[] = [];
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
if (oldPos < 0 || oldPos > newState.doc.content.size) return;
if (!newState.doc.resolve(oldPos).parent) return;
const newNode = newState.doc.nodeAt(oldPos);
// Check if the node has been deleted or replaced
if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) {
if (!newImageSources.has(oldNode.attrs.src)) {
removedImages.push(oldNode as ImageNode);
}
}
});
removedImages.forEach(async (node) => {
const src = node.attrs.src;
await onNodeDeleted(src, deleteImage);
});
});
return null;
},
});
export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
}
} catch (error) {
console.error("Error deleting image: ", error);
}
}
@@ -1,7 +1,6 @@
// @ts-nocheck
import { UploadImage } from "../../types/upload-image";
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import fileService from "services/file.service";
const uploadKey = new PluginKey("upload-image");
@@ -58,7 +57,7 @@ export async function startImageUpload(
file: File,
view: EditorView,
pos: number,
workspaceSlug: string,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) {
if (!file.type.includes("image/")) {
@@ -83,11 +82,8 @@ export async function startImageUpload(
view.dispatch(tr);
};
if (!workspaceSlug) {
return;
}
setIsSubmitting?.("submitting");
const src = await UploadImageHandler(file, workspaceSlug);
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
@@ -101,28 +97,30 @@ export async function startImageUpload(
view.dispatch(transaction);
}
const UploadImageHandler = (file: File, workspaceSlug: string): Promise<string> => {
if (!workspaceSlug) {
return Promise.reject("Workspace slug is missing");
}
const UploadImageHandler = (file: File,
uploadFile: UploadImage
): Promise<string> => {
try {
const formData = new FormData();
formData.append("asset", file);
formData.append("attributes", JSON.stringify({}));
return new Promise(async (resolve, reject) => {
const imageUrl = await fileService
.uploadFile(workspaceSlug, formData)
.then((response) => response.asset);
try {
const imageUrl = await uploadFile(file)
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve(imageUrl);
};
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve(imageUrl);
};
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
reject(error);
}
});
} catch (error) {
console.log(error);
if (error instanceof Error) {
console.log(error.message);
}
return Promise.reject(error);
}
};
@@ -1,9 +1,10 @@
import { EditorProps } from "@tiptap/pm/view";
import { findTableAncestor } from "../lib/utils";
import { startImageUpload } from "./plugins/upload-image";
import { findTableAncestor } from "./table-menu";
import { UploadImage } from "../types/upload-image";
export function TiptapEditorProps(
workspaceSlug: string,
export function CoreEditorProps(
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
): EditorProps {
return {
@@ -35,7 +36,7 @@ export function TiptapEditorProps(
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
startImageUpload(file, view, pos, workspaceSlug, setIsSubmitting);
startImageUpload(file, view, pos, uploadFile, setIsSubmitting);
return true;
}
return false;
@@ -57,9 +58,8 @@ export function TiptapEditorProps(
left: event.clientX,
top: event.clientY,
});
// here we deduct 1 from the pos or else the image will create an extra node
if (coordinates) {
startImageUpload(file, view, coordinates.pos - 1, workspaceSlug, setIsSubmitting);
startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting);
}
return true;
}
@@ -0,0 +1,92 @@
import StarterKit from "@tiptap/starter-kit";
import TiptapLink from "@tiptap/extension-link";
import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Gapcursor from "@tiptap/extension-gapcursor";
// import { CustomTableCell } from "../extensions/table/table-cell";
// import { Table } from "../extensions/table";
// import { TableHeader } from "../extensions/table/table-header";
// import { TableRow } from "@tiptap/extension-table-row";
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
import { isValidHttpUrl } from "../../lib/utils";
export const CoreReadOnlyEditorExtensions = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ReadOnlyImageExtension.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
// Table,
// TableHeader,
// CustomTableCell,
// TableRow,
];
@@ -0,0 +1,8 @@
import { EditorProps } from "@tiptap/pm/view";
export const CoreReadOnlyEditorProps: EditorProps =
{
attributes: {
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
},
};
+6
View File
@@ -0,0 +1,6 @@
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
module.exports = {
// prefix ui lib classes to avoid conflicting with the app
...sharedConfig,
};
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "tsconfig/react.json",
"include": [
"src/**/*",
"index.d.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/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));
@@ -0,0 +1,97 @@
# @plane/lite-text-editor
## Description
The `@plane/lite-text-editor` package extends from the `editor-core` package, inheriting its base functionality while adding its own unique features of Custom control over Enter key, etc.
## Key Features
- **Exported Components**: There are two components exported from the Lite text editor (with and without Ref), you can choose to use the `withRef` instance whenever you want to control the Editors state via a side effect of some external action from within the application code.
`LiteTextEditor` & `LiteTextEditorWithRef`
- **Read Only Editor Instances**: We have added a really light weight *Read Only* Editor instance for the Lite editor types (with and without Ref)
`LiteReadOnlyEditor` &`LiteReadOnlyEditorWithRef`
## LiteTextEditor
| Prop | Type | Description |
| --- | --- | --- |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
1. Here is an example of how to use the `RichTextEditor` component
```tsx
<LiteTextEditor
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
```
2. Example of how to use the `LiteTextEditorWithRef` component
```tsx
const editorRef = useRef<any>(null);
// can use it to set the editor's value
editorRef.current?.setEditorValue(`${watch("description_html")}`);
// can use it to clear the editor
editorRef?.current?.clearEditor();
return (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
ref={editorRef}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
)
```
## LiteReadOnlyEditor
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `html string` | The initial content of the editor. |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
Here is an example of how to use the `RichReadOnlyEditor` component
```tsx
<LiteReadOnlyEditor
value={comment.comment_html}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
```
@@ -0,0 +1,63 @@
{
"name": "@plane/lite-text-editor",
"version": "0.0.1",
"description": "Package that powers Plane's Comment Editor",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"files": [
"dist/**/*"
],
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"module": "./dist/index.mjs"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"next": "12.3.2",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "18.2.0"
},
"dependencies": {
"@plane/editor-core": "*",
"@tiptap/extension-list-item": "^2.1.11",
"@types/node": "18.15.3",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eventsource-parser": "^0.1.0",
"lowlight": "^2.9.0",
"lucide-react": "^0.244.0",
"react-markdown": "^8.0.7",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.2",
"use-debounce": "^9.0.4"
},
"devDependencies": {
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"tailwind-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "4.9.5"
},
"keywords": [
"editor",
"rich-text",
"markdown",
"nextjs",
"react"
]
}
@@ -0,0 +1,9 @@
// If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -0,0 +1,2 @@
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
@@ -0,0 +1,9 @@
import ListItem from '@tiptap/extension-list-item'
export const CustomListItem = ListItem.extend({
addKeyboardShortcuts() {
return {
'Shift-Enter': () => this.editor.chain().focus().splitListItem('listItem').run(),
}
},
})

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