Compare commits

..
Author SHA1 Message Date
pablohashescobar 6c6aa3b0d5 chore: separate out project feature model 2024-01-29 14:13:42 +05:30
guru_sainathandGitHub 532da80375 fix: handled attachment upload filename size error in the issue detail (#3485)
* fix: handled attachment upload filename size error in the issue detail

* ui: profile detail sidebar border
2024-01-28 18:30:53 +05:30
212f2b54f8 chore: issue relation modal and issue peek overview mutation fix (#3482)
* fix: resolve addtocycle and addtomodule mutation in peek overview and issue sidebar

* fix: issue relation modal fix for all issues peek overview

* fix: cycle and module mutation in issue detail and issue peek overview

* fix: updated the issue actions for cycle and module mutation in peek overview

* chore: module issue store updated

* chore: existing isssue modal improvement and build error fix

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-01-27 15:20:36 +05:30
Anmol Singh BhatiaandGitHub 9ecdcc6fde chore: spreadsheet layout improvement (#3483)
* chore: spreadsheet layout improvement

* chore: spreadsheet layout improvement

* chore: spreadsheet layout improvement
2024-01-27 15:18:42 +05:30
rahulrameshaandGitHub ddae745669 fix exception in member list (#3479) 2024-01-25 19:50:34 +05:30
e78c1f2060 Fix/inbox issue bugs (#3477)
* fix: inbox pending_issue_count updation from the store

* fix: inbox list item overflow issue on issue title

* fix: inbox issue mutation

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-01-25 19:20:02 +05:30
Manish GuptaandGitHub ebc891b985 fix: one click install fixes (#3476)
* test 1

* test 2

* test 3

* test 4

* minor fix

* installer script created

* minor fix

* installer modified

* cleanup
2024-01-25 18:21:21 +05:30
60b5589c48 chore: archived issues restructure. (#3469)
* chore: archived issues restructure.

* fix issue detail functionalities

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2024-01-25 18:09:01 +05:30
Prateek ShouryaandGitHub f8208b1b5e Fix/empty state flicker (#3475)
* fix: flicker issue between loader and empty states.

* chore: fix `All Issues` tab highlight when we switch between tabs inside all issues.
2024-01-25 18:08:21 +05:30
Anmol Singh BhatiaandGitHub 6c6b764421 chore: empty state and project active cycle improvement (#3472)
* chore: pages empty state improvement

* chore: workspace all issues empty state improvement

* chore: profile issue empty state improvement

* chore: empty state sm size updated

* chore: project view empty state image updated

* chore: dashboard widgets permission uodated

* chore: draft issues and project issue empty state image

* chore: active cycle label updated
2024-01-25 18:00:45 +05:30
Manish GuptaandGitHub 5c912b8821 feat: One Click Deployment (#3474)
* wip

* 1-click install script added
2024-01-25 17:13:30 +05:30
NikhilandGitHub ff19980502 chore: update python version (#3471) 2024-01-25 16:39:19 +05:30
a1a24e4574 fix: pre release bug fixes (#3473)
* clear store on signout

* fix: project member list response change

* fix adding member to project

* fix exceptions with invitations

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-01-25 16:38:34 +05:30
guru_sainathandGitHub c1c2a6ddce fix: removing the issue from the issue root store while we are deleting from the issue bulk delete modal (#3470) 2024-01-25 16:37:59 +05:30
guru_sainathandGitHub ec3cad1f25 chore: applying query params to the global issues filters in the global views (#3464)
* chore: applying filters from the route params to the global issue filters store and Typos

* chore: enabled posthog

* fix: labels disbaled and loader while creating the label in isse detail and relation modal loader and mutation issue
2024-01-25 14:27:35 +05:30
Anmol Singh BhatiaandGitHub 336c97d336 chore: bug fixes and ui improvements (#3468)
* chore: empty state improvement

* chore: app sidebar improvement

* chore: dashboard empty state improvement
2024-01-25 14:23:55 +05:30
Aaryan KhandelwalandGitHub e4a3d0db5c fix: addIssue function logic (#3467) 2024-01-25 14:09:52 +05:30
Anmol Singh BhatiaandGitHub 7f2e99dd2d fix: resolve module and cycle mutation issues in peek overview, issue sidebar and empty state (#3466)
* fix: resolve module and cycle mutation issues in peek overview, sidebar, and empty state

* chore: code refactor
2024-01-25 14:06:03 +05:30
Prateek ShouryaandGitHub a104cc4814 chore: remove deprecated components related to issue activity/ comments. (#3465) 2024-01-25 14:04:38 +05:30
03cbad5110 fix: inbox issue bug fixes and improvements. (#3460)
* style: fix create comment card overflow issue.

* chore: improved loader and filter selection logic.

* chore: implement inbox issue navigation functionality.

* chore: loaders in inbox issue sidebar and the content root

* chore: inbox issue detail sidebar revamp.

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-01-25 13:41:02 +05:30
Aaryan KhandelwalandGitHub 2956c43ed5 fix: issues list modal not closing on escape key (#3463) 2024-01-25 13:30:44 +05:30
Aaryan KhandelwalandGitHub eae32593cb chore: added optional tooltip to dropdowns (#3462) 2024-01-25 13:29:56 +05:30
Anmol Singh BhatiaandGitHub 7fd625e0e3 fix: project pages loader (#3461) 2024-01-25 12:38:55 +05:30
Bavisetti NarayanandGitHub f007dcff26 fix: page archive and unarchive (#3459) 2024-01-25 00:48:18 +05:30
adf091fa07 fix: email notifications (#3457)
* fix: email-template design

* fix: priority and state new value

* dev: update template with comments, cta text and view issue button.

* dev: fix priority and state

* dev: update data condition

* fix: added avatar url

* fix: comment avatar url

* fix: priority, labels, state design

* style: assignee property

* dev: fix template for comments and profile changes

* fix: spacing between properties

* fix: todo image for state change

* fix: blocking and blocked by value change

* dev: update template summsary

* fix: blocking, duplicate

* fix: comments spacing

* chore: improve `state change` checkbox logic.

* fix: email notification message change

* fix: updated date format

* fix: updates text color

* fix: labels sequence rendering

* fix: schedular time change

---------

Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2024-01-24 20:34:32 +05:30
b66f07845a chore: inbox issue restructure the components and store (#3456)
* chore: inbox-issues store and type updates

* chore: issue inbox payload change for GET and POST

* chore: issue inbox payload change for PATCH

* chore: inbox-issue new hooks and store updates

* chore: update inbox issue template.

* chore: UI root

* chore: sidebar issues render

* chore: inbox issue details page layout.

* chore: inbox issue filters

* chore: inbox issue status card.

* chore: add loader.

* chore: active inbox issue styles.

* chore: inbox filters

* chore: inbox applied filters UI

* chore: inbox issue approval header

* chore: inbox issue approval header operations

* chore: issue reaction and activity fetch in issue_inbox store

* chore: posthog enabled

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2024-01-24 20:33:54 +05:30
rahulrameshaandGitHub 911211cf3d Chore: change kanban layout and issue activity fixes (#3458)
* change back the to kanban to enable full board scrolling

* fix adding context to activity
2024-01-24 20:32:13 +05:30
Aaryan KhandelwalandGitHub 53b41481a2 chore: dashboard empty states, fetching logic (#3455)
* chore: remove one-time fetching

* chore: update empty states

* chore: updated icons

* chore: empty state content
2024-01-24 19:41:02 +05:30
9d9d703c62 chore: workspace active cycles upgrade page implementation (#3454)
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-01-24 19:37:49 +05:30
Aaryan KhandelwalandGitHub a2f34e9573 style: peek overview and issue details properties (#3447)
* style: peek overview and issue details properties

* fix: cycle and module remove function

* style: update placeholder text color

* fix: relation constant

* chore: added todos to fix later
2024-01-24 19:21:59 +05:30
Aaryan KhandelwalandGitHub 81f84f24f7 fix: outsideClickDetector not working on the date dropdown (#3450) 2024-01-24 19:13:38 +05:30
Anmol Singh BhatiaandGitHub 87f39d7372 chore: empty state revamp and loader improvement (#3448)
* chore: empty state asset added

* chore: empty state asset updated and image path helper function added

* chore: empty state asset updated

* chore: empty state asset updated and empty state details constant added

* chore: empty state component, helper function and comicbox button added

* chore: draft, archived and project issue empty state

* chore: cycle, module and issue layout empty state

* chore: analytics, dashboard, all issues, pages and project view empty state

* chore:projects empty state

* chore:projects empty state improvement

* chore: cycle, module, view and page loader improvement

* chore: code refactor
2024-01-24 19:12:54 +05:30
Henit ChobisaandGitHub 1a1594e818 chore: Removed Issue Embeds from the Document Editor (#3449)
* fix: issue embed going to next line when selected on slash commands

* fix: issue suggestions selecting next embed on arrow down

* fix: pages crashing, because of incorrect data type

* chore: removed issue embeds from document editor and page interface

* fix: upgraded issue widget card to show only Placeholder

* fix: pricing url changes for issue embed placeholder

* fix: build errors
2024-01-24 19:12:36 +05:30
M. PalanikannanandGitHub 8d3ea5bb3e fix: delete and restore for dynamic urls for minio hosted images fix… (#3452)
* feat: delete and restore for dynamic urls for minio hosted images fixed in spaces

* feat: delete and restore images calls fixed for web
2024-01-24 18:56:19 +05:30
rahulrameshaandGitHub 6a2be6afc4 fix: archived issues and minor bug fixes (#3451)
* make computedFn without optional arguments

* fix archived issues

* fix activity changes with proper context

* fix display filters that require server side filtering
2024-01-24 18:50:54 +05:30
Henit ChobisaandGitHub 338d58f79d fix: fixed checklist scrolling on click (#3453) 2024-01-24 18:49:44 +05:30
M. PalanikannanandGitHub e23e4bc392 fix: inline codes now exitable with right arrow key and inclusive (#3446)
* fix: inline codes now exitable with right arrow key and inclusive

* regression: toggle inline code on bubble menu selection

* feat: added different code block behaviour on selection and not selection

* fix: blockquote toggling and isActive state fixed
2024-01-24 14:59:48 +05:30
Prateek ShouryaandGitHub c1598c3d38 fix: input field value not changing in delete import modal. (#3445) 2024-01-24 13:16:46 +05:30
4a436eeee2 fix: project exports (#3441)
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-01-23 22:32:53 +05:30
rahulrameshaandGitHub 47681fe9f8 fix: minor bug fixes and quality of life improvements (#3444)
* add concurrency to dev command to avaoid erroring out

* add context to issue activity

* minor quality of life improvement for exporter modal

* show the option to save draft issue only when there is content in name and description

* maintain commonality while referencing the user in activity

* fix minor changes in draft save issue modal logical condition

* minor change is state component for filter selection

* change logic for create issue activity

* change use last draft issue button to state control over previous on hover as that was inconsistent
2024-01-23 20:45:44 +05:30
f27efb80e1 dev: email notifications (#3421)
* dev: create email notification preference model

* dev: intiate models

* dev: user notification preferences

* dev: create notification logs for the user.

* dev: email notification stacking and sending logic

* feat: email notification preference settings page.

* dev: delete subscribers

* dev: issue update ui implementation in email notification

* chore: integrate email notification endpoint.

* chore: remove toggle switch.

* chore: added labels part

* fix: refactored base design with tables

* dev: email notification templates

* dev: template updates

* dev: update models

* dev: update template for labels and new migrations

* fix: profile settings preference sidebar.

* dev: update preference endpoints

* dev: update the schedule to 5 minutes

* dev: update template with priority data

* dev: update templates

* chore: enable `issue subscribe` button for all users.

* chore: notification handling for external api

* dev: update origin request

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com>
Co-authored-by: Ramesh Kumar Chandra <rameshkumar2299@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-01-23 17:49:22 +05:30
guru_sainathandGitHub c1e1b81b99 chore: sub issue mutation for state and project change (#3442)
* chore: sub-issues mutation

* chore: posthog un commented
2024-01-23 16:56:22 +05:30
sriram veeraghantaandGitHub d9db765ae3 chore: updating package json version (#3443)
* chore: updating package json version

* chore: update package json in apiserver
2024-01-23 16:37:51 +05:30
Aaryan KhandelwalandGitHub 0531dc3308 chore: fix authorization for new projects in the peek overview (#3439)
* chore: fix authorization for new projects in the peek overview

* fix: prjects empty state authorization

* fix: peek overview auth
2024-01-23 16:16:55 +05:30
e36b7a5ab9 fix: Kanban related issues (#3436)
* fix for drag and drop issues

* add horizontal scroll for kanban

* fix all issues quick action overlap

---------

Co-authored-by: Rahul R <rahul.ramesha@plane.so>
2024-01-23 16:09:37 +05:30
Anmol Singh BhatiaandGitHub c6b756d918 chore: module and cycle bug fixes (#3435)
* chore: project active cycle improvement

* chore: cycle and module add existing mutation fix
2024-01-23 15:57:26 +05:30
sriram veeraghanta f3ae57bc85 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-01-23 15:56:29 +05:30
Manish GuptaandGitHub 2374161030 Sync Action Modified (#3437) 2024-01-23 15:53:07 +05:30
NikhilandGitHub 512ad83c08 dev: migration check (#3440)
* dev: wait for  migrations script for beat, worker, takeoff

* dev: update docker compose and migrator commands

* dev: migrator commands for self hosted setup

* dev: add recursive flag for chown
2024-01-23 15:07:45 +05:30
Aaryan KhandelwalandGitHub 801f75f406 chore: drop-downs improvements and bug fixes (#3433)
* chore: dropdowns should close on selecting an option

* style: @plane/ui dropdown styling

* refactor: @plane/ui dropdowns

* fix: build errors

* fix: list layout dropdowns positioning

* fix: priority dropdown text in dark mode
2024-01-23 14:25:09 +05:30
f88109ef04 chore: issue activity, comments, and comment reaction store and component restructure (#3428)
* fix: issue activity and comment change

* chore: posthog enabled

* chore: comment creation in activity

* chore: comment crud in store mutation

* fix: issue activity/ comments `disable` and `showAccessSpecifier` logic.

* chore: comment reaction serializer change

* conflicts: merge conflicts resolved

* conflicts: merge conflicts resolved

* chore: add issue activity/ comments to peek-overview.
* imporve `showAccessIdentifier` logic.

* chore: remove quotes from issue activity.

* chore: use `projectLabels` instead of `workspaceLabels` in labels activity.

* fix: project publish `is_deployed` not updating bug.

* cleanup

* fix: posthog enabled

* fix: typos and the comment endpoint updates

* fix: issue activity icons update

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2024-01-23 13:28:58 +05:30
Anmol Singh BhatiaandGitHub bb50df0dff chore: bug fixes and improvement (#3434)
* fix: peek overview issue delete bug

* chore: create cycle workflow improvement

* chore: project setting improvement
2024-01-23 13:11:39 +05:30
Aaryan KhandelwalandGitHub 2986769f28 fix: bugs in dashboard widgets (#3420)
* fix: bugs in dashboard widgets

* chore: updated view all issues button

* refactor: make use of getWidgetDetails and getWidgetStats computed functions

* fix: widgets redirection

* fix: build errors
2024-01-22 20:50:30 +05:30
fd5326dec6 chore: project cycle bug fixes and improvement (#3427)
* chore: burndown chart's completed at changes

* chore: project cycle bug fixes and improvement

* chore: cycle state constant updated

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-01-22 20:42:09 +05:30
49452a68ab ♻️ fix: Added new drag handle hide behavior to Pages (#3426)
- Removed unused `switchLinkView` function in `PageRenderer`
- Added `hideDragHandle` prop to `PageRenderer` component
- Updated `EditorContainer` component in `PageRenderer` to include `hideDragHandle` prop
- Removed unused code and variables in `DocumentEditor` component
- Added `hideDragHandleOnMouseLeave` state variable in `DocumentEditor` component
- Added `setHideDragHandleFunction` to set the hideDragHandle function from the DragAndDrop extension
- Passed `hideDragHandleOnMouseLeave` as prop to `PageRenderer` component in `DocumentEditor`
- Removed unused code and variables in `PageDetailsPage` component
- Updated `customClassName` prop in `PageRenderer` component used in `PageDetailsPage`

chore(deps): Update dependencies

- Updated `@tippyjs/react` dependency to version 4.2.6
- Updated `mobx-react-lite` dependency to version 4.0.5
- Updated `tippy.js` dependency to version 6.3.7

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-01-22 17:47:44 +05:30
b3ac9def8d fix: issue property dropdown data flow (#3425)
* dev: workspace states and estimates

* refactor issue dropdown logic to help work properly with issues on global level

* fix: project labels response change

* fix label type

* change store computed actions to computed functions from mobx-utils

* fix: state response change

* chore: project and workspace state change

* fix state and label types

* chore: state and label serializer change

* modify state and label types

* fix dropdown reset on project id change

* fix label sort order

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Rahul R <rahul.ramesha@plane.so>
2024-01-22 17:07:32 +05:30
Prateek ShouryaandGitHub be62662bb1 chore: remove active cycle link from dashboard sidebar. (#3423) 2024-01-22 15:27:49 +05:30
sriram veeraghantaandGitHub 4eba5c115a fix: docker branch builds (#3424)
* fix: build branch fixes

* fix: build branch fixes typos

* fix: changing push branches
2024-01-22 15:27:41 +05:30
sriram veeraghanta 24442ccc9c fix: deleting old issue store 2024-01-22 13:58:34 +05:30
sriram veeraghanta e32a50fba6 fix: merge conflicts reoslved 2024-01-22 13:56:38 +05:30
Aaryan Khandelwalandsriram veeraghanta 4baf2a2f09 fix: workspace project ids computed (#3417) 2024-01-22 13:23:10 +05:30
Aaryan Khandelwalandsriram veeraghanta 577118ca02 chore: new sign-in, sign-up and forgot password workflows (#3415)
* chore: sign up workflow updated

* chore: sign in workflow updated

* refactor: folder structure

* chore: forgot password workflow

* refactor: form component props

* chore: forgot password popover for instances with smtp unconfigured

* chore: updated UX copy

* chore: update reset password link

* chore: update email placeholder
2024-01-22 13:23:10 +05:30
Nikhilandsriram veeraghanta 4a26f11e23 dev: update apiserver .env.example (#3412) 2024-01-22 13:23:10 +05:30
Facundo Martin Gordilloandsriram veeraghanta 9926e321f6 fix: Updated "deployment documentation" link (#3413) 2024-01-22 13:23:10 +05:30
Prateek Shouryaandsriram veeraghanta 7f5028a4f6 refactor: move all sidebar links to constant file. (#3414)
* chore: move all workspace, project and profile links constants into their own constants file.

* chore: sidebar menu links improvement.
2024-01-22 13:23:10 +05:30
06a7bdffd7 Improvement: High Performance MobX Integration for Pages ✈︎ (#3397)
* fix: removed parameters `workspace`, `project` & `id` from the patch calls

* feat: modified components to work with new pages hooks

* feat: modified stores

* feat: modified initial component

* feat: component implementation changes

* feat: store implementation

* refactor pages store

* feat: updated page store to perform async operations faster

* fix: added types for archive and restore pages

* feat: implemented archive and restore pages

* fix: page creating twice when form submit

* feat: updated create-page-modal

* feat: updated page form and delete page modal

* fix: create page modal not updating isSubmitted prop

* feat: list items and list view refactored for pages

* feat: refactored project-page-store for inserting computed pagesids

* chore: renamed project pages hook

* feat: added favourite pages implementation

* fix: implemented store for archived pages

* fix: project page store for recent pages

* fix: issue suggestions breaking pages

* fix: issue embeds and suggestions breaking

* feat: implemented page store and project page store in page editor

* chore: lock file changes

* fix: modified page details header to catch mobx updates instead of swr calls

* fix: modified usePage hook to fetch page details when reloaded directly on page

* fix: fixed deleting pages

* fix: removed render on props changed

* feat: implemented page store inside page details

* fix: role change in pages archives

* fix: rerending of pages on tab change

* fix: reimplementation of peek overview inside pages

* chore: typo fixes

* fix: issue suggestion widget selecting wrong issues on click

* feat: added labels in pages

* fix: deepsource errors fixed

* fix: build errors

* fix: review comments

* fix: removed swr hooks from the `usePage` store hook and refactored `issueEmbed` hook

* fix: resolved reviewed comments

---------

Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-22 13:22:09 +05:30
Bavisetti Narayanandsriram veeraghanta d3dedc8e51 fix: user profile issues (#3409) 2024-01-22 13:22:09 +05:30
d656f8e62a fix: update global issues filter and enable profile issues (#3410)
* fix all issues and fix profile issues

* minor comments update

* minor change nullish check logic

* update nullish check logic

---------

Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-22 13:22:09 +05:30
Anmol Singh Bhatiaandsriram veeraghanta 864519e770 chore: issue filter loader improvement (#3406) 2024-01-22 13:22:09 +05:30
Nikhilandsriram veeraghanta 034f0a06db fix: key validation when magic sign in (#3403) 2024-01-22 13:22:09 +05:30
dependabot[bot]sriram veeraghantadependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7263cb072c chore(deps): bump follow-redirects from 1.15.3 to 1.15.4 (#3349)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.3 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-22 13:22:09 +05:30
ea3a0362b0 fix: enable global/ all issues (#3405)
* fix global issues and views

* remove separate layouts for specific views

* add permissions to views

* fix global issues filters

---------

Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-22 13:22:09 +05:30
c9337d4a41 feat: dashboard widgets (#3362)
* fix: created dashboard, widgets and dashboard widget model

* fix: new user home dashboard

* chore: recent projects list

* chore: recent collaborators

* chore: priority order change

* chore: payload changes

* chore: collaborator's active issue count

* chore: all dashboard widgets added with services and typs

* chore: centered metric for pie chart

* chore: widget filters

* chore: created issue filter

* fix: created and assigned issues payload change

* chore: created issue payload change

* fix: date filter change

* chore: implement filters

* fix: added expansion fields

* fix: changed issue structure with relation

* chore: new issues response

* fix: project member fix

* chore: updated issue_relation structure

* chore: code cleanup

* chore: update issues response and added empty states

* fix: button text wrap

* chore: update empty state messages

* fix: filters

* chore: update dark mode empty states

* build-error: Type check in the issue relation service

* fix: issues redirection

* fix: project empty state

* chore: project member active check

* chore: project member check in state and priority

* chore: remove console logs and replace harcoded values with constants

* fix: code refactoring

* fix: key name changed

* refactor: mapping through similar components using an array

* fix: build errors

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-01-22 13:22:09 +05:30
Prateek Shouryaandsriram veeraghanta f347c1cd69 refactor: update create/update issue modal to use currently active store's create/update method. (#3395)
* refactor: update `create/update issue` modal to use currently active store's create/update method.

* chore: add condition to avoid multiple API calls if the current store is MODULE or CYCLE.

* remove: console log

* chore: update `currentStore` to `storeType`.
2024-01-22 13:21:45 +05:30
Nikhilandsriram veeraghanta 3f07c48b35 chore: update issue template to auto assign and update labels (#3404) 2024-01-22 13:21:45 +05:30
M. Palanikannanandsriram veeraghanta 04b2214bf2 🐛 fix: Hide drag handle when cursor leaves the editor container (#3401)
This fix adds support for hiding the drag handle when the cursor leaves the editor container. It improves the user experience by providing a cleaner interface and removing unnecessary visual elements especially while scrolling.

- Add `hideDragHandle` prop to `EditorContainer` component in `editor-container.tsx`.
- Implement `onMouseLeave` event handler in `EditorContainer` to invoke `hideDragHandle` function.
- Update `DragAndDrop` extension in `drag-drop.tsx` to accept a `setHideDragHandle` function as an optional parameter.
- Pass the `setHideDragHandle` function from `RichTextEditor` component to `DragAndDrop` extension in `RichTextEditorExtensions` function in `index.tsx`.
- Set `hideDragHandleOnMouseLeave` state in `RichTextEditor` component to store the `hideDragHandlerFromDragDrop` function.
- Create `setHideDragHandleFunction` callback function in `RichTextEditor` to update the `hideDragHandleOnMouseLeave` state.
- Pass `hideDragHandleOnMouseLeave` as `hideDragHandle` prop to `EditorContainer` component in `RichTextEditor`.
2024-01-22 13:21:45 +05:30
Prateek Shouryaandsriram veeraghanta 1adb38655a fix: stack integration disable button mutation issue in project settings. (#3402) 2024-01-22 13:21:45 +05:30
Manish Guptaandsriram veeraghanta 4ab64b6905 dev: self host local build for other arch cpu (#3358)
handled x86_64 along with amd64
2024-01-22 13:21:45 +05:30
sriram veeraghanta 4b0d85591e fix: updated env variables at root (#3390) 2024-01-22 13:21:45 +05:30
Erhanandsriram veeraghanta 8e2789af3e Fix self-hosting docker-compose doc link (#3389)
Fixes the link as it leads to a 404 at the moment in the documentation / readme.
2024-01-22 13:21:45 +05:30
3592feb3d7 fix error meesage on successful error message (#3387)
Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-22 13:21:45 +05:30
Nikhilandsriram veeraghanta bc6a2542f5 dev: remove slack ping (#3377) 2024-01-22 13:20:06 +05:30
Prateek Shouryaandsriram veeraghanta 5e2d93df52 fix: project views bugs related to store refactor. (#3391)
* chore: remove debounce logic to fix create/ update view modal bugs.

* fix: bug in delete views not mutating the store.

* chore: replace `Project Empty State` with `Project Views Empty State`.

* chore: add issue peek overview.

* refactor: issue update, delete actions for project views layout.
fix: issue update and delete action throwing error bug.
fix: issue quick add throwing error bug.
2024-01-22 13:19:44 +05:30
M. Palanikannanandsriram veeraghanta cce349b805 [chore]: Removed explicit dependencies and cleaned up turbo config (#3388)
* Removed explicit dependencies and cleaned up turbo config

* fix: upgrade turbo

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-01-22 13:19:44 +05:30
fadda7cf04 fix: refactor related bugs (#3384)
* fix sub issues inside issue detail

* close peek over view after opening issue detail

* fix error while opening peek overview

* fix saving project views

---------

Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-22 13:19:44 +05:30
Anmol Singh Bhatiaandsriram veeraghanta c4093d29a7 chore: filter and display properties improvement (#3382) 2024-01-22 13:19:44 +05:30
Anmol Singh Bhatiaandsriram veeraghanta 8c89e9cc01 chore: esc to close peek overview added (#3380) 2024-01-22 13:19:44 +05:30
Anmol Singh Bhatiaandsriram veeraghanta 5625a3581a fix: drag and delete issue (#3379) 2024-01-22 13:19:44 +05:30
Lakhan Bahetiandsriram veeraghanta ee387c4222 chore webhook create page removed (#3376)
* chore webhook create page removed

* fix: removed unused variables
2024-01-22 13:19:44 +05:30
6a16a98b03 chore: update in sub-issues component and property validation and issue loaders (#3375)
* fix: handled undefined issue_id in list layout

* chore: refactor peek overview and user role validation.

* chore: sub issues

* fix: sub issues state distribution changed

* chore: sub_issues implementation in issue detail page

* chore: fixes in cycle/ module layout.
* Fix progress chart
* Module issues's update/ delete.
* Peek Overview for Modules/ Cycle.
* Fix Cycle Filters not applying bug.

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-01-22 13:19:43 +05:30
sriram veeraghanta 11f84a986c chore: formatting all python files using black formatter (#3366) 2024-01-22 13:19:43 +05:30
4b0d48b290 enable peekoverview for spreadsheet and minor refactor for faster opening of the peekoverview component (#3361)
Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-22 13:19:43 +05:30
9789068880 fix: project loaders for mobx store (#3356)
* add loaders to all the dropdowns outside project wrpper

* fix build errors

* minor refactor for project states color

---------

Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-22 13:19:43 +05:30
Henit Chobisaandsriram veeraghanta 151c355177 [FIX] Pages Malfunctioning on Load and Recent Pages Computation (#3359)
* fix: fixed `usePage` hook returning context instead of IPageStore

* fix: updated recent pages with `updated_at` instead of `created_at`

* fix: thown error instead of returning empty array
2024-01-22 13:19:43 +05:30
38580c3940 Fix env substitute issue in websocket docker setup (#3296)
* fix websocket connection issue in docker makeplane/plane#3195

* fix websocket connection issue for local env with removing from the prod nginx conf template makeplane/plane#319

* fix env substitution issue  of proxy_set_header makeplane/plane#3196

* review fixes

---------

Co-authored-by: Manish Gupta <manish@mgupta.me>
2024-01-22 13:19:43 +05:30
sriram veeraghanta 7ff91fdb82 fix: create more toggle fixes in create issue modal (#3355)
* fix: create more issue bugfixes

* fix: removing all warning
2024-01-22 13:19:43 +05:30
sriram veeraghanta a679b42200 fix: create sync action (#3353)
* fix: create sync action changes

* fix: typo changes
2024-01-22 13:19:43 +05:30
M. Palanikannanandsriram veeraghanta 27762ea500 fix: inline code blocks, code blocks and links have saner behaviour (#3318)
* fix: removed backticks in inline code blocks

* added better error handling while cancelling uploads

* fix: inline code blocks, code blocks and links have saner behaviour

- Inline code blocks are now exitable, don't have backticks, have better padding vertically and better regex matching
- Code blocks on the top and bottom of the document are now exitable via Up and Down Arrow keys
- Links are now exitable while being autolinkable via a custom re-write of the tiptap-link-extension

* fix: more robust link checking
2024-01-22 13:19:43 +05:30
2cd5dbcd02 chore: Error Handling and Validation Updates (#3351)
* fix: handled undefined issue_id in list layout

* chore: updated label select dropdown in the issue detail

* fix: peekoverview issue is resolved

* chore: user role validation for issue details.

* fix: Link, Attachement, parent mutation

* build-error: build error resolved in peekoverview

* chore: user role validation for issue details.

* chore: user role validation for `issue description`, `parent`, `relation` and `subscription`.

* chore: issue subscription mutation

* chore: user role validation for `labels` in issue details.

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2024-01-22 13:19:43 +05:30
96868760a3 update swr config to not fetch everything on focus (#3350)
Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-22 13:19:43 +05:30
df97b35a99 chore: Refactor Spreadsheet view for better code maintainability and performance (#3322)
* refcator spreadsheet to use table and roow based approach rather than column based

* update spreadsheet and optimized layout

* fix issues in spread sheet

* close quick action menu on click

---------

Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-22 13:19:43 +05:30
4611ec0b83 chore: refactored and resolved build issues on the issues and issue detail page (#3340)
* fix: handled undefined issue_id in list layout

* dev: issue detail store and optimization

* dev: issue filter and list operations

* fix: typo on labels update

* dev: Handled all issues in the list layout in project issues

* dev: handled kanban and auick add issue in swimlanes

* chore: fixed peekoverview in kanban

* chore: fixed peekoverview in calendar

* chore: fixed peekoverview in gantt

* chore: updated quick add in the gantt chart

* chore: handled issue detail properties and resolved build issues

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-01-22 13:19:43 +05:30
e6b31e2550 fix: link preview editor (#3335)
* feat: added link preview plugin in document editor

* fix: readonly editor page renderer css

* fix: autolink issue with links

* chore: added floating UI

* feat: added link preview components

* feat: added floating UI to page renderer for link previews

* feat: added actionCompleteHandler to page renderer

* chore: Lock file changes

* fix: regex security error

* chore: updated radix with lucid icons

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-01-22 13:19:43 +05:30
Nikhilandsriram veeraghanta 59fb371e3d dev: fix smtp configuration (#3339) 2024-01-22 13:19:43 +05:30
Nikhilandsriram veeraghanta 23e5306f6d dev: update the instance urls (#3329) 2024-01-22 13:19:43 +05:30
0f99fb302b chore: modal and dropdown improvement (#3332)
* dev: dropdown key down custom hook added

* chore: plane ui dropdowns updated

* chore: cycle and module tab index added in modals

* chore: view and page tab index added in modals

* chore: issue modal tab indexing added

* chore: project modal tab indexing added

* fix: build fix

* build-error: build error in pages new structure and reverted back to old page structure

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-01-22 13:19:43 +05:30
Manish Guptaandsriram veeraghanta 0e49d616b7 fixes web container public assets (#3336) 2024-01-22 13:19:43 +05:30
Nikhilandsriram veeraghanta e72920d33e fix: update jira summary endpoints (#3333)
* dev: update jira summary endpoints

* dev: update jira project key validations

* dev: updated key length
2024-01-22 13:19:43 +05:30
Nikhilandsriram veeraghanta 80dc38b649 fix: jira importer validations (#3323)
* fix: jira importer validations

* dev: update validation for cloud hostname

* dev: update the function to be used externally

* dev: update codeql workflow

* dev: update repository selection api
2024-01-22 13:19:43 +05:30
Nikhilandsriram veeraghanta 43b503c756 fix: security warnings related to information exposure and regex validations (#3325) 2024-01-22 13:19:43 +05:30
dependabot[bot]sriram veeraghantadependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
68d370fd86 chore(deps): bump tj-actions/changed-files in /.github/workflows (#3327)
Bumps [tj-actions/changed-files](https://github.com/tj-actions/changed-files) from 38 to 41.
- [Release notes](https://github.com/tj-actions/changed-files/releases)
- [Changelog](https://github.com/tj-actions/changed-files/blob/main/HISTORY.md)
- [Commits](https://github.com/tj-actions/changed-files/compare/v38...v41)

---
updated-dependencies:
- dependency-name: tj-actions/changed-files
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-22 13:19:43 +05:30
Bavisetti Narayanandsriram veeraghanta 5e03f3dd82 chore: mobile configs (#3328)
* chore: mobile configs

* chore: mobile configurations changed

* chore: removed the slack id

* chore: reversed google client id
2024-01-22 13:19:43 +05:30
1257a88089 fix: breaking cycle issues and replacing router.push with Links (#3330)
* fix cycle creation and active cycle map

* minor fix in cycle store

* create cycle breaking fix

* replace last possible bits of router.push with Link

---------

Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-22 13:19:43 +05:30
Prateek Shouryaandsriram veeraghanta 12a3392722 fix: estimate order not maintained in create/ update modal. (#3326)
* fix: estimate order not maintained in create/ update modal.

* fix: estimate points mutation on update.
2024-01-22 13:19:43 +05:30
sriram veeraghanta b62a1b11b1 fix: pages store structure changes 2024-01-22 13:19:43 +05:30
Anmol Singh Bhatiaandsriram veeraghanta 0a05aef046 fix: workspace invitations response updated (#3321) 2024-01-22 13:19:43 +05:30
efd3ebf067 chore: bug fixes and improvement (#3303)
* refactor: updated preloaded function for the list view quick add

* fix: resolved bug in the assignee dropdown

* chore: issue sidebar link improvement

* fix: resolved subscription store bug

* chore: updated preloaded function for the kanban layout quick add

* chore: resolved issues in the list filters and component

* chore: filter store updated

* fix: issue serializer changed

* chore: quick add preload function updated

* fix: build error

* fix: serializer changed

* fix: minor request change

* chore: resolved build issues and updated the prepopulated data in the quick add issue.

* fix: build fix and code refactor

* fix: spreadsheet layout quick add fix

* fix: issue peek overview link section updated

* fix: cycle status bug fix

* fix: serializer changes

* fix: assignee and labels listing

* chore: issue modal parent_id default value updated

* fix: cycle and module issue serializer change

* fix: cycle list serializer changed

* chore: prepopulated validation in both list and kanban for quick add and group header add issues

* chore: group header validation added

* fix: issue response payload change

* dev: make cycle and module issue create response simillar

* chore: custom control link component added

* dev: make issue create and update response simillar to list and retrieve

* fix: build error

* chore: control link component improvement

* chore: globalise issue peek overview

* chore: control link component improvement

* chore: made changes and optimised the issue peek overview root

* build-error: resolved build erros for issueId dependancy from issue detail store

* chore: peek overview link fix

* dev: update state nullable rule

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-01-22 13:19:43 +05:30
Prateek Shouryaandsriram veeraghanta 266f14d550 fix: project identifier cursor behaviour in create project modal. (#3320) 2024-01-22 13:19:43 +05:30
Prateek Shouryaandsriram veeraghanta 9eb8f41008 chore: UI/UX improvements (#3319)
* chore: add proper message for cycle/ module having start & end date but isn't active yet.

* fix: infinite loader after updating workspace settings.

* fix: user profile icon dropdown doesn't closes automatically.

* style: fix inconsistent padding in cycle empty state.

* chore: remove multiple `empty state` in labels settings and improve add label logic.

* style: fix inconsistent padding in project label, integration and estimates empty state.

* style: fix integrations settings breadcrumb title.

* style: add proper `disabled` styles for email field in profile settings.

* style: fix cycle layout height.
2024-01-22 13:19:43 +05:30
M. Palanikannanandsriram veeraghanta b340232e76 chore: Updated TableView component in table extension to solve sentry (#3309)
error of table not being defined while getting getBoundingClientRect()
and solve other TS issues

- Added ResolvedPos import from @tiptap/pm/model
- Updated setCellsBackgroundColor function parameter type to string
- Declared ToolboxItem type for toolbox items
- Modified columnsToolboxItems and rowsToolboxItems to use the ToolboxItem type
- Updated createToolbox function parameters to specify Element or null for triggerButton and ToolboxItem[] for items
- Added ts-expect-error comment above the toolbox variable declaration
- Updated update method parameter type to readonly Decoration[]
- Changed destructuring assignment of hoveredTable and hoveredCell in updateControls method to use Object.values and reduce method
- Added null check for this.table in updateControls method
- Wrapped the code that updates columnsControl and rowsControl with null checks for each control
- Replaced ts-ignore comments with proper dispatch calls in selectColumn and selectRow methods

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-01-22 13:19:43 +05:30
Aaryan Khandelwalandsriram veeraghanta 64ca267db4 dev: new create issue modal (#3312) 2024-01-22 13:19:43 +05:30
sriram veeraghanta 46e79dde27 fix: Login workflow depending on smtp is configured (#3307) 2024-01-22 13:19:43 +05:30
Jigin Jayaprakashandsriram veeraghanta 7272c54439 Fixes 3299 (#3308) 2024-01-22 13:19:20 +05:30
607 changed files with 15152 additions and 11201 deletions
+78 -87
View File
@@ -1,61 +1,30 @@
name: Branch Build
on:
pull_request:
types:
- closed
workflow_dispatch:
inputs:
branch_name:
description: "Branch Name"
required: true
default: "preview"
push:
branches:
- master
- preview
- qa
- develop
- release-*
release:
types: [released, prereleased]
env:
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }}
TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }}
jobs:
branch_build_setup:
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }}
name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Uploading Proxy Source
uses: actions/upload-artifact@v3
with:
name: proxy-src-code
path: ./nginx
- name: Uploading Backend Source
uses: actions/upload-artifact@v3
with:
name: backend-src-code
path: ./apiserver
- name: Uploading Web Source
uses: actions/upload-artifact@v3
with:
name: web-src-code
path: |
./
!./apiserver
!./nginx
!./deploy
!./space
- name: Uploading Space Source
uses: actions/upload-artifact@v3
with:
name: space-src-code
path: |
./
!./apiserver
!./nginx
!./deploy
!./web
outputs:
gh_branch_name: ${{ env.TARGET_BRANCH }}
@@ -63,33 +32,38 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Frontend Docker Tag
- name: Set Frontend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
else
TAG=${{ env.FRONTEND_TAG }}
fi
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Web Source Code
uses: actions/download-artifact@v3
with:
name: web-src-code
- name: Check out the repo
uses: actions/checkout@v4.1.1
- name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./web/Dockerfile.web
@@ -105,33 +79,39 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Space Docker Tag
- name: Set Space Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
else
TAG=${{ env.SPACE_TAG }}
fi
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Space Source Code
uses: actions/download-artifact@v3
with:
name: space-src-code
- name: Check out the repo
uses: actions/checkout@v4.1.1
- name: Build and Push Space to Docker Hub
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./space/Dockerfile.space
@@ -147,36 +127,42 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Backend Docker Tag
- name: Set Backend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
else
TAG=${{ env.BACKEND_TAG }}
fi
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Backend Source Code
uses: actions/download-artifact@v3
with:
name: backend-src-code
- name: Check out the repo
uses: actions/checkout@v4.1.1
- name: Build and Push Backend to Docker Hub
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./Dockerfile.api
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/amd64
push: true
tags: ${{ env.BACKEND_TAG }}
@@ -189,37 +175,42 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Proxy Docker Tag
- name: Set Proxy Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
else
TAG=${{ env.PROXY_TAG }}
fi
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Proxy Source Code
uses: actions/download-artifact@v3
with:
name: proxy-src-code
- name: Check out the repo
uses: actions/checkout@v4.1.1
- name: Build and Push Plane-Proxy to Docker Hub
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./Dockerfile
context: ./nginx
file: ./nginx/Dockerfile
platforms: linux/amd64
tags: ${{ env.PROXY_TAG }}
push: true
+5 -7
View File
@@ -1,25 +1,23 @@
name: Create Sync Action
on:
pull_request:
workflow_dispatch:
push:
branches:
- preview
types:
- closed
env:
SOURCE_BRANCH_NAME: ${{github.event.pull_request.base.ref}}
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
jobs:
sync_changes:
# 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: Checkout Code
uses: actions/checkout@v2
uses: actions/checkout@v4.1.1
with:
persist-credentials: false
fetch-depth: 0
+3 -8
View File
@@ -33,15 +33,10 @@ RUN pip install -r requirements/local.txt --compile --no-cache-dir
RUN addgroup -S plane && \
adduser -S captain -G plane
RUN chown captain.plane /code
COPY . .
USER captain
# Add in Django deps and generate Django's static files
USER root
# RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
RUN chown -R captain.plane /code
RUN chmod -R +x /code/bin
RUN chmod -R 777 /code
USER captain
Regular → Executable
+3
View File
@@ -2,4 +2,7 @@
set -e
python manage.py wait_for_db
# Wait for migrations
python manage.py wait_for_migrations
# Run the processes
celery -A plane beat -l info
+2 -1
View File
@@ -1,7 +1,8 @@
#!/bin/bash
set -e
python manage.py wait_for_db
python manage.py migrate
# Wait for migrations
python manage.py wait_for_migrations
# Create the default bucket
#!/bin/bash
+2 -1
View File
@@ -1,7 +1,8 @@
#!/bin/bash
set -e
python manage.py wait_for_db
python manage.py migrate
# Wait for migrations
python manage.py wait_for_migrations
# Create the default bucket
#!/bin/bash
+3
View File
@@ -2,4 +2,7 @@
set -e
python manage.py wait_for_db
# Wait for migrations
python manage.py wait_for_migrations
# Run the processes
celery -A plane worker -l info
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.14.0"
"version": "0.15.0"
}
@@ -8,6 +8,7 @@ from plane.db.models import (
WorkspaceMember,
State,
Estimate,
ProjectFeature,
)
from .base import BaseSerializer
@@ -78,6 +79,7 @@ class ProjectSerializer(BaseSerializer):
project = Project.objects.create(
**validated_data, workspace_id=self.context["workspace_id"]
)
_ = ProjectIdentifier.objects.create(
name=project.identifier,
project=project,
+6 -1
View File
@@ -11,7 +11,6 @@ from rest_framework.serializers import ValidationError
from plane.db.models import (
Workspace,
Project,
ProjectFavorite,
ProjectMember,
ProjectDeployBoard,
State,
@@ -19,6 +18,7 @@ from plane.db.models import (
Module,
IssueProperty,
Inbox,
ProjectFeature,
)
from plane.app.permissions import ProjectBasePermission
from plane.api.serializers import ProjectSerializer
@@ -149,6 +149,11 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
if serializer.is_valid():
serializer.save()
# features
_ = ProjectFeature.objects.create(
project_id=serializer.data["id"]
)
# Add the user as Administrator to the project
project_member = ProjectMember.objects.create(
project_id=serializer.data["id"],
+4 -1
View File
@@ -21,6 +21,7 @@ from .workspace import (
)
from .project import (
ProjectSerializer,
ProjectFeatureSerializer,
ProjectListSerializer,
ProjectDetailSerializer,
ProjectMemberSerializer,
@@ -104,17 +105,19 @@ from .estimate import (
EstimateSerializer,
EstimatePointSerializer,
EstimateReadSerializer,
WorkspaceEstimateSerializer,
)
from .inbox import (
InboxSerializer,
InboxIssueSerializer,
IssueStateInboxSerializer,
InboxIssueLiteSerializer,
)
from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer
from .notification import NotificationSerializer, UserNotificationPreferenceSerializer
from .exporter import ExporterHistorySerializer
+6 -2
View File
@@ -60,6 +60,7 @@ class DynamicBaseSerializer(BaseSerializer):
CycleIssueSerializer,
IssueFlatSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
)
# Expansion mapper
@@ -80,9 +81,10 @@ class DynamicBaseSerializer(BaseSerializer):
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer,
}
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation"] else False)
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False)
return self.fields
@@ -103,6 +105,7 @@ class DynamicBaseSerializer(BaseSerializer):
LabelSerializer,
CycleIssueSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
)
# Expansion mapper
@@ -122,7 +125,8 @@ class DynamicBaseSerializer(BaseSerializer):
"labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"issue_relation": IssueRelationSerializer
"issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer,
}
# Check if field in expansion then expand the field
if expand in expansion:
@@ -61,3 +61,18 @@ class EstimateReadSerializer(BaseSerializer):
"name",
"description",
]
class WorkspaceEstimateSerializer(BaseSerializer):
points = EstimatePointSerializer(read_only=True, many=True)
class Meta:
model = Estimate
fields = "__all__"
read_only_fields = [
"points",
"name",
"description",
]
+22 -24
View File
@@ -259,14 +259,17 @@ class IssuePropertySerializer(BaseSerializer):
class LabelSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
class Meta:
model = Label
fields = "__all__"
fields = [
"parent",
"name",
"color",
"id",
"project_id",
"workspace_id",
"sort_order",
]
read_only_fields = [
"workspace",
"project",
@@ -295,8 +298,12 @@ class IssueLabelSerializer(BaseSerializer):
class IssueRelationSerializer(BaseSerializer):
id = serializers.UUIDField(source="related_issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(source="related_issue.project_id", read_only=True)
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(
source="related_issue.project_id", read_only=True
)
sequence_id = serializers.IntegerField(
source="related_issue.sequence_id", read_only=True
)
relation_type = serializers.CharField(read_only=True)
class Meta:
@@ -315,8 +322,12 @@ class IssueRelationSerializer(BaseSerializer):
class RelatedIssueSerializer(BaseSerializer):
id = serializers.UUIDField(source="issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(
source="issue.project_id", read_only=True
)
sequence_id = serializers.IntegerField(
source="issue.sequence_id", read_only=True
)
relation_type = serializers.CharField(read_only=True)
class Meta:
@@ -458,19 +469,6 @@ class IssueReactionSerializer(BaseSerializer):
]
class CommentReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = CommentReaction
fields = [
"id",
"reaction",
"comment",
"actor_detail",
]
class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
@@ -501,7 +499,7 @@ class IssueCommentSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
comment_reactions = CommentReactionLiteSerializer(
comment_reactions = CommentReactionSerializer(
read_only=True, many=True
)
is_member = serializers.BooleanField(read_only=True)
@@ -1,7 +1,7 @@
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from plane.db.models import Notification
from plane.db.models import Notification, UserNotificationPreference
class NotificationSerializer(BaseSerializer):
@@ -12,3 +12,10 @@ class NotificationSerializer(BaseSerializer):
class Meta:
model = Notification
fields = "__all__"
class UserNotificationPreferenceSerializer(BaseSerializer):
class Meta:
model = UserNotificationPreference
fields = "__all__"
@@ -16,6 +16,7 @@ from plane.db.models import (
ProjectFavorite,
ProjectDeployBoard,
ProjectPublicMember,
ProjectFeature,
)
@@ -47,6 +48,8 @@ class ProjectSerializer(BaseSerializer):
project = Project.objects.create(
**validated_data, workspace_id=self.context["workspace_id"]
)
# Create identifiers
_ = ProjectIdentifier.objects.create(
name=project.identifier,
project=project,
@@ -237,3 +240,14 @@ class ProjectPublicMemberSerializer(BaseSerializer):
"project",
"member",
]
class ProjectFeatureSerializer(BaseSerializer):
class Meta:
model = ProjectFeature
fields = "__all__"
read_only_fields = [
"project",
"workspace",
]
+11 -1
View File
@@ -8,7 +8,17 @@ from plane.db.models import State
class StateSerializer(BaseSerializer):
class Meta:
model = State
fields = "__all__"
fields = [
"id",
"project_id",
"workspace_id",
"name",
"color",
"group",
"default",
"description",
"sequence",
]
read_only_fields = [
"workspace",
"project",
-6
View File
@@ -8,16 +8,10 @@ from plane.app.views import (
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
ActiveCycleEndpoint
)
urlpatterns = [
path(
"workspaces/<str:slug>/active-cycles/",
ActiveCycleEndpoint.as_view(),
name="workspace-active-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/",
CycleViewSet.as_view(
+6
View File
@@ -5,6 +5,7 @@ from plane.app.views import (
NotificationViewSet,
UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
UserNotificationPreferenceEndpoint,
)
@@ -63,4 +64,9 @@ urlpatterns = [
),
name="mark-all-read-notifications",
),
path(
"users/me/notification-preferences/",
UserNotificationPreferenceEndpoint.as_view(),
name="user-notification-preferences",
),
]
+6
View File
@@ -14,6 +14,7 @@ from plane.app.views import (
ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
UserProjectRolesEndpoint,
ProjectFeatureEndpoint,
)
@@ -175,4 +176,9 @@ urlpatterns = [
),
name="project-deploy-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-features/",
ProjectFeatureEndpoint.as_view(),
name="project-feature",
),
]
+12
View File
@@ -20,6 +20,8 @@ from plane.app.views import (
WorkspaceLabelsEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
)
@@ -207,4 +209,14 @@ urlpatterns = [
WorkspaceUserPropertiesEndpoint.as_view(),
name="workspace-user-filters",
),
path(
"workspaces/<str:slug>/states/",
WorkspaceStatesEndpoint.as_view(),
name="workspace-state",
),
path(
"workspaces/<str:slug>/estimates/",
WorkspaceEstimatesEndpoint.as_view(),
name="workspace-estimate",
),
]
+4 -1
View File
@@ -12,6 +12,7 @@ from .project import (
ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
UserProjectRolesEndpoint,
ProjectFeatureEndpoint,
)
from .user import (
UserEndpoint,
@@ -47,6 +48,8 @@ from .workspace import (
WorkspaceLabelsEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
)
from .state import StateViewSet
from .view import (
@@ -62,7 +65,6 @@ from .cycle import (
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
ActiveCycleEndpoint,
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue import (
@@ -166,6 +168,7 @@ from .notification import (
NotificationViewSet,
UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
UserNotificationPreferenceEndpoint,
)
from .exporter import ExportIssuesEndpoint
+6 -233
View File
@@ -39,7 +39,6 @@ from plane.app.serializers import (
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
WorkspaceUserPermission
)
from plane.db.models import (
User,
@@ -531,6 +530,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Delete the cycle
cycle.delete()
@@ -722,6 +723,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Return all Cycle Issues
@@ -754,6 +757,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
cycle_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -910,235 +915,3 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
)
serializer = CycleUserPropertiesSerializer(cycle_properties)
return Response(serializer.data, status=status.HTTP_200_OK)
class ActiveCycleEndpoint(BaseAPIView):
permission_classes = [
WorkspaceUserPermission,
]
def get(self, request, slug):
subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
active_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=self.request.user,
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.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",
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",
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",
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",
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",
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",
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",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only("name", "color", "id").distinct(),
)
)
.order_by("-created_at")
)
cycles = CycleSerializer(active_cycles, many=True).data
for cycle in cycles:
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle["id"],
project_id=cycle["project"],
workspace__slug=slug,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.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,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle["id"],
project_id=cycle["project"],
workspace__slug=slug,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"label_id",
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,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
cycle["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if cycle["start_date"] and cycle["end_date"]:
cycle["distribution"][
"completion_chart"
] = burndown_plot(
queryset=active_cycles.get(pk=cycle["id"]),
slug=slug,
project_id=cycle["project"],
cycle_id=cycle["id"],
)
return Response(cycles, status=status.HTTP_200_OK)
+38 -46
View File
@@ -88,39 +88,24 @@ class InboxIssueViewSet(BaseViewSet):
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now())
| Q(snoozed_till__isnull=True),
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("issue", "workspace", "project")
)
def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET")
issues = (
return (
Issue.objects.filter(
issue_inbox__inbox_id=inbox_id,
workspace__slug=slug,
project_id=project_id,
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_inbox__inbox_id=self.kwargs.get("inbox_id")
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
.prefetch_related("labels", "assignees")
.prefetch_related(
Prefetch(
"issue_inbox",
queryset=InboxIssue.objects.only(
"status", "duplicate_to", "snoozed_till", "source"
),
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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()
@@ -135,16 +120,20 @@ class InboxIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_inbox",
queryset=InboxIssue.objects.only(
"status", "duplicate_to", "snoozed_till", "source"
),
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
issues_data = IssueStateInboxSerializer(issues, many=True).data
).distinct()
def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status")
issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data
return Response(
issues_data,
status=status.HTTP_200_OK,
@@ -200,6 +189,8 @@ class InboxIssueViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# create an inbox issue
InboxIssue.objects.create(
@@ -209,7 +200,8 @@ class InboxIssueViewSet(BaseViewSet):
source=request.data.get("source", "in-app"),
)
serializer = IssueStateInboxSerializer(issue)
issue = (self.get_queryset().filter(pk=issue.id).first())
serializer = IssueSerializer(issue ,expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
@@ -277,6 +269,8 @@ class InboxIssueViewSet(BaseViewSet):
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue_serializer.save()
else:
@@ -327,22 +321,20 @@ class InboxIssueViewSet(BaseViewSet):
if state is not None:
issue.state = state
issue.save()
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
return Response(
InboxIssueSerializer(inbox_issue).data,
status=status.HTTP_200_OK,
)
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue ,expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueStateInboxSerializer(issue)
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, issue_id):
+66 -6
View File
@@ -48,10 +48,8 @@ from plane.app.serializers import (
ProjectMemberLiteSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
@@ -83,6 +81,7 @@ from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from collections import defaultdict
class IssueViewSet(WebhookMixin, BaseViewSet):
def get_serializer_class(self):
return (
@@ -260,6 +259,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first()
@@ -298,6 +299,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = self.get_queryset().filter(pk=pk).first()
return Response(
@@ -321,6 +324,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -492,17 +497,27 @@ class IssueActivityEndpoint(BaseAPIView):
@method_decorator(gzip_page)
def get(self, request, slug, project_id, issue_id):
filters = {}
if request.GET.get("created_at__gt", None) is not None:
filters = {"created_at__gt": request.GET.get("created_at__gt")}
issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id)
.filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
)
.filter(**filters)
.select_related("actor", "workspace", "issue", "project")
).order_by("created_at")
issue_comments = (
IssueComment.objects.filter(issue_id=issue_id)
.filter(project__project_projectmember__member=self.request.user)
.filter(
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
)
.filter(**filters)
.order_by("created_at")
.select_related("actor", "issue", "project", "workspace")
.prefetch_related(
@@ -517,6 +532,12 @@ class IssueActivityEndpoint(BaseAPIView):
).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
if request.GET.get("activity_type", None) == "issue-property":
return Response(issue_activities, status=status.HTTP_200_OK)
if request.GET.get("activity_type", None) == "issue-comment":
return Response(issue_comments, status=status.HTTP_200_OK)
result_list = sorted(
chain(issue_activities, issue_comments),
key=lambda instance: instance["created_at"],
@@ -580,6 +601,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -609,6 +632,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -633,6 +658,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -821,7 +848,9 @@ class SubIssuesEndpoint(BaseAPIView):
_ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10)
updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids).annotate(state_group=F("state__group"))
updated_sub_issues = Issue.issue_objects.filter(
id__in=sub_issue_ids
).annotate(state_group=F("state__group"))
# Track the issue
_ = [
@@ -833,10 +862,12 @@ class SubIssuesEndpoint(BaseAPIView):
project_id=str(project_id),
current_instance=json.dumps({"parent": str(sub_issue_id)}),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for sub_issue_id in sub_issue_ids
]
# create's a dict with state group name with their respective issue id's
result = defaultdict(list)
for sub_issue in updated_sub_issues:
@@ -853,7 +884,6 @@ class SubIssuesEndpoint(BaseAPIView):
},
status=status.HTTP_200_OK,
)
class IssueLinkViewSet(BaseViewSet):
@@ -893,6 +923,8 @@ class IssueLinkViewSet(BaseViewSet):
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -922,6 +954,8 @@ class IssueLinkViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -945,6 +979,8 @@ class IssueLinkViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue_link.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1001,6 +1037,8 @@ class IssueAttachmentEndpoint(BaseAPIView):
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1017,6 +1055,8 @@ class IssueAttachmentEndpoint(BaseAPIView):
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1195,6 +1235,8 @@ class IssueArchiveViewSet(BaseViewSet):
IssueSerializer(issue).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue.archived_at = None
issue.save()
@@ -1340,6 +1382,8 @@ class IssueReactionViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1365,6 +1409,8 @@ class IssueReactionViewSet(BaseViewSet):
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1405,6 +1451,8 @@ class CommentReactionViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1431,6 +1479,8 @@ class CommentReactionViewSet(BaseViewSet):
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1559,6 +1609,8 @@ class IssueRelationViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
if relation_type == "blocking":
@@ -1603,6 +1655,8 @@ class IssueRelationViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1774,6 +1828,8 @@ class IssueDraftViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1804,6 +1860,8 @@ class IssueDraftViewSet(BaseViewSet):
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1830,5 +1888,7 @@ class IssueDraftViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
+6
View File
@@ -310,6 +310,8 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
module.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -488,6 +490,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issues = self.get_queryset().values_list("issue_id", flat=True)
@@ -519,6 +523,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
module_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+53 -6
View File
@@ -1,5 +1,5 @@
# Django imports
from django.db.models import Q
from django.db.models import Q, OuterRef, Exists
from django.utils import timezone
# Third party imports
@@ -15,8 +15,9 @@ from plane.db.models import (
IssueSubscriber,
Issue,
WorkspaceMember,
UserNotificationPreference,
)
from plane.app.serializers import NotificationSerializer
from plane.app.serializers import NotificationSerializer, UserNotificationPreferenceSerializer
class NotificationViewSet(BaseViewSet, BasePaginator):
@@ -71,11 +72,29 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
# Subscribed issues
if type == "watching":
issue_ids = IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
).values_list("issue_id", flat=True)
issue_ids = (
IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
)
.annotate(
created=Exists(
Issue.objects.filter(
created_by=request.user, pk=OuterRef("issue_id")
)
)
)
.annotate(
assigned=Exists(
IssueAssignee.objects.filter(
pk=OuterRef("issue_id"), assignee=request.user
)
)
)
.filter(created=False, assigned=False)
.values_list("issue_id", flat=True)
)
notifications = notifications.filter(
entity_identifier__in=issue_ids
entity_identifier__in=issue_ids,
)
# Assigned Issues
@@ -295,3 +314,31 @@ class MarkAllReadNotificationViewSet(BaseViewSet):
updated_notifications, ["read_at"], batch_size=100
)
return Response({"message": "Successful"}, status=status.HTTP_200_OK)
class UserNotificationPreferenceEndpoint(BaseAPIView):
model = UserNotificationPreference
serializer_class = UserNotificationPreferenceSerializer
# request the object
def get(self, request):
user_notification_preference = UserNotificationPreference.objects.get(
user=request.user
)
serializer = UserNotificationPreferenceSerializer(
user_notification_preference
)
return Response(serializer.data, status=status.HTTP_200_OK)
# update the object
def patch(self, request):
user_notification_preference = UserNotificationPreference.objects.get(
user=request.user
)
serializer = UserNotificationPreferenceSerializer(
user_notification_preference, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+8 -8
View File
@@ -158,18 +158,18 @@ class PageViewSet(BaseViewSet):
pk=page_id, workspace__slug=slug, project_id=project_id
)
# only the owner and admin can archive the page
# only the owner or admin can archive the page
if (
ProjectMember.objects.filter(
project_id=project_id,
member=request.user,
is_active=True,
role__gte=20,
role__lte=15,
).exists()
or request.user.id != page.owned_by_id
and request.user.id != page.owned_by_id
):
return Response(
{"error": "Only the owner and admin can archive the page"},
{"error": "Only the owner or admin can archive the page"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -182,18 +182,18 @@ class PageViewSet(BaseViewSet):
pk=page_id, workspace__slug=slug, project_id=project_id
)
# only the owner and admin can un archive the page
# only the owner or admin can un archive the page
if (
ProjectMember.objects.filter(
project_id=project_id,
member=request.user,
is_active=True,
role__gt=20,
role__lte=15,
).exists()
or request.user.id != page.owned_by_id
and request.user.id != page.owned_by_id
):
return Response(
{"error": "Only the owner and admin can un archive the page"},
{"error": "Only the owner or admin can un archive the page"},
status=status.HTTP_400_BAD_REQUEST,
)
+56 -27
View File
@@ -37,6 +37,7 @@ from plane.app.serializers import (
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
ProjectMemberRoleSerializer,
ProjectFeatureSerializer,
)
from plane.app.permissions import (
@@ -62,13 +63,14 @@ from plane.db.models import (
Inbox,
ProjectDeployBoard,
IssueProperty,
ProjectFeature,
)
from plane.bgtasks.project_invitation_task import project_invitation
class ProjectViewSet(WebhookMixin, BaseViewSet):
serializer_class = ProjectSerializer
serializer_class = ProjectListSerializer
model = Project
webhook_event = "project"
@@ -76,11 +78,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
ProjectBasePermission,
]
def get_serializer_class(self, *args, **kwargs):
if self.action in ["update", "partial_update"]:
return ProjectSerializer
return ProjectDetailSerializer
def get_queryset(self):
return self.filter_queryset(
super()
@@ -208,6 +205,10 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
if serializer.is_valid():
serializer.save()
_ = ProjectFeature.objects.create(
project_id=serializer.data["id"]
)
# Add the user as Administrator to the project
project_member = ProjectMember.objects.create(
project_id=serializer.data["id"],
@@ -690,6 +691,24 @@ class ProjectMemberViewSet(BaseViewSet):
.order_by("sort_order")
)
bulk_project_members = []
member_roles = {
member.get("member_id"): member.get("role") for member in members
}
# Update roles in the members array based on the member_roles dictionary
for project_member in ProjectMember.objects.filter(
project_id=project_id,
member_id__in=[member.get("member_id") for member in members],
):
project_member.role = member_roles[str(project_member.member_id)]
project_member.is_active = True
bulk_project_members.append(project_member)
# Update the roles of the existing members
ProjectMember.objects.bulk_update(
bulk_project_members, ["is_active", "role"], batch_size=100
)
for member in members:
sort_order = [
project_member.get("sort_order")
@@ -716,25 +735,6 @@ class ProjectMemberViewSet(BaseViewSet):
)
)
# Check if the user is already a member of the project and is inactive
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member_id=member.get("member_id"),
is_active=False,
).exists():
member_detail = ProjectMember.objects.get(
workspace__slug=slug,
project_id=project_id,
member_id=member.get("member_id"),
is_active=False,
)
# Check if the user has not deactivated the account
user = User.objects.filter(pk=member.get("member_id")).first()
if user.is_active:
member_detail.is_active = True
member_detail.save(update_fields=["is_active"])
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
batch_size=10,
@@ -745,8 +745,11 @@ class ProjectMemberViewSet(BaseViewSet):
bulk_issue_props, batch_size=10, ignore_conflicts=True
)
serializer = ProjectMemberSerializer(project_members, many=True)
project_members = ProjectMember.objects.filter(
project_id=project_id,
member_id__in=[member.get("member_id") for member in members],
)
serializer = ProjectMemberRoleSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def list(self, request, slug, project_id):
@@ -1149,3 +1152,29 @@ class UserProjectRolesEndpoint(BaseAPIView):
for member in project_members
}
return Response(project_members, status=status.HTTP_200_OK)
class ProjectFeatureEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
]
def get(self, request, slug, project_id):
project_feature = ProjectFeature.objects.get(
workspace__slug=slug, project_id=project_id
)
serializer = ProjectFeatureSerializer(project_feature)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id):
project_feature = ProjectFeature.objects.get(
workspace__slug=slug,
project_id=project_id,
)
serializer = ProjectFeatureSerializer(
project_feature, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+5 -5
View File
@@ -9,9 +9,12 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseViewSet
from . import BaseViewSet, BaseAPIView
from plane.app.serializers import StateSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.app.permissions import (
ProjectEntityPermission,
WorkspaceEntityPermission,
)
from plane.db.models import State, Issue
@@ -22,9 +25,6 @@ class StateViewSet(BaseViewSet):
ProjectEntityPermission,
]
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self):
return self.filter_queryset(
super()
+48 -4
View File
@@ -46,10 +46,14 @@ from plane.app.serializers import (
WorkspaceMemberMeSerializer,
ProjectMemberRoleSerializer,
WorkspaceUserPropertiesSerializer,
WorkspaceEstimateSerializer,
StateSerializer,
LabelSerializer,
)
from plane.app.views.base import BaseAPIView
from . import BaseViewSet
from plane.db.models import (
State,
User,
Workspace,
WorkspaceMemberInvite,
@@ -67,6 +71,8 @@ from plane.db.models import (
CycleIssue,
IssueReaction,
WorkspaceUserProperties,
Estimate,
EstimatePoint,
)
from plane.app.permissions import (
WorkSpaceBasePermission,
@@ -1358,7 +1364,9 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -1441,10 +1449,46 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
labels = Label.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
).values(
"parent", "name", "color", "id", "project_id", "workspace__slug"
)
return Response(labels, status=status.HTTP_200_OK)
serializer = LabelSerializer(labels, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
class WorkspaceStatesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
states = State.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
)
serializer = StateSerializer(states, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
class WorkspaceEstimatesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
estimate_ids = Project.objects.filter(
workspace__slug=slug, estimate__isnull=False
).values_list("estimate_id", flat=True)
estimates = Estimate.objects.filter(
pk__in=estimate_ids
).prefetch_related(
Prefetch(
"points",
queryset=EstimatePoint.objects.select_related(
"estimate", "workspace", "project"
),
)
)
serializer = WorkspaceEstimateSerializer(estimates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class WorkspaceUserPropertiesEndpoint(BaseAPIView):
@@ -0,0 +1,236 @@
import json
from datetime import datetime
# Third party imports
from celery import shared_task
# Django imports
from django.utils import timezone
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Module imports
from plane.db.models import EmailNotificationLog, User, Issue
from plane.license.utils.instance_value import get_email_configuration
from plane.settings.redis import redis_instance
@shared_task
def stack_email_notification():
# get all email notifications
email_notifications = (
EmailNotificationLog.objects.filter(processed_at__isnull=True)
.order_by("receiver")
.values()
)
# Create the below format for each of the issues
# {"issue_id" : { "actor_id1": [ { data }, { data } ], "actor_id2": [ { data }, { data } ] }}
# Convert to unique receivers list
receivers = list(
set(
[
str(notification.get("receiver_id"))
for notification in email_notifications
]
)
)
processed_notifications = []
# Loop through all the issues to create the emails
for receiver_id in receivers:
# Notifcation triggered for the receiver
receiver_notifications = [
notification
for notification in email_notifications
if str(notification.get("receiver_id")) == receiver_id
]
# create payload for all issues
payload = {}
email_notification_ids = []
for receiver_notification in receiver_notifications:
payload.setdefault(
receiver_notification.get("entity_identifier"), {}
).setdefault(
str(receiver_notification.get("triggered_by_id")), []
).append(
receiver_notification.get("data")
)
# append processed notifications
processed_notifications.append(receiver_notification.get("id"))
email_notification_ids.append(receiver_notification.get("id"))
# Create emails for all the issues
for issue_id, notification_data in payload.items():
send_email_notification.delay(
issue_id=issue_id,
notification_data=notification_data,
receiver_id=receiver_id,
email_notification_ids=email_notification_ids,
)
# Update the email notification log
EmailNotificationLog.objects.filter(pk__in=processed_notifications).update(
processed_at=timezone.now()
)
def create_payload(notification_data):
# return format {"actor_id": { "key": { "old_value": [], "new_value": [] } }}
data = {}
for actor_id, changes in notification_data.items():
for change in changes:
issue_activity = change.get("issue_activity")
if issue_activity: # Ensure issue_activity is not None
field = issue_activity.get("field")
old_value = str(issue_activity.get("old_value"))
new_value = str(issue_activity.get("new_value"))
# Append old_value if it's not empty and not already in the list
if old_value:
data.setdefault(actor_id, {}).setdefault(
field, {}
).setdefault("old_value", []).append(
old_value
) if old_value not in data.setdefault(
actor_id, {}
).setdefault(
field, {}
).get(
"old_value", []
) else None
# Append new_value if it's not empty and not already in the list
if new_value:
data.setdefault(actor_id, {}).setdefault(
field, {}
).setdefault("new_value", []).append(
new_value
) if new_value not in data.setdefault(
actor_id, {}
).setdefault(
field, {}
).get(
"new_value", []
) else None
if not data.get("actor_id", {}).get("activity_time", False):
data[actor_id]["activity_time"] = str(
datetime.fromisoformat(
issue_activity.get("activity_time").rstrip("Z")
).strftime("%Y-%m-%d %H:%M:%S")
)
return data
@shared_task
def send_email_notification(
issue_id, notification_data, receiver_id, email_notification_ids
):
ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data)
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
receiver = User.objects.get(pk=receiver_id)
issue = Issue.objects.get(pk=issue_id)
template_data = []
total_changes = 0
comments = []
for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes)
comment = changes.pop("comment", False)
if comment:
comments.append(
{
"actor_comments": comment,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
# Parse the input string into a datetime object
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
if changes:
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(formatted_time),
}
)
summary = "updates were made to the issue by"
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
context = {
"data": template_data,
"summary": summary,
"issue": {
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"name": issue.name,
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
},
"receiver": {
"email": receiver.email,
},
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
)
text_content = strip_tags(html_content)
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
EmailNotificationLog.objects.filter(
pk__in=email_notification_ids
).update(sent_at=timezone.now())
return
except Exception as e:
print(e)
return
+1 -1
View File
@@ -97,7 +97,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
)
# Create the new url with updated domain and protocol
presigned_url = presigned_url.replace(
"http://plane-minio:9000/uploads/",
f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}/",
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/",
)
else:
@@ -21,7 +21,7 @@ from plane.license.utils.instance_value import get_email_configuration
def forgot_password(first_name, email, uidb64, token, current_site):
try:
relative_link = (
f"/accounts/password/?uidb64={uidb64}&token={token}&email={email}"
f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}"
)
abs_url = str(current_site) + relative_link
+16 -1
View File
@@ -24,6 +24,7 @@ from plane.db.models import (
Label,
User,
IssueProperty,
UserNotificationPreference,
)
@@ -50,10 +51,24 @@ def service_importer(service, importer_id):
for user in users
if user.get("import", False) == "invite"
],
batch_size=10,
batch_size=100,
ignore_conflicts=True,
)
_ = UserNotificationPreference.objects.bulk_create(
[UserNotificationPreference(user=user) for user in new_users],
batch_size=100,
)
_ = [
send_welcome_slack.delay(
str(user.id),
True,
f"{user.email} was imported to Plane from {service}",
)
for user in new_users
]
workspace_users = User.objects.filter(
email__in=[
user.get("email").strip().lower()
+59 -32
View File
@@ -24,10 +24,11 @@ from plane.db.models import (
IssueReaction,
CommentReaction,
IssueComment,
IssueSubscriber,
)
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
from plane.settings.redis import redis_instance
# Track Changes in name
def track_name(
@@ -111,15 +112,15 @@ def track_parent(
issue_activities,
epoch,
):
if current_instance.get("parent") != requested_data.get("parent"):
if current_instance.get("parent_id") != requested_data.get("parent_id"):
old_parent = (
Issue.objects.filter(pk=current_instance.get("parent")).first()
if current_instance.get("parent") is not None
Issue.objects.filter(pk=current_instance.get("parent_id")).first()
if current_instance.get("parent_id") is not None
else None
)
new_parent = (
Issue.objects.filter(pk=requested_data.get("parent")).first()
if requested_data.get("parent") is not None
Issue.objects.filter(pk=requested_data.get("parent_id")).first()
if requested_data.get("parent_id") is not None
else None
)
@@ -188,9 +189,11 @@ def track_state(
issue_activities,
epoch,
):
if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None))
old_state = State.objects.get(pk=current_instance.get("state", None))
if current_instance.get("state_id") != requested_data.get("state_id"):
new_state = State.objects.get(pk=requested_data.get("state_id", None))
old_state = State.objects.get(
pk=current_instance.get("state_id", None)
)
issue_activities.append(
IssueActivity(
@@ -288,10 +291,10 @@ def track_labels(
epoch,
):
requested_labels = set(
[str(lab) for lab in requested_data.get("labels", [])]
[str(lab) for lab in requested_data.get("label_ids", [])]
)
current_labels = set(
[str(lab) for lab in current_instance.get("labels", [])]
[str(lab) for lab in current_instance.get("label_ids", [])]
)
added_labels = requested_labels - current_labels
@@ -350,15 +353,16 @@ def track_assignees(
epoch,
):
requested_assignees = set(
[str(asg) for asg in requested_data.get("assignees", [])]
[str(asg) for asg in requested_data.get("assignee_ids", [])]
)
current_assignees = set(
[str(asg) for asg in current_instance.get("assignees", [])]
[str(asg) for asg in current_instance.get("assignee_ids", [])]
)
added_assignees = requested_assignees - current_assignees
dropped_assginees = current_assignees - requested_assignees
bulk_subscribers = []
for added_asignee in added_assignees:
assignee = User.objects.get(pk=added_asignee)
issue_activities.append(
@@ -376,6 +380,21 @@ def track_assignees(
epoch=epoch,
)
)
bulk_subscribers.append(
IssueSubscriber(
subscriber_id=assignee.id,
issue_id=issue_id,
workspace_id=workspace_id,
project_id=project_id,
created_by_id=assignee.id,
updated_by_id=assignee.id,
)
)
# Create assignees subscribers to the issue and ignore if already
IssueSubscriber.objects.bulk_create(
bulk_subscribers, batch_size=10, ignore_conflicts=True
)
for dropped_assignee in dropped_assginees:
assignee = User.objects.get(pk=dropped_assignee)
@@ -541,14 +560,14 @@ def update_issue_activity(
):
ISSUE_ACTIVITY_MAPPER = {
"name": track_name,
"parent": track_parent,
"parent_id": track_parent,
"priority": track_priority,
"state": track_state,
"state_id": track_state,
"description_html": track_description,
"target_date": track_target_date,
"start_date": track_start_date,
"labels": track_labels,
"assignees": track_assignees,
"label_ids": track_labels,
"assignee_ids": track_assignees,
"estimate_point": track_estimate_points,
"archived_at": track_archive_at,
"closed_to": track_closed_to,
@@ -1543,6 +1562,8 @@ def issue_activity(
project_id,
epoch,
subscriber=True,
notification=False,
origin=None,
):
try:
issue_activities = []
@@ -1551,6 +1572,10 @@ def issue_activity(
workspace_id = project.workspace_id
if issue_id is not None:
if origin:
ri = redis_instance()
# set the request origin in redis
ri.set(str(issue_id), origin, ex=600)
issue = Issue.objects.filter(pk=issue_id).first()
if issue:
try:
@@ -1623,22 +1648,24 @@ def issue_activity(
)
except Exception as e:
capture_exception(e)
notifications.delay(
type=type,
issue_id=issue_id,
actor_id=actor_id,
project_id=project_id,
subscriber=subscriber,
issue_activities_created=json.dumps(
IssueActivitySerializer(
issue_activities_created, many=True
).data,
cls=DjangoJSONEncoder,
),
requested_data=requested_data,
current_instance=current_instance,
)
if notification:
notifications.delay(
type=type,
issue_id=issue_id,
actor_id=actor_id,
project_id=project_id,
subscriber=subscriber,
issue_activities_created=json.dumps(
IssueActivitySerializer(
issue_activities_created, many=True
).data,
cls=DjangoJSONEncoder,
),
requested_data=requested_data,
current_instance=current_instance,
)
return
except Exception as e:
@@ -87,6 +87,7 @@ def archive_old_issues():
current_instance=json.dumps({"archived_at": None}),
subscriber=False,
epoch=int(timezone.now().timestamp()),
notification=True,
)
for issue in issues_to_update
]
@@ -169,6 +170,7 @@ def close_old_issues():
current_instance=None,
subscriber=False,
epoch=int(timezone.now().timestamp()),
notification=True,
)
for issue in issues_to_update
]
+477 -256
View File
@@ -10,9 +10,12 @@ from plane.db.models import (
User,
IssueAssignee,
Issue,
State,
EmailNotificationLog,
Notification,
IssueComment,
IssueActivity,
UserNotificationPreference,
)
# Third Party imports
@@ -20,7 +23,7 @@ from celery import shared_task
from bs4 import BeautifulSoup
# =========== Issue Description Html Parsing and Notification Functions ======================
# =========== Issue Description Html Parsing and notification Functions ======================
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
@@ -37,9 +40,7 @@ def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
)
IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100)
IssueMention.objects.filter(
issue=issue, mention__in=removed_mention
).delete()
IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete()
def get_new_mentions(requested_instance, current_instance):
@@ -60,8 +61,6 @@ def get_new_mentions(requested_instance, current_instance):
# Get Removed Mention
def get_removed_mentions(requested_instance, current_instance):
# requested_data is the newer instance of the current issue
# current_instance is the older instance of the current issue, saved in the database
@@ -79,8 +78,6 @@ def get_removed_mentions(requested_instance, current_instance):
# Adds mentions as subscribers
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
# mentions is an array of User IDs representing the FILTERED set of mentioned users
@@ -95,9 +92,7 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
project_id=project_id,
).exists()
and not IssueAssignee.objects.filter(
project_id=project_id,
issue_id=issue_id,
assignee_id=mention_id,
project_id=project_id, issue_id=issue_id, assignee_id=mention_id
).exists()
and not Issue.objects.filter(
project_id=project_id, pk=issue_id, created_by_id=mention_id
@@ -125,9 +120,7 @@ def extract_mentions(issue_instance):
data = json.loads(issue_instance)
html = data.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(
"mention-component", attrs={"target": "users"}
)
mention_tags = soup.find_all("mention-component", attrs={"target": "users"})
mentions = [mention_tag["id"] for mention_tag in mention_tags]
@@ -136,14 +129,12 @@ def extract_mentions(issue_instance):
return []
# =========== Comment Parsing and Notification Functions ======================
# =========== Comment Parsing and notification Functions ======================
def extract_comment_mentions(comment_value):
try:
mentions = []
soup = BeautifulSoup(comment_value, "html.parser")
mentions_tags = soup.find_all(
"mention-component", attrs={"target": "users"}
)
mentions_tags = soup.find_all("mention-component", attrs={"target": "users"})
for mention_tag in mentions_tags:
mentions.append(mention_tag["id"])
return list(set(mentions))
@@ -165,14 +156,8 @@ def get_new_comment_mentions(new_value, old_value):
return new_mentions
def createMentionNotification(
project,
notification_comment,
issue,
actor_id,
mention_id,
issue_id,
activity,
def create_mention_notification(
project, notification_comment, issue, actor_id, mention_id, issue_id, activity
):
return Notification(
workspace=project.workspace,
@@ -215,244 +200,199 @@ def notifications(
requested_data,
current_instance,
):
issue_activities_created = (
json.loads(issue_activities_created)
if issue_activities_created is not None
else None
)
if type not in [
"issue.activity.deleted",
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
"issue_draft.activity.created",
"issue_draft.activity.updated",
"issue_draft.activity.deleted",
]:
# Create Notifications
bulk_notifications = []
"""
Mention Tasks
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
"""
# Get new mentions from the newer instance
new_mentions = get_new_mentions(
requested_instance=requested_data,
current_instance=current_instance,
)
removed_mention = get_removed_mentions(
requested_instance=requested_data,
current_instance=current_instance,
try:
issue_activities_created = (
json.loads(issue_activities_created)
if issue_activities_created is not None
else None
)
if type not in [
"issue.activity.deleted",
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
"issue_draft.activity.created",
"issue_draft.activity.updated",
"issue_draft.activity.deleted",
]:
# Create Notifications
bulk_notifications = []
bulk_email_logs = []
comment_mentions = []
all_comment_mentions = []
"""
Mention Tasks
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
"""
# Get New Subscribers from the mentions of the newer instance
requested_mentions = extract_mentions(issue_instance=requested_data)
mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id,
issue_id=issue_id,
mentions=requested_mentions,
)
for issue_activity in issue_activities_created:
issue_comment = issue_activity.get("issue_comment")
issue_comment_new_value = issue_activity.get("new_value")
issue_comment_old_value = issue_activity.get("old_value")
if issue_comment is not None:
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
all_comment_mentions = (
all_comment_mentions
+ extract_comment_mentions(issue_comment_new_value)
)
new_comment_mentions = get_new_comment_mentions(
old_value=issue_comment_old_value,
new_value=issue_comment_new_value,
)
comment_mentions = comment_mentions + new_comment_mentions
comment_mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id,
issue_id=issue_id,
mentions=all_comment_mentions,
)
"""
We will not send subscription activity notification to the below mentioned user sets
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
- When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification
- When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification
"""
issue_assignees = list(
IssueAssignee.objects.filter(
project_id=project_id, issue_id=issue_id
# Get new mentions from the newer instance
new_mentions = get_new_mentions(
requested_instance=requested_data,
current_instance=current_instance,
)
.exclude(assignee_id__in=list(new_mentions + comment_mentions))
.values_list("assignee", flat=True)
)
issue_subscribers = list(
IssueSubscriber.objects.filter(
project_id=project_id, issue_id=issue_id
removed_mention = get_removed_mentions(
requested_instance=requested_data,
current_instance=current_instance,
)
.exclude(
subscriber_id__in=list(
new_mentions + comment_mentions + [actor_id]
)
comment_mentions = []
all_comment_mentions = []
# Get New Subscribers from the mentions of the newer instance
requested_mentions = extract_mentions(
issue_instance=requested_data
)
mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id,
issue_id=issue_id,
mentions=requested_mentions,
)
.values_list("subscriber", flat=True)
)
issue = Issue.objects.filter(pk=issue_id).first()
if issue.created_by_id is not None and str(issue.created_by_id) != str(
actor_id
):
issue_subscribers = issue_subscribers + [issue.created_by_id]
if subscriber:
# add the user to issue subscriber
try:
if (
str(issue.created_by_id) != str(actor_id)
and uuid.UUID(actor_id) not in issue_assignees
):
_ = IssueSubscriber.objects.get_or_create(
project_id=project_id,
issue_id=issue_id,
subscriber_id=actor_id,
)
except Exception as e:
pass
project = Project.objects.get(pk=project_id)
issue_subscribers = list(
set(issue_subscribers + issue_assignees) - {uuid.UUID(actor_id)}
)
for subscriber in issue_subscribers:
if subscriber in issue_subscribers:
sender = "in_app:issue_activities:subscribed"
if (
issue.created_by_id is not None
and subscriber == issue.created_by_id
):
sender = "in_app:issue_activities:created"
if subscriber in issue_assignees:
sender = "in_app:issue_activities:assigned"
for issue_activity in issue_activities_created:
# Do not send notification for description update
if issue_activity.get("field") == "description":
continue
issue_comment = issue_activity.get("issue_comment")
issue_comment_new_value = issue_activity.get("new_value")
issue_comment_old_value = issue_activity.get("old_value")
if issue_comment is not None:
issue_comment = IssueComment.objects.get(
id=issue_comment,
issue_id=issue_id,
project_id=project_id,
workspace_id=project.workspace_id,
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
all_comment_mentions = (
all_comment_mentions
+ extract_comment_mentions(issue_comment_new_value)
)
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender=sender,
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
title=issue_activity.get("comment"),
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")),
"field": str(issue_activity.get("field")),
"actor": str(issue_activity.get("actor_id")),
"new_value": str(
issue_activity.get("new_value")
),
"old_value": str(
issue_activity.get("old_value")
),
"issue_comment": str(
issue_comment.comment_stripped
if issue_activity.get("issue_comment")
is not None
else ""
),
},
},
new_comment_mentions = get_new_comment_mentions(
old_value=issue_comment_old_value,
new_value=issue_comment_new_value,
)
comment_mentions = comment_mentions + new_comment_mentions
comment_mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id,
issue_id=issue_id,
mentions=all_comment_mentions,
)
"""
We will not send subscription activity notification to the below mentioned user sets
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
- When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification
- When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification
"""
# ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
issue_subscribers = list(
IssueSubscriber.objects.filter(
project_id=project_id, issue_id=issue_id
)
.exclude(
subscriber_id__in=list(
new_mentions + comment_mentions + [actor_id]
)
)
.values_list("subscriber", flat=True)
)
# Add Mentioned as Issue Subscribers
IssueSubscriber.objects.bulk_create(
mention_subscribers + comment_mention_subscribers, batch_size=100
)
issue = Issue.objects.filter(pk=issue_id).first()
last_activity = (
IssueActivity.objects.filter(issue_id=issue_id)
.order_by("-created_at")
.first()
)
actor = User.objects.get(pk=actor_id)
for mention_id in comment_mentions:
if mention_id != actor_id:
for issue_activity in issue_activities_created:
notification = createMentionNotification(
project=project,
issue=issue,
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}",
actor_id=actor_id,
mention_id=mention_id,
issue_id=issue_id,
activity=issue_activity,
if subscriber:
# add the user to issue subscriber
try:
_ = IssueSubscriber.objects.get_or_create(
project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
)
bulk_notifications.append(notification)
except Exception as e:
pass
for mention_id in new_mentions:
if mention_id != actor_id:
if (
last_activity is not None
and last_activity.field == "description"
and actor_id == str(last_activity.actor_id)
project = Project.objects.get(pk=project_id)
issue_assignees = IssueAssignee.objects.filter(
issue_id=issue_id, project_id=project_id
).values_list("assignee", flat=True)
issue_subscribers = list(
set(issue_subscribers) - {uuid.UUID(actor_id)}
)
for subscriber in issue_subscribers:
if issue.created_by_id and issue.created_by_id == subscriber:
sender = "in_app:issue_activities:created"
elif (
subscriber in issue_assignees
and issue.created_by_id not in issue_assignees
):
sender = "in_app:issue_activities:assigned"
else:
sender = "in_app:issue_activities:subscribed"
preference = UserNotificationPreference.objects.get(
user_id=subscriber
)
for issue_activity in issue_activities_created:
# If activity done in blocking then blocked by email should not go
if issue_activity.get("issue_detail").get("id") != issue_id:
continue;
# Do not send notification for description update
if issue_activity.get("field") == "description":
continue
# Check if the value should be sent or not
send_email = False
if (
issue_activity.get("field") == "state"
and preference.state_change
):
send_email = True
elif (
issue_activity.get("field") == "state"
and preference.issue_completed
and State.objects.filter(
project_id=project_id,
pk=issue_activity.get("new_identifier"),
group="completed",
).exists()
):
send_email = True
elif (
issue_activity.get("field") == "comment"
and preference.comment
):
send_email = True
elif preference.property_change:
send_email = True
else:
send_email = False
# If activity is of issue comment fetch the comment
issue_comment = (
IssueComment.objects.filter(
id=issue_activity.get("issue_comment"),
issue_id=issue_id,
project_id=project_id,
workspace_id=project.workspace_id,
).first()
if issue_activity.get("issue_comment")
else None
)
# Create in app notification
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities:mentioned",
sender=sender,
triggered_by_id=actor_id,
receiver_id=mention_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
message=f"You have been mentioned in the issue {issue.name}",
title=issue_activity.get("comment"),
data={
"issue": {
"id": str(issue_id),
@@ -465,36 +405,317 @@ def notifications(
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(last_activity.id),
"verb": str(last_activity.verb),
"field": str(last_activity.field),
"actor": str(last_activity.actor_id),
"new_value": str(last_activity.new_value),
"old_value": str(last_activity.old_value),
"id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")),
"field": str(issue_activity.get("field")),
"actor": str(
issue_activity.get("actor_id")
),
"new_value": str(
issue_activity.get("new_value")
),
"old_value": str(
issue_activity.get("old_value")
),
"issue_comment": str(
issue_comment.comment_stripped
if issue_comment is not None
else ""
),
},
},
)
)
else:
# Create email notification
if send_email:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(
issue.project.identifier
),
"project_id": str(issue.project.id),
"workspace_slug": str(
issue.project.workspace.slug
),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(
issue_activity.get("verb")
),
"field": str(
issue_activity.get("field")
),
"actor": str(
issue_activity.get("actor_id")
),
"new_value": str(
issue_activity.get("new_value")
),
"old_value": str(
issue_activity.get("old_value")
),
"issue_comment": str(
issue_comment.comment_stripped
if issue_comment is not None
else ""
),
"activity_time": issue_activity.get("created_at"),
},
},
)
)
# ----------------------------------------------------------------------------------------------------------------- #
# Add Mentioned as Issue Subscribers
IssueSubscriber.objects.bulk_create(
mention_subscribers + comment_mention_subscribers,
batch_size=100,
ignore_conflicts=True,
)
last_activity = (
IssueActivity.objects.filter(issue_id=issue_id)
.order_by("-created_at")
.first()
)
actor = User.objects.get(pk=actor_id)
for mention_id in comment_mentions:
if mention_id != actor_id:
preference = UserNotificationPreference.objects.get(
user_id=mention_id
)
for issue_activity in issue_activities_created:
notification = createMentionNotification(
notification = create_mention_notification(
project=project,
issue=issue,
notification_comment=f"You have been mentioned in the issue {issue.name}",
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}",
actor_id=actor_id,
mention_id=mention_id,
issue_id=issue_id,
activity=issue_activity,
)
# check for email notifications
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(
issue.project.identifier
),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
"project_id": str(
issue.project.id
),
"workspace_slug": str(
issue.project.workspace.slug
),
},
"issue_activity": {
"id": str(
issue_activity.get("id")
),
"verb": str(
issue_activity.get("verb")
),
"field": str("mention"),
"actor": str(
issue_activity.get("actor_id")
),
"new_value": str(
issue_activity.get("new_value")
),
"old_value": str(
issue_activity.get("old_value")
),
},
},
)
)
bulk_notifications.append(notification)
# save new mentions for the particular issue and remove the mentions that has been deleted from the description
update_mentions_for_issue(
issue=issue,
project=project,
new_mentions=new_mentions,
removed_mention=removed_mention,
)
for mention_id in new_mentions:
if mention_id != actor_id:
preference = UserNotificationPreference.objects.get(
user_id=mention_id
)
if (
last_activity is not None
and last_activity.field == "description"
and actor_id == str(last_activity.actor_id)
):
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities:mentioned",
triggered_by_id=actor_id,
receiver_id=mention_id,
entity_identifier=issue_id,
entity_name="issue",
project=project,
message=f"You have been mentioned in the issue {issue.name}",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(
issue.project.identifier
),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
"project_id": str(issue.project.id),
"workspace_slug": str(
issue.project.workspace.slug
),
},
"issue_activity": {
"id": str(last_activity.id),
"verb": str(last_activity.verb),
"field": str(last_activity.field),
"actor": str(last_activity.actor_id),
"new_value": str(
last_activity.new_value
),
"old_value": str(
last_activity.old_value
),
},
},
)
)
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(
issue.project.identifier
),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(last_activity.id),
"verb": str(last_activity.verb),
"field": "mention",
"actor": str(
last_activity.actor_id
),
"new_value": str(
last_activity.new_value
),
"old_value": str(
last_activity.old_value
),
},
},
)
)
else:
for issue_activity in issue_activities_created:
notification = create_mention_notification(
project=project,
issue=issue,
notification_comment=f"You have been mentioned in the issue {issue.name}",
actor_id=actor_id,
mention_id=mention_id,
issue_id=issue_id,
activity=issue_activity,
)
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(
issue.project.identifier
),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(
issue_activity.get("id")
),
"verb": str(
issue_activity.get("verb")
),
"field": str("mention"),
"actor": str(
issue_activity.get(
"actor_id"
)
),
"new_value": str(
issue_activity.get(
"new_value"
)
),
"old_value": str(
issue_activity.get(
"old_value"
)
),
},
},
)
)
bulk_notifications.append(notification)
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
# save new mentions for the particular issue and remove the mentions that has been deleted from the description
update_mentions_for_issue(
issue=issue,
project=project,
new_mentions=new_mentions,
removed_mention=removed_mention,
)
# Bulk create notifications
Notification.objects.bulk_create(
bulk_notifications, batch_size=100
)
EmailNotificationLog.objects.bulk_create(
bulk_email_logs, batch_size=100, ignore_conflicts=True
)
return
except Exception as e:
print(e)
return
+5
View File
@@ -2,6 +2,7 @@ import os
from celery import Celery
from plane.settings.redis import redis_instance
from celery.schedules import crontab
from django.utils.timezone import timedelta
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
@@ -28,6 +29,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.file_asset_task.delete_file_asset",
"schedule": crontab(hour=0, minute=0),
},
"check-every-five-minutes-to-send-email-notifications": {
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
"schedule": crontab(minute='*/5')
},
}
# Load task modules from all registered Django app configs.
@@ -0,0 +1,21 @@
# wait_for_migrations.py
import time
from django.core.management.base import BaseCommand
from django.db.migrations.executor import MigrationExecutor
from django.db import connections, DEFAULT_DB_ALIAS
class Command(BaseCommand):
help = 'Wait for database migrations to complete before starting Celery worker/beat'
def handle(self, *args, **kwargs):
while self._pending_migrations():
self.stdout.write("Waiting for database migrations to complete...")
time.sleep(10) # wait for 10 seconds before checking again
self.stdout.write(self.style.SUCCESS("No migrations Pending. Starting processes ..."))
def _pending_migrations(self):
connection = connections[DEFAULT_DB_ALIAS]
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
return bool(executor.migration_plan(targets))
@@ -0,0 +1,184 @@
# Generated by Django 4.2.7 on 2024-01-22 08:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0055_auto_20240108_0648"),
]
operations = [
migrations.CreateModel(
name="UserNotificationPreference",
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,
),
),
("property_change", models.BooleanField(default=True)),
("state_change", models.BooleanField(default=True)),
("comment", models.BooleanField(default=True)),
("mention", models.BooleanField(default=True)),
("issue_completed", models.BooleanField(default=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_notification_preferences",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_preferences",
to=settings.AUTH_USER_MODEL,
),
),
(
"workspace",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_notification_preferences",
to="db.workspace",
),
),
],
options={
"verbose_name": "UserNotificationPreference",
"verbose_name_plural": "UserNotificationPreferences",
"db_table": "user_notification_preferences",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="EmailNotificationLog",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("entity_identifier", models.UUIDField(null=True)),
("entity_name", models.CharField(max_length=255)),
("data", models.JSONField(null=True)),
("processed_at", models.DateTimeField(null=True)),
("sent_at", models.DateTimeField(null=True)),
("entity", models.CharField(max_length=200)),
(
"old_value",
models.CharField(blank=True, max_length=300, null=True),
),
(
"new_value",
models.CharField(blank=True, max_length=300, null=True),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"receiver",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="email_notifications",
to=settings.AUTH_USER_MODEL,
),
),
(
"triggered_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="triggered_emails",
to=settings.AUTH_USER_MODEL,
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
],
options={
"verbose_name": "Email Notification Log",
"verbose_name_plural": "Email Notification Logs",
"db_table": "email_notification_logs",
"ordering": ("-created_at",),
},
),
]
@@ -0,0 +1,28 @@
# Generated by Django 4.2.7 on 2024-01-22 09:01
from django.db import migrations
def create_notification_preferences(apps, schema_editor):
UserNotificationPreference = apps.get_model("db", "UserNotificationPreference")
User = apps.get_model("db", "User")
bulk_notification_preferences = []
for user_id in User.objects.filter(is_bot=False).values_list("id", flat=True):
bulk_notification_preferences.append(
UserNotificationPreference(
user_id=user_id,
created_by_id=user_id,
)
)
UserNotificationPreference.objects.bulk_create(
bulk_notification_preferences, batch_size=1000, ignore_conflicts=True
)
class Migration(migrations.Migration):
dependencies = [
("db", "0056_usernotificationpreference_emailnotificationlog"),
]
operations = [
migrations.RunPython(create_notification_preferences)
]
@@ -0,0 +1,120 @@
# Generated by Django 4.2.7 on 2024-01-29 07:11
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
def create_project_preferences(apps, schema_editor):
ProjectFeature = apps.get_model("db", "ProjectFeature")
Project = apps.get_model("db", "Project")
ProjectFeature.objects.bulk_create(
[
ProjectFeature(
project_id=project.get("id"),
workspace_id=project.get("workspace_id"),
cycles=project.get("cycle_view"),
modules=project.get("module_view"),
views=project.get("issue_views_view"),
pages=project.get("page_view"),
inbox=project.get("inbox_view"),
)
for project in Project.objects.values(
"id",
"workspace_id",
"cycle_view",
"inbox_view",
"issue_views_view",
"page_view",
"module_view",
)
],
batch_size=100,
)
class Migration(migrations.Migration):
dependencies = [
("db", "0057_auto_20240122_0901"),
]
operations = [
migrations.CreateModel(
name="ProjectFeature",
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,
),
),
("modules", models.BooleanField(default=True)),
("cycles", models.BooleanField(default=True)),
("views", models.BooleanField(default=True)),
("pages", models.BooleanField(default=True)),
("inbox", models.BooleanField(default=False)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="features",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_features",
to="db.workspace",
),
),
],
options={
"verbose_name": "Project Feature",
"verbose_name_plural": "Project Features",
"db_table": "project_features",
"ordering": ("-created_at",),
},
),
migrations.RunPython(create_project_preferences),
]
+2 -1
View File
@@ -22,6 +22,7 @@ from .project import (
ProjectFavorite,
ProjectDeployBoard,
ProjectPublicMember,
ProjectFeature,
)
from .issue import (
@@ -85,7 +86,7 @@ from .inbox import Inbox, InboxIssue
from .analytic import AnalyticView
from .notification import Notification
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
from .exporter import ExporterHistory
+12
View File
@@ -9,6 +9,7 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.exceptions import ValidationError
from django.utils import timezone
# Module imports
from . import ProjectBaseModel
@@ -183,6 +184,17 @@ class Issue(ProjectBaseModel):
self.state = default_state
except ImportError:
pass
else:
try:
from plane.db.models import State
# Check if the current issue state group is completed or not
if self.state.group == "completed":
self.completed_at = timezone.now()
else:
self.completed_at = None
except ImportError:
pass
if self._state.adding:
# Get the maximum display_id value from the database
+82 -3
View File
@@ -1,9 +1,9 @@
# Django imports
from django.db import models
from django.conf import settings
# Third party imports
from .base import BaseModel
# Module imports
from . import BaseModel
class Notification(BaseModel):
workspace = models.ForeignKey(
@@ -47,3 +47,82 @@ class Notification(BaseModel):
def __str__(self):
"""Return name of the notifications"""
return f"{self.receiver.email} <{self.workspace.name}>"
def get_default_preference():
return {
"property_change": {
"email": True,
},
"state": {
"email": True,
},
"comment": {
"email": True,
},
"mentions": {
"email": True,
},
}
class UserNotificationPreference(BaseModel):
# user it is related to
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notification_preferences",
)
# workspace if it is applicable
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_notification_preferences",
null=True,
)
# project
project = models.ForeignKey(
"db.Project",
on_delete=models.CASCADE,
related_name="project_notification_preferences",
null=True,
)
# preference fields
property_change = models.BooleanField(default=True)
state_change = models.BooleanField(default=True)
comment = models.BooleanField(default=True)
mention = models.BooleanField(default=True)
issue_completed = models.BooleanField(default=True)
class Meta:
verbose_name = "UserNotificationPreference"
verbose_name_plural = "UserNotificationPreferences"
db_table = "user_notification_preferences"
ordering = ("-created_at",)
def __str__(self):
"""Return the user"""
return f"<{self.user}>"
class EmailNotificationLog(BaseModel):
# receiver
receiver = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="email_notifications")
triggered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="triggered_emails")
# entity - can be issues, pages, etc.
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(max_length=255)
# data
data = models.JSONField(null=True)
# sent at
processed_at = models.DateTimeField(null=True)
sent_at = models.DateTimeField(null=True)
entity = models.CharField(max_length=200)
old_value = models.CharField(max_length=300, blank=True, null=True)
new_value = models.CharField(max_length=300, blank=True, null=True)
class Meta:
verbose_name = "Email Notification Log"
verbose_name_plural = "Email Notification Logs"
db_table = "email_notification_logs"
ordering = ("-created_at",)
+30 -5
View File
@@ -89,11 +89,6 @@ class Project(BaseModel):
)
emoji = models.CharField(max_length=255, null=True, blank=True)
icon_prop = models.JSONField(null=True)
module_view = models.BooleanField(default=True)
cycle_view = models.BooleanField(default=True)
issue_views_view = models.BooleanField(default=True)
page_view = models.BooleanField(default=True)
inbox_view = models.BooleanField(default=False)
cover_image = models.URLField(blank=True, null=True, max_length=800)
estimate = models.ForeignKey(
"db.Estimate",
@@ -146,6 +141,36 @@ class ProjectBaseModel(BaseModel):
super(ProjectBaseModel, self).save(*args, **kwargs)
class ProjectFeature(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="project_features",
)
project = models.OneToOneField(
Project, on_delete=models.CASCADE, related_name="features"
)
modules = models.BooleanField(default=False)
cycles = models.BooleanField(default=False)
views = models.BooleanField(default=False)
pages = models.BooleanField(default=True)
inbox = models.BooleanField(default=False)
def save(self, *args, **kwargs):
self.workspace = self.project.workspace
super(ProjectFeature, self).save(*args, **kwargs)
def __str__(self):
"""Return feature of the project"""
return f"{self.project.name} <{self.id}>"
class Meta:
verbose_name = "Project Feature"
verbose_name_plural = "Project Features"
db_table = "project_features"
ordering = ("-created_at",)
class ProjectMemberInvite(ProjectBaseModel):
email = models.CharField(max_length=255)
accepted = models.BooleanField(default=False)
+39
View File
@@ -11,8 +11,16 @@ from django.contrib.auth.models import (
UserManager,
PermissionsMixin,
)
from django.db.models.signals import post_save
from django.conf import settings
from django.dispatch import receiver
from django.utils import timezone
# Third party imports
from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
def get_default_onboarding():
return {
@@ -134,3 +142,34 @@ class User(AbstractBaseUser, PermissionsMixin):
self.is_staff = True
super(User, self).save(*args, **kwargs)
@receiver(post_save, sender=User)
def send_welcome_slack(sender, instance, created, **kwargs):
try:
if created and not instance.is_bot:
# Send message on slack as well
if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN)
try:
_ = client.chat_postMessage(
channel="#trackers",
text=f"New user {instance.email} has signed up and begun the onboarding journey.",
)
except SlackApiError as e:
print(f"Got an error: {e.response['error']}")
return
except Exception as e:
capture_exception(e)
return
@receiver(post_save, sender=User)
def create_user_notification(sender, instance, created, **kwargs):
# create preferences
if created and not instance.is_bot:
# Module imports
from plane.db.models import UserNotificationPreference
UserNotificationPreference.objects.create(
user=instance,
)
+1
View File
@@ -291,6 +291,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.issue_automation_task",
"plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
)
# Sentry Settings
+5 -1
View File
@@ -4,6 +4,7 @@ from datetime import timedelta
# Django import
from django.db import models
from django.utils import timezone
from django.db.models.functions import TruncDate
from django.db.models import Count, F, Sum, Value, Case, When, CharField
from django.db.models.functions import (
@@ -168,6 +169,9 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
if item["date"] is not None and item["date"] <= date
)
cumulative_pending_issues -= total_completed
chart_data[str(date)] = cumulative_pending_issues
if date > timezone.now().date():
chart_data[str(date)] = None
else:
chart_data[str(date)] = cumulative_pending_issues
return chart_data
+1 -1
View File
@@ -1 +1 @@
python-3.11.6
python-3.11.7
@@ -0,0 +1,978 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Updates on issue</title>
<style type="text/css" emogrify="no">
html {
font-family: system-ui;
}
p,
h1,
h2,
h3,
h4,
ol,
ul {
margin: 0;
}
h-full {
height: 100%;
}
a:hover {
color: #3358d4 !important;
}
</style>
</head>
<body
bgcolor="#ffffff"
text="#3b3f44"
link="#3f76ff"
yahoo="fix"
style="background-color: #f7f9ff; margin: 20px"
>
<div
style="
width: 600px;
table-layout: fixed;
height: 100%;
margin-left: auto;
margin-right: auto;
"
>
<!-- Header -->
<div>
<table style="width: 600px" cellspacing="0">
<tr>
<td>
<div
style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px"
>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.webp"
width="130"
height="40"
border="0"
/>
</div>
</td>
</tr>
</table>
</div>
<!-- Body -->
<div
style="
color: #1f2d5c;
padding: 30px;
border-radius: 4px;
background-color: #fcfcfd;
max-width: 100%;
"
>
<div>
<table style="width: 100%">
<tr>
<td>
<p style="font-size: 1rem; color: #1f2d5c; font-weight: 600">
{{ issue.issue_identifier }} updates
</p>
<p style="font-size: 1rem; font-weight: 500; color: #1f2d5c; line-height: 28px">
{{ issue.name }}: {{ issue.issue_identifier }}
</p>
</td>
</tr>
</table>
<hr
style="
background-color: #f0f0f3;
height: 1px;
border: 0;
margin-top: 15px;
margin-bottom: 15px;
"
/>
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28pxl">
{% if data.1 %}{{ data|length }}{% endif %} {{ summary }}
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{{ data.0.actor_detail.first_name}}
{{data.0.actor_detail.last_name }}
</span>
</p>
{% if comments.0 %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
{{ comments|length }} {% if comments|length == 1 %}comment was{% else %}comments were{% endif %} left by
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{% if comments|length == 1 %}
{{ data.0.actor_detail.first_name }}
{{ data.0.actor_detail.last_name }}
{% else %}
{{ data.0.actor_detail.first_name }}
{{ data.0.actor_detail.last_name }} and others
{% endif %}
</span>
</p>
{% endif %}
{% if mentions and comments.0 and data.0 %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
There are 3 new updates, added 1 new comment and, you were
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
@{{ data.0.actor_detail.first_name}}
{{data.0.actor_detail.last_name }}
</span>
mentioned a comment of this issue.
</p>
{% endif %}
{% for update in data %} {% if update.changes.name %}
<!-- Issue title updated -->
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c">
The issue title has been updated from “{{update.changes.user.old_value.0}}“ to "{{update.changes.user.new_value|last}}"
</p>
{% endif %}
<!-- Outer update Box start -->
{% if data %}
<div
style="
background-color: #f7f9ff;
border-radius: 8px;
border-style: solid;
border-width: 1px;
border-color: #c1d0ff;
padding: 20px;
margin-top: 15px;
max-width: 100%;
"
>
<!-- Block Heading -->
<div style="padding-bottom: 20px">
<p style="font-size: 0.8rem; font-weight: 600; color: #121a26">
Updates
</p>
</div>
<!-- Property Updates -->
<div
style="
background-color: white;
max-width: 100%;
overflow: hidden;
overflow-wrap: break-word;
word-wrap: break-word;
padding-left: 15px;
padding-bottom: 15px;
"
>
<!-- action performer -->
<table
role="presentation"
cellspacing="0"
cellpadding="0"
style="max-width: 100%"
>
<tr style="border-radius: 8px; margin-top: 20px">
<td style="width: 30px">
{% if update.actor_detail.avatar_url %}
<img src="{{ update.actor_detail.avatar_url }}" width="25" height="25" border="0" />
{% else %}
<table cellspacing="0" cellpadding="0">
<tr>
<td
style="
width: 25px;
height: 25px;
background-color: #4f3422;
border-radius: 50%;
text-align: center;
"
>
<span
style="
color: white;
font-weight: 500;
font-size: 13px;
line-height: 25px;
display: inline-block;
"
>
{{ update.actor_detail.first_name.0 }}
</span>
</td>
</tr>
</table>
{% endif %}
</td>
<td style="padding-top: 20px; padding-bottom: 20px">
<p
style="
font-weight: 500;
font-size: 0.8rem;
color: #1c2024;
width: fit-content;
margin-left: 5px;
"
>
{{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }}
</p>
</td>
<td>
<p
style="
font-weight: 500;
font-size: 0.6rem;
color: #80838d;
margin-left: 10px;
"
>
{{ update.activity_time }}
</p>
</td>
</tr>
</table>
{% if update.changes.target_date %}
<!-- due date changed -->
<table
role="presentation"
style="max-width: 100%; padding-bottom: 15px"
>
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/due-date.webp"
width="12"
height="12"
border="0"
style="display: block"
/>
</td>
<td style="padding: 0px; text-align: center">
<div>
<p
style="
padding: 0px;
margin: 0px;
font-size: 0.8rem;
font-weight: 500;
color: #525252;
"
>
Due Date:
</p>
</div>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #171717;
margin-left: 5px;
padding: 0px;
"
>
{{ update.changes.target_date.new_value.0 }}
</p>
</td>
</tr>
</table>
{% endif %} {% if update.changes.duplicate %}
<!-- duplicate changed -->
<table
role="presentation"
style="max-width: 100%; padding-bottom: 15px"
>
<tr>
<td style="overflow-wrap: break-word;">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/duplicate.webp"
width="12"
height="12"
border="0"
style="display: inline-block"
/>
<span
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
"
>
Duplicate:
</span>
</td>
<td style="padding-left: 5px;">
{% for duplicate in update.changes.duplicate.new_value %}
<span
style="
font-size: 0.8rem;
font-weight: 500;
color: #3a5bc7;
margin-right: 3px;
padding-top: 0px;
"
>
{{ duplicate }}
</span>
{% endfor %}
</td>
</tr>
</table>
{% endif %}
<!-- Assignee changed-->
{% if update.changes.assignees %}
<table
role="presentation"
style="max-width: 100%; padding-bottom: 15px"
>
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/assignee.webp"
width="12"
height="12"
border="0"
style="display: block"
/>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
"
>
Assignees:
</p>
</td>
{% if update.changes.assignees.new_value.0 %}
<td>
<p
style="
font-size: 0.8rem;
color: #0d74ce;
background-color: #e6f4fe;
margin-left: 5px;
padding-left: 4px;
padding-right: 4px;
padding-top: 2px;
padding-bottom: 2px;
font-weight: 500;
border-radius: 4px;
"
>
{{ update.changes.assignees.new_value.0 }}
</p>
</td>
{% endif %} {% if update.changes.assignees.new_value.1 %}
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #0d74ce;
margin-left: 2px;
"
>
+{{ update.changes.assignees.new_value|length|add:"-1"}}
more
</p>
</td>
{% endif %} {% if update.changes.assignees.old_value.0 %}
<td>
<p
style="
font-size: 0.8rem;
padding-left: 4px;
padding-right: 4px;
padding-top: 2px;
padding-bottom: 2px;
font-weight: 500;
text-decoration: line-through;
color: #641723;
background-color: #feebec;
margin-left: 5px;
border-radius: 4px;
"
>
{{update.changes.assignees.old_value.0}}
</p>
</td>
{% endif %} {% if update.changes.assignees.old_value.1 %}
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #641723;
margin-left: 2px;
"
>
+{{ update.changes.assignees.old_value|length|add:"-1"}}
more
</p>
</td>
{% endif %}
</tr>
</table>
{% endif %} {% if update.changes.labels %}
<!-- Labels -->
<table
role="presentation"
style="padding-bottom: 15px; max-width: 100%"
>
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/labels.webp"
width="12"
height="12"
border="0"
style="display: block"
/>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
"
>
Labels:
</p>
</td>
{% if update.changes.labels.new_value.0 %}
<td>
<p
style="
font-size: 0.8rem;
color: #0d74ce;
background-color: #e6f4fe;
margin-left: 5px;
padding-left: 4px;
padding-right: 4px;
padding-top: 2px;
padding-bottom: 2px;
font-weight: 500;
border-radius: 4px;
"
>
{{update.changes.labels.new_value.0}}
</p>
</td>
{% endif %}
{% if update.changes.labels.new_value.1 %}
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #0d74ce;
margin-left: 2px;
"
>
+{{ update.changes.labels.new_value|length|add:"-1"}} more
</p>
</td>
{% endif %}
{% if update.changes.labels.old_value.0 %}
<td>
<p
style="
font-size: 0.8rem;
padding-left: 4px;
padding-right: 4px;
padding-top: 2px;
padding-bottom: 2px;
font-weight: 500;
text-decoration: line-through;
color: #641723;
background-color: #feebec;
margin-left: 5px;
border-radius: 4px;
"
>
{{update.changes.labels.old_value.0}}
</p>
</td>
{% endif %}
{% if update.changes.labels.old_value.1 %}
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #641723;
margin-left: 2px;
"
>
+{{ update.changes.labels.old_value|length|add:"-1"}} more
</p>
</td>
{% endif %}
</tr>
</table>
{% endif %}
<!-- State changed -->
{% if update.changes.state %}
<table
role="presentation"
style="padding-bottom: 15px; max-width: 100%"
>
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/state.webp"
width="12"
height="12"
border="0"
style="display: block"
/>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
"
>
State:
</p>
</td>
<td >
<img
src="{% if update.changes.state.old_value.0 == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% endif %}{% if update.changes.state.old_value.0 == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% endif %}{% if update.changes.state.old_value.0 == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% endif %}{% if update.changes.state.old_value.0 == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}"
width="12"
height="12"
border="0"
style="display: block; margin-left: 5px;"
/>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #60646C;
"
>
{{ update.changes.state.old_value.0 }}
</p>
</td>
<td style="padding-left: 10px; padding-right: 10px;">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp"
width="16"
height="16"
border="0"
style="display: block;"
/>
</td>
<td >
<img
src="{% if update.changes.state.new_value|last == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% elif update.changes.state.new_value|last == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% elif update.changes.state.new_value|last == 'Todo' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/todo.webp{% elif update.changes.state.new_value|last == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% elif update.changes.state.new_value|last == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}"
width="12"
height="12"
border="0"
style="display: block;"
/>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #60646c;
"
>
{{update.changes.state.new_value|last }}
</p>
</td>
</tr>
</table>
{% endif %} {% if update.changes.link %}
<!-- Link Added -->
<table
role="presentation"
style="padding-bottom: 15px; max-width: 100%"
>
<tr>
<td valign="top">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/link.webp"
width="12"
height="12"
border="0"
style="display: block; margin-top: 3px;"
/>
</td>
<td valign="top">
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
margin-right: 5px;
"
>
Links:
</p>
</td>
<td>
{% for link in update.changes.link.new_value %}
<a
href="{{link}}"
style="
font-size: 0.8rem;
font-weight: 600;
color: #3a5bc7;
display: block;
padding-bottom: 5px;
"
>
{{ link }}
</a>
{% endfor %}
{% if update.changes.link.old_value|length > 0 %}
{% if update.changes.link.old_value.0 != "None" %}
<p
href=""
style="
font-size: 0.8rem;
font-weight: 500;
color: #60646c;
"
>
2 Links were removed
</p>
{% endif %}
{% endif %}
</td>
</tr>
</table>
{% endif %}
{% if update.changes.priority %}
<!-- Priority changed -->
<table
role="presentation"
style="padding-bottom: 15px; max-width: 100%"
>
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/priority.webp"
width="12"
height="12"
border="0"
style="display: block; margin: 0px; padding: 0px"
/>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
margin-right: 5px;
"
>
Priority:
</p>
</td>
<td>
<p
style="
font-size: 0.8rem;
padding-left: 4px;
padding-right: 4px;
padding-bottom: 2px;
font-weight: 500;
border-radius: 4px;
text-transform: capitalize;
{% if update.changes.priority.old_value.0 == 'urgent' %}background-color: #FFDBDC; color: #CE2C31;{% endif %}
{% if update.changes.priority.old_value.0 == 'high' %}background-color: #FFE2C7; color: #F04610;{% endif %}
{% if update.changes.priority.old_value.0 == 'medium' %}background-color: #FEFBE9; color: #E2A336;{% endif %}
{% if update.changes.priority.old_value.0 == 'low' %}background-color: #E9F6E9; color: #2A7E3B;{% endif %}
"
>
{{ update.changes.priority.old_value.0 }}
</p>
</td>
<td style="padding-left: 10px; padding-right: 10px;">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp"
width="16"
height="16"
border="0"
style="display: block;"
/>
</td>
<td>
<p
style="
font-size: 0.8rem;
padding-left: 4px;
padding-right: 4px;
padding-bottom: 2px;
font-weight: 500;
border-radius: 4px;
text-transform: capitalize;
{% if update.changes.priority.new_value|last == 'urgent' %}background-color: #FFDBDC; color: #CE2C31;{% endif %}
{% if update.changes.priority.new_value|last == 'high' %}background-color: #FEEBEC; color: #CE2C31;{% endif %}
{% if update.changes.priority.new_value|last == 'medium' %}background-color: #FEFBE9; color: #E2A336;{% endif %}
{% if update.changes.priority.new_value|last == 'low' %}background-color: #E9F6E9; color: #2A7E3B;{% endif %}
"
>
{{ update.changes.priority.new_value|last }}
</p>
</td>
</tr>
</table>
{% endif %}
{% if update.changes.blocking.new_value %}
<!-- Blocking changed -->
<table
role="presentation"
style="padding-bottom: 15px; max-width: 100%"
>
<tr style="overflow-wrap: break-word;">
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/blocking.webp"
width="12"
height="12"
border="0"
style="display: inline-block;"
/>
<span
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
margin-right: 5px;
"
>
Blocking:
</span>
</td>
<td>
{% for blocking in update.changes.blocking.new_value %}
<span href="" style="font-size: 0.8rem; font-weight: 500; margin-right: 5px; color: #3358d4;">
{{blocking}}
</span>
{% endfor %}
</td>
</tr>
</table>
{% endif %}
</div>
</div>
{% endif %}
<!-- Outer update Box end -->
{% endfor %} {% if comments.0 %}
<!-- Comments outer update Box -->
<div
style="
max-width: 100%;
background-color: #f7f9ff;
border-radius: 8px;
border-style: solid;
border-width: 1px;
border-color: #c1d0ff;
padding: 20px;
margin-top: 15px;
"
>
<!-- Block Heading -->
<p style="font-size: 0.8rem; font-weight: 600; color: #121a26; padding-bottom: 20px;">
Comments
</p>
<!-- Comments -->
{% for comment in comments %}
<table cellspacing="0">
<tr style="border-radius: 8px">
<td valign="top">
{% if comment.actor_detail.avatar_url %}
<img
src="{{ comment.actor_detail.avatar_url }}"
width="25"
height="25"
border="0"
/>
{% else %}
<table cellspacing="0" cellpadding="0">
<tr>
<td
style="
width: 25px;
height: 25px;
background-color: #4f3422;
border-radius: 50%;
text-align: center;
"
>
<span
style="
color: white;
font-weight: 500;
font-size: 13px;
line-height: 25px;
display: inline-block;
"
>
{{ comment.actor_detail.first_name.0 }}
</span>
</td>
</tr>
</table>
{% endif %}
</td>
<td style="padding-bottom: 15px">
<table>
<tr>
<td>
<p
style="
font-weight: 500;
font-size: 0.8rem;
color: #1c2024;
margin-left: 8px;
"
>
{{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }}
</p>
</td>
</tr>
{% for actor_comment in comment.actor_comments.new_value %}
<tr>
<td>
<div
style="
padding: 6px 10px;
margin-left: 10px;
background-color: white;
font-size: 0.8rem;
color: #525252;
margin-top: 5px;
border-radius: 4px;
display: flex;
align-items: center;
"
>
<p>
{{ actor_comment|safe }}
</p>
</div>
</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
</table>
{% endfor %}
</div>
{% endif %}
</div>
<a href="{{ issue_url }}" style="text-decoration: none;">
<div
style="
max-width: min-content;
white-space: nowrap;
background-color: #3e63dd;
padding: 10px 15px;
border: 1px solid #2f4ba8;
border-radius: 4px;
margin-top: 15px;
cursor: pointer;
font-size: 0.8rem;
color: white;
"
>
View issue
</div>
</a>
</div>
<!-- Footer -->
<table style="width: 100%; padding: 20px; justify-content: center">
<tr>
<td>
<div style="font-size: 0.8rem; color: #1c2024">
This email was sent to
<a
href="mailto:{{receiver.email}}"
style="color: #3a5bc7; font-weight: 500; text-decoration: none"
>{{ receiver.email }}.</a
>
If you'd rather not receive this kind of email,
<a
href="{{ issue_url }}"
style="color: #3a5bc7; text-decoration: none"
>you can unsubscribe to the issue</a
>
or
<a
href="{{ user_preference }}"
style="color: #3a5bc7; text-decoration: none"
>manage your email preferences</a
>.
<!-- Github | LinkedIn | Twitter -->
<div style="margin-top: 60px; float: right">
<a
href="https://github.com/makeplane"
target="_blank"
style="margin-left: 10px; text-decoration: none"
>
<img
src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png"
width="25"
height="25"
border="0"
style="display: inline-block"
/>
</a>
<a
href="https://www.linkedin.com/company/planepowers/"
target="_blank"
style="margin-left: 10px; text-decoration: none"
>
<img
src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png"
width="25"
height="25"
border="0"
style="display: inline-block"
/>
</a>
<a
href="https://twitter.com/planepowers"
target="_blank"
style="margin-left: 10px; text-decoration: none"
>
<img
src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png"
width="25"
height="25"
border="0"
style="display: inline-block"
/>
</a>
</div>
</div>
</td>
</tr>
</table>
</div>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
if command -v curl &> /dev/null; then
sudo curl -sSL \
-o /usr/local/bin/plane-app \
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
else
sudo wget -q \
-O /usr/local/bin/plane-app \
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
fi
sudo chmod +x /usr/local/bin/plane-app
sudo sed -i 's/export BRANCH=${BRANCH:-master}/export BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
sudo plane-app --help
+713
View File
@@ -0,0 +1,713 @@
#!/bin/bash
function print_header() {
clear
cat <<"EOF"
---------------------------------------
____ _
| _ \| | __ _ _ __ ___
| |_) | |/ _` | '_ \ / _ \
| __/| | (_| | | | | __/
|_| |_|\__,_|_| |_|\___|
---------------------------------------
Project management tool from the future
---------------------------------------
EOF
}
function update_env_files() {
config_file=$1
key=$2
value=$3
# Check if the config file exists
if [ ! -f "$config_file" ]; then
echo "Config file not found. Creating a new one..." >&2
touch "$config_file"
fi
# Check if the key already exists in the config file
if grep -q "^$key=" "$config_file"; then
awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
else
echo "$key=$value" >> "$config_file"
fi
}
function read_env_file() {
config_file=$1
key=$2
# Check if the config file exists
if [ ! -f "$config_file" ]; then
echo "Config file not found. Creating a new one..." >&2
touch "$config_file"
fi
# Check if the key already exists in the config file
if grep -q "^$key=" "$config_file"; then
value=$(awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
echo "$value"
else
echo ""
fi
}
function update_config() {
config_file="$PLANE_INSTALL_DIR/config.env"
update_env_files "$config_file" "$1" "$2"
}
function read_config() {
config_file="$PLANE_INSTALL_DIR/config.env"
read_env_file "$config_file" "$1"
}
function update_env() {
config_file="$PLANE_INSTALL_DIR/.env"
update_env_files "$config_file" "$1" "$2"
}
function read_env() {
config_file="$PLANE_INSTALL_DIR/.env"
read_env_file "$config_file" "$1"
}
function show_message() {
print_header
if [ "$2" == "replace_last_line" ]; then
PROGRESS_MSG[-1]="$1"
else
PROGRESS_MSG+=("$1")
fi
for statement in "${PROGRESS_MSG[@]}"; do
echo "$statement"
done
}
function prepare_environment() {
show_message "Prepare Environment..." >&2
show_message "- Updating OS with required tools ✋" >&2
sudo apt-get update -y &> /dev/null
sudo apt-get upgrade -y &> /dev/null
required_tools=("curl" "awk" "wget" "nano" "dialog" "git")
for tool in "${required_tools[@]}"; do
if ! command -v $tool &> /dev/null; then
sudo apt install -y $tool &> /dev/null
fi
done
show_message "- OS Updated ✅" "replace_last_line" >&2
# Install Docker if not installed
if ! command -v docker &> /dev/null; then
show_message "- Installing Docker ✋" >&2
sudo curl -o- https://get.docker.com | bash -
if [ "$EUID" -ne 0 ]; then
dockerd-rootless-setuptool.sh install &> /dev/null
fi
show_message "- Docker Installed ✅" "replace_last_line" >&2
else
show_message "- Docker is already installed ✅" >&2
fi
update_config "PLANE_ARCH" "$CPU_ARCH"
update_config "DOCKER_VERSION" "$(docker -v | awk '{print $3}' | sed 's/,//g')"
update_config "PLANE_DATA_DIR" "$DATA_DIR"
update_config "PLANE_LOG_DIR" "$LOG_DIR"
# echo "TRUE"
echo "Environment prepared successfully ✅"
show_message "Environment prepared successfully ✅" >&2
show_message "" >&2
return 0
}
function download_plane() {
# Download Docker Compose File from github url
show_message "Downloading Plane Setup Files ✋" >&2
curl -H 'Cache-Control: no-cache, no-store' \
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
curl -H 'Cache-Control: no-cache, no-store' \
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
# if .env does not exists rename variables-upgrade.env to .env
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
fi
show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2
show_message "" >&2
echo "PLANE_DOWNLOADED"
return 0
}
function printUsageInstructions() {
show_message "" >&2
show_message "----------------------------------" >&2
show_message "Usage Instructions" >&2
show_message "----------------------------------" >&2
show_message "" >&2
show_message "To use the Plane Setup utility, use below commands" >&2
show_message "" >&2
show_message "Usage: plane-app [OPTION]" >&2
show_message "" >&2
show_message " start Start Server" >&2
show_message " stop Stop Server" >&2
show_message " restart Restart Server" >&2
show_message "" >&2
show_message "other options" >&2
show_message " -i, --install Install Plane" >&2
show_message " -c, --configure Configure Plane" >&2
show_message " -up, --upgrade Upgrade Plane" >&2
show_message " -un, --uninstall Uninstall Plane" >&2
show_message " -ui, --update-installer Update Plane Installer" >&2
show_message " -h, --help Show help" >&2
show_message "" >&2
show_message "" >&2
show_message "Application Data is stored in mentioned folders" >&2
show_message " - DB Data: $DATA_DIR/postgres" >&2
show_message " - Redis Data: $DATA_DIR/redis" >&2
show_message " - Minio Data: $DATA_DIR/minio" >&2
show_message "" >&2
show_message "" >&2
show_message "----------------------------------" >&2
show_message "" >&2
}
function build_local_image() {
show_message "- Downloading Plane Source Code ✋" >&2
REPO=https://github.com/makeplane/plane.git
CURR_DIR=$PWD
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch -q > /dev/null
sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml
show_message "- Plane Source Code Downloaded ✅" "replace_last_line" >&2
show_message "- Building Docker Images ✋" >&2
sudo docker compose --env-file=$PLANE_INSTALL_DIR/.env -f $PLANE_TEMP_CODE_DIR/build.yml build --no-cache
}
function check_for_docker_images() {
show_message "" >&2
# show_message "Building Plane Images" >&2
update_env "DOCKERHUB_USER" "makeplane"
update_env "PULL_POLICY" "always"
CURR_DIR=$(pwd)
if [ "$BRANCH" == "master" ]; then
update_env "APP_RELEASE" "latest"
export APP_RELEASE=latest
else
update_env "APP_RELEASE" "$BRANCH"
export APP_RELEASE=$BRANCH
fi
if [ $CPU_ARCH == "amd64" ] || [ $CPU_ARCH == "x86_64" ]; then
# show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2
echo "Building Plane Images for $CPU_ARCH is not required. Skipping..."
else
export DOCKERHUB_USER=myplane
show_message "Building Plane Images for $CPU_ARCH " >&2
update_env "DOCKERHUB_USER" "myplane"
update_env "PULL_POLICY" "never"
build_local_image
sudo rm -rf $PLANE_INSTALL_DIR/temp > /dev/null
show_message "- Docker Images Built ✅" "replace_last_line" >&2
sudo cd $CURR_DIR
fi
sudo sed -i "s|- pgdata:|- $DATA_DIR/postgres:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
sudo sed -i "s|- redisdata:|- $DATA_DIR/redis:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
show_message "Plane Images Downloaded ✅" "replace_last_line" >&2
}
function configure_plane() {
show_message "" >&2
show_message "Configuring Plane" >&2
show_message "" >&2
exec 3>&1
nginx_port=$(read_env "NGINX_PORT")
domain_name=$(read_env "DOMAIN_NAME")
upload_limit=$(read_env "FILE_SIZE_LIMIT")
NGINX_SETTINGS=$(dialog \
--ok-label "Next" \
--cancel-label "Skip" \
--backtitle "Plane Configuration" \
--title "Nginx Settings" \
--form "" \
0 0 0 \
"Port:" 1 1 "${nginx_port:-80}" 1 10 50 0 \
"Domain:" 2 1 "${domain_name:-localhost}" 2 10 50 0 \
"Upload Limit:" 3 1 "${upload_limit:-5242880}" 3 10 15 0 \
2>&1 1>&3)
save_nginx_settings=0
if [ $? -eq 0 ]; then
save_nginx_settings=1
nginx_port=$(echo "$NGINX_SETTINGS" | sed -n 1p)
domain_name=$(echo "$NGINX_SETTINGS" | sed -n 2p)
upload_limit=$(echo "$NGINX_SETTINGS" | sed -n 3p)
fi
smtp_host=$(read_env "EMAIL_HOST")
smtp_user=$(read_env "EMAIL_HOST_USER")
smtp_password=$(read_env "EMAIL_HOST_PASSWORD")
smtp_port=$(read_env "EMAIL_PORT")
smtp_from=$(read_env "EMAIL_FROM")
smtp_tls=$(read_env "EMAIL_USE_TLS")
smtp_ssl=$(read_env "EMAIL_USE_SSL")
SMTP_SETTINGS=$(dialog \
--ok-label "Next" \
--cancel-label "Skip" \
--backtitle "Plane Configuration" \
--title "SMTP Settings" \
--form "" \
0 0 0 \
"Host:" 1 1 "$smtp_host" 1 10 80 0 \
"User:" 2 1 "$smtp_user" 2 10 80 0 \
"Password:" 3 1 "$smtp_password" 3 10 80 0 \
"Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \
"From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \
"TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \
"SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \
2>&1 1>&3)
save_smtp_settings=0
if [ $? -eq 0 ]; then
save_smtp_settings=1
smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p)
smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p)
smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p)
smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p)
smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p)
smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p)
fi
external_pgdb_url=$(dialog \
--backtitle "Plane Configuration" \
--title "Using External Postgres Database ?" \
--ok-label "Next" \
--cancel-label "Skip" \
--inputbox "Enter your external database url" \
8 60 3>&1 1>&2 2>&3)
external_redis_url=$(dialog \
--backtitle "Plane Configuration" \
--title "Using External Redis Database ?" \
--ok-label "Next" \
--cancel-label "Skip" \
--inputbox "Enter your external redis url" \
8 60 3>&1 1>&2 2>&3)
aws_region=$(read_env "AWS_REGION")
aws_access_key=$(read_env "AWS_ACCESS_KEY_ID")
aws_secret_key=$(read_env "AWS_SECRET_ACCESS_KEY")
aws_bucket=$(read_env "AWS_S3_BUCKET_NAME")
AWS_S3_SETTINGS=$(dialog \
--ok-label "Next" \
--cancel-label "Skip" \
--backtitle "Plane Configuration" \
--title "AWS S3 Bucket Configuration" \
--form "" \
0 0 0 \
"Region:" 1 1 "$aws_region" 1 10 50 0 \
"Access Key:" 2 1 "$aws_access_key" 2 10 50 0 \
"Secret Key:" 3 1 "$aws_secret_key" 3 10 50 0 \
"Bucket:" 4 1 "$aws_bucket" 4 10 50 0 \
2>&1 1>&3)
save_aws_settings=0
if [ $? -eq 0 ]; then
save_aws_settings=1
aws_region=$(echo "$AWS_S3_SETTINGS" | sed -n 1p)
aws_access_key=$(echo "$AWS_S3_SETTINGS" | sed -n 2p)
aws_secret_key=$(echo "$AWS_S3_SETTINGS" | sed -n 3p)
aws_bucket=$(echo "$AWS_S3_SETTINGS" | sed -n 4p)
fi
# display dialogbox asking for confirmation to continue
CONFIRM_CONFIG=$(dialog \
--title "Confirm Configuration" \
--backtitle "Plane Configuration" \
--yes-label "Confirm" \
--no-label "Cancel" \
--yesno \
"
save_ngnix_settings: $save_nginx_settings
nginx_port: $nginx_port
domain_name: $domain_name
upload_limit: $upload_limit
save_smtp_settings: $save_smtp_settings
smtp_host: $smtp_host
smtp_user: $smtp_user
smtp_password: $smtp_password
smtp_port: $smtp_port
smtp_from: $smtp_from
smtp_tls: $smtp_tls
smtp_ssl: $smtp_ssl
save_aws_settings: $save_aws_settings
aws_region: $aws_region
aws_access_key: $aws_access_key
aws_secret_key: $aws_secret_key
aws_bucket: $aws_bucket
pdgb_url: $external_pgdb_url
redis_url: $external_redis_url
" \
0 0 3>&1 1>&2 2>&3)
if [ $? -eq 0 ]; then
if [ $save_nginx_settings == 1 ]; then
update_env "NGINX_PORT" "$nginx_port"
update_env "DOMAIN_NAME" "$domain_name"
update_env "WEB_URL" "http://$domain_name"
update_env "CORS_ALLOWED_ORIGINS" "http://$domain_name"
update_env "FILE_SIZE_LIMIT" "$upload_limit"
fi
# check enable smpt settings value
if [ $save_smtp_settings == 1 ]; then
update_env "EMAIL_HOST" "$smtp_host"
update_env "EMAIL_HOST_USER" "$smtp_user"
update_env "EMAIL_HOST_PASSWORD" "$smtp_password"
update_env "EMAIL_PORT" "$smtp_port"
update_env "EMAIL_FROM" "$smtp_from"
update_env "EMAIL_USE_TLS" "$smtp_tls"
update_env "EMAIL_USE_SSL" "$smtp_ssl"
fi
# check enable aws settings value
if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then
update_env "USE_MINIO" "0"
update_env "AWS_REGION" "$aws_region"
update_env "AWS_ACCESS_KEY_ID" "$aws_access_key"
update_env "AWS_SECRET_ACCESS_KEY" "$aws_secret_key"
update_env "AWS_S3_BUCKET_NAME" "$aws_bucket"
elif [[ -z $aws_access_key || -z $aws_secret_key ]] ; then
update_env "USE_MINIO" "1"
update_env "AWS_REGION" ""
update_env "AWS_ACCESS_KEY_ID" ""
update_env "AWS_SECRET_ACCESS_KEY" ""
update_env "AWS_S3_BUCKET_NAME" "uploads"
fi
if [ "$external_pgdb_url" != "" ]; then
update_env "DATABASE_URL" "$external_pgdb_url"
fi
if [ "$external_redis_url" != "" ]; then
update_env "REDIS_URL" "$external_redis_url"
fi
fi
exec 3>&-
}
function upgrade_configuration() {
upg_env_file="$PLANE_INSTALL_DIR/variables-upgrade.env"
# Check if the file exists
if [ -f "$upg_env_file" ]; then
# Read each line from the file
while IFS= read -r line; do
# Skip comments and empty lines
if [[ "$line" =~ ^\s*#.*$ ]] || [[ -z "$line" ]]; then
continue
fi
# Split the line into key and value
key=$(echo "$line" | cut -d'=' -f1)
value=$(echo "$line" | cut -d'=' -f2-)
current_value=$(read_env "$key")
if [ -z "$current_value" ]; then
update_env "$key" "$value"
fi
done < "$upg_env_file"
fi
}
function install() {
show_message ""
if [ "$(uname)" == "Linux" ]; then
OS="linux"
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
# check the OS
if [ "$OS_NAME" == "ubuntu" ]; then
OS_SUPPORTED=true
show_message "******** Installing Plane ********"
show_message ""
prepare_environment
if [ $? -eq 0 ]; then
download_plane
if [ $? -eq 0 ]; then
# create_service
check_for_docker_images
last_installed_on=$(read_config "INSTALLATION_DATE")
if [ "$last_installed_on" == "" ]; then
configure_plane
fi
printUsageInstructions
update_config "INSTALLATION_DATE" "$(date)"
show_message "Plane Installed Successfully ✅"
show_message ""
else
show_message "Download Failed ❌"
exit 1
fi
else
show_message "Initialization Failed ❌"
exit 1
fi
else
PROGRESS_MSG="❌❌❌ Unsupported OS Detected ❌❌❌"
show_message ""
exit 1
fi
else
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
show_message ""
exit 1
fi
}
function upgrade() {
if [ "$(uname)" == "Linux" ]; then
OS="linux"
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
# check the OS
if [ "$OS_NAME" == "ubuntu" ]; then
OS_SUPPORTED=true
prepare_environment
if [ $? -eq 0 ]; then
download_plane
if [ $? -eq 0 ]; then
check_for_docker_images
upgrade_configuration
update_config "UPGRADE_DATE" "$(date)"
show_message ""
show_message "Plane Upgraded Successfully ✅"
show_message ""
printUsageInstructions
else
show_message "Download Failed ❌"
exit 1
fi
else
show_message "Initialization Failed ❌"
exit 1
fi
else
PROGRESS_MSG="Unsupported OS Detected"
show_message ""
exit 1
fi
else
PROGRESS_MSG="Unsupported OS Detected : $(uname)"
show_message ""
exit 1
fi
}
function uninstall() {
if [ "$(uname)" == "Linux" ]; then
OS="linux"
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
# check the OS
if [ "$OS_NAME" == "ubuntu" ]; then
OS_SUPPORTED=true
show_message "******** Uninstalling Plane ********"
show_message ""
stop_server
# CHECK IF PLANE SERVICE EXISTS
# if [ -f "/etc/systemd/system/plane.service" ]; then
# sudo systemctl stop plane.service &> /dev/null
# sudo systemctl disable plane.service &> /dev/null
# sudo rm /etc/systemd/system/plane.service &> /dev/null
# sudo systemctl daemon-reload &> /dev/null
# fi
# show_message "- Plane Service removed ✅"
if ! [ -x "$(command -v docker)" ]; then
echo "DOCKER_NOT_INSTALLED" &> /dev/null
else
# Ask of user input to confirm uninstall docker ?
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
if [ $? -eq 0 ]; then
show_message "- Uninstalling Docker ✋"
sudo apt-get purge -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
sudo apt-get autoremove -y --purge docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
show_message "- Docker Uninstalled ✅" "replace_last_line" >&2
fi
fi
rm $PLANE_INSTALL_DIR/.env &> /dev/null
rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
rm $PLANE_INSTALL_DIR/config.env &> /dev/null
rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
show_message "- Configuration Cleaned ✅"
show_message ""
show_message "******** Plane Uninstalled ********"
show_message ""
show_message ""
show_message "Plane Configuration Cleaned with some exceptions"
show_message "- DB Data: $DATA_DIR/postgres"
show_message "- Redis Data: $DATA_DIR/redis"
show_message "- Minio Data: $DATA_DIR/minio"
show_message ""
show_message ""
show_message "Thank you for using Plane. We hope to see you again soon."
show_message ""
show_message ""
else
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
show_message ""
exit 1
fi
else
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
show_message ""
exit 1
fi
}
function start_server() {
docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml"
env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Starting Plane Server ✋"
docker compose -f $docker_compose_file --env-file=$env_file up -d
# Wait for containers to be running
echo "Waiting for containers to start..."
while ! docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
sleep 1
done
show_message "Plane Server Started ✅" "replace_last_line" >&2
else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi
}
function stop_server() {
docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml"
env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Stopping Plane Server ✋"
docker compose -f $docker_compose_file --env-file=$env_file down
show_message "Plane Server Stopped ✅" "replace_last_line" >&2
else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi
}
function restart_server() {
docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml"
env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Restarting Plane Server ✋"
docker compose -f $docker_compose_file --env-file=$env_file restart
show_message "Plane Server Restarted ✅" "replace_last_line" >&2
else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi
}
function show_help() {
# print_header
show_message "Usage: plane-app [OPTION]" >&2
show_message "" >&2
show_message " start Start Server" >&2
show_message " stop Stop Server" >&2
show_message " restart Restart Server" >&2
show_message "" >&2
show_message "other options" >&2
show_message " -i, --install Install Plane" >&2
show_message " -c, --configure Configure Plane" >&2
show_message " -up, --upgrade Upgrade Plane" >&2
show_message " -un, --uninstall Uninstall Plane" >&2
show_message " -ui, --update-installer Update Plane Installer" >&2
show_message " -h, --help Show help" >&2
show_message "" >&2
exit 1
}
function update_installer() {
show_message "Updating Plane Installer ✋" >&2
curl -H 'Cache-Control: no-cache, no-store' \
-s -o /usr/local/bin/plane-app \
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/1-click/install.sh?token=$(date +%s)
chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
}
export BRANCH=${BRANCH:-master}
export APP_RELEASE=$BRANCH
export DOCKERHUB_USER=makeplane
export PULL_POLICY=always
PLANE_INSTALL_DIR=/opt/plane
DATA_DIR=$PLANE_INSTALL_DIR/data
LOG_DIR=$PLANE_INSTALL_DIR/log
OS_SUPPORTED=false
CPU_ARCH=$(uname -m)
PROGRESS_MSG=""
USE_GLOBAL_IMAGES=1
mkdir -p $PLANE_INSTALL_DIR/{data,log}
if [ "$1" == "start" ]; then
start_server
elif [ "$1" == "stop" ]; then
stop_server
elif [ "$1" == "restart" ]; then
restart_server
elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then
install
elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then
configure_plane
printUsageInstructions
elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then
upgrade
elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then
uninstall
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ] ; then
update_installer
elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_help
else
show_help
fi
+12
View File
@@ -122,6 +122,18 @@ services:
- plane-db
- plane-redis
migrator:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-latest}
pull_policy: ${PULL_POLICY:-always}
restart: no
command: >
sh -c "python manage.py wait_for_db &&
python manage.py migrate"
depends_on:
- plane-db
- plane-redis
plane-db:
<<: *app-env
image: postgres:15.2-alpine
+23 -3
View File
@@ -86,7 +86,7 @@ services:
- dev_env
volumes:
- ./apiserver:/code
# command: /bin/sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local"
command: ./bin/takeoff.local
env_file:
- ./apiserver/.env
depends_on:
@@ -104,7 +104,7 @@ services:
- dev_env
volumes:
- ./apiserver:/code
command: /bin/sh -c "celery -A plane worker -l info"
command: ./bin/worker
env_file:
- ./apiserver/.env
depends_on:
@@ -123,7 +123,7 @@ services:
- dev_env
volumes:
- ./apiserver:/code
command: /bin/sh -c "celery -A plane beat -l info"
command: ./bin/beat
env_file:
- ./apiserver/.env
depends_on:
@@ -131,6 +131,26 @@ services:
- plane-db
- plane-redis
migrator:
build:
context: ./apiserver
dockerfile: Dockerfile.dev
args:
DOCKER_BUILDKIT: 1
restart: no
networks:
- dev_env
volumes:
- ./apiserver:/code
command: >
sh -c "python manage.py wait_for_db --settings=plane.settings.local &&
python manage.py migrate --settings=plane.settings.local"
env_file:
- ./apiserver/.env
depends_on:
- plane-db
- plane-redis
proxy:
build:
context: ./nginx
+3 -3
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.14.0",
"version": "0.15.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -15,7 +15,7 @@
],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"dev": "turbo run dev --concurrency=13",
"start": "turbo run start",
"lint": "turbo run lint",
"clean": "turbo run clean",
@@ -34,4 +34,4 @@
"@types/react": "18.2.42"
},
"packageManager": "yarn@1.22.19"
}
}
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor-core",
"version": "0.14.0",
"version": "0.15.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -30,7 +30,6 @@
"dependencies": {
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-code": "^2.1.13",
"@tiptap/extension-code-block-lowlight": "^2.1.13",
"@tiptap/extension-color": "^2.1.13",
"@tiptap/extension-image": "^2.1.13",
@@ -34,8 +34,32 @@ export const toggleUnderline = (editor: Editor, range?: Range) => {
};
export const toggleCodeBlock = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
else editor.chain().focus().toggleCodeBlock().run();
// Check if code block is active then toggle code block
if (editor.isActive("codeBlock")) {
if (range) {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
return;
}
editor.chain().focus().toggleCodeBlock().run();
return;
}
// Check if user hasn't selected any text
const isSelectionEmpty = editor.state.selection.empty;
if (isSelectionEmpty) {
if (range) {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
return;
}
editor.chain().focus().toggleCodeBlock().run();
} else {
if (range) {
editor.chain().focus().deleteRange(range).toggleCode().run();
return;
}
editor.chain().focus().toggleCode().run();
}
};
export const toggleOrderedList = (editor: Editor, range?: Range) => {
@@ -59,8 +83,8 @@ export const toggleStrike = (editor: Editor, range?: Range) => {
};
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();
if (range) editor.chain().focus().deleteRange(range).toggleBlockquote().run();
else editor.chain().focus().toggleBlockquote().run();
};
export const insertTableCommand = (editor: Editor, range?: Range) => {
@@ -12,7 +12,7 @@ export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, chil
<div
id="editor-container"
onClick={() => {
editor?.chain().focus().run();
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
}}
onMouseLeave={() => {
hideDragHandle?.();
@@ -1,12 +1,80 @@
import { markInputRule, markPasteRule } from "@tiptap/core";
import Code from "@tiptap/extension-code";
import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core";
export const inputRegex = /(?<!`)`([^`]*)`(?!`)/;
export const pasteRegex = /(?<!`)`([^`]+)`(?!`)/g;
export interface CodeOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
code: {
/**
* Set a code mark
*/
setCode: () => ReturnType;
/**
* Toggle inline code
*/
toggleCode: () => ReturnType;
/**
* Unset a code mark
*/
unsetCode: () => ReturnType;
};
}
}
export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/;
export const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g;
export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
name: "code",
addOptions() {
return {
HTMLAttributes: {
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
};
},
excludes: "_",
code: true,
export const CustomCodeInlineExtension = Code.extend({
exitable: true,
inclusive: false,
parseHTML() {
return [{ tag: "code" }];
},
renderHTML({ HTMLAttributes }) {
return ["code", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addCommands() {
return {
setCode:
() =>
({ commands }) =>
commands.setMark(this.name),
toggleCode:
() =>
({ commands }) =>
commands.toggleMark(this.name),
unsetCode:
() =>
({ commands }) =>
commands.unsetMark(this.name),
};
},
addKeyboardShortcuts() {
return {
"Mod-e": () => this.editor.commands.toggleCode(),
};
},
addInputRules() {
return [
markInputRule({
@@ -15,6 +83,7 @@ export const CustomCodeInlineExtension = Code.extend({
}),
];
},
addPasteRules() {
return [
markPasteRule({
@@ -23,9 +92,4 @@ export const CustomCodeInlineExtension = Code.extend({
}),
];
},
}).configure({
HTMLAttributes: {
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
});
@@ -53,11 +53,6 @@ export const CoreEditorExtensions = (
class: "leading-normal -mb-2",
},
},
hardBreak: {
HTMLAttributes: {
class: "p-2",
},
},
code: false,
codeBlock: false,
horizontalRule: false,
@@ -106,7 +106,7 @@ export const TodoListItem = (editor: Editor): EditorMenuItem => ({
export const CodeItem = (editor: Editor): EditorMenuItem => ({
name: "code",
isActive: () => editor?.isActive("code"),
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
});
@@ -120,7 +120,7 @@ export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
name: "quote",
isActive: () => editor?.isActive("quote"),
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/document-editor",
"version": "0.14.0",
"version": "0.15.0",
"description": "Package that powers Plane's Pages Editor",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
@@ -27,11 +27,20 @@ type IPageRenderer = {
}) => void;
editorClassNames: string;
editorContentCustomClassNames?: string;
hideDragHandle?: () => void;
readonly: boolean;
};
export const PageRenderer = (props: IPageRenderer) => {
const { documentDetails, editor, editorClassNames, editorContentCustomClassNames, updatePageTitle, readonly } = props;
const {
documentDetails,
editor,
editorClassNames,
editorContentCustomClassNames,
updatePageTitle,
readonly,
hideDragHandle,
} = props;
const [pageTitle, setPagetitle] = useState(documentDetails.title);
@@ -65,14 +74,6 @@ export const PageRenderer = (props: IPageRenderer) => {
setIsOpen(false);
};
const switchLinkView = (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => {
if (!linkViewProps) return;
setLinkViewProps({
...linkViewProps,
view: view,
});
};
const handleLinkHover = useCallback(
(event: React.MouseEvent) => {
if (!editor) return;
@@ -167,7 +168,7 @@ export const PageRenderer = (props: IPageRenderer) => {
/>
)}
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
</EditorContainer>
</div>
@@ -1,55 +1,29 @@
import Placeholder from "@tiptap/extension-placeholder";
import { IssueWidgetExtension } from "src/ui/extensions/widgets/issue-embed-widget";
import { IIssueEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types";
import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-widget";
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
import { ISlashCommandItem, UploadImage } from "@plane/editor-core";
import { IssueSuggestions } from "src/ui/extensions/widgets/issue-embed-suggestion-list";
import { LayersIcon } from "@plane/ui";
import { UploadImage } from "@plane/editor-core";
export const DocumentEditorExtensions = (
uploadFile: UploadImage,
issueEmbedConfig?: IIssueEmbedConfig,
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) => {
const additionalOptions: ISlashCommandItem[] = [
{
key: "issue_embed",
title: "Issue embed",
description: "Embed an issue from the project.",
searchTerms: ["issue", "link", "embed"],
icon: <LayersIcon className="h-3.5 w-3.5" />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.insertContentAt(
range,
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>\n"
)
.run();
},
) => [
SlashCommand(uploadFile, setIsSubmitting),
DragAndDrop(setHideDragHandle),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
return "Press '/' for commands...";
},
];
includeChildren: true,
}),
IssueWidgetPlaceholder(),
];
return [
SlashCommand(uploadFile, setIsSubmitting, additionalOptions),
DragAndDrop,
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
IssueWidgetExtension({ issueEmbedConfig }),
IssueSuggestions(issueEmbedConfig ? issueEmbedConfig.issues : []),
];
};
@@ -78,7 +78,6 @@ const IssueSuggestionList = ({
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
e.preventDefault();
// if (editor.isFocused) {
// editor.chain().blur();
// commandListContainer.current?.focus();
@@ -87,7 +86,6 @@ const IssueSuggestionList = ({
setSelectedIndex(
(selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length
);
e.stopPropagation();
return true;
}
if (e.key === "ArrowDown") {
@@ -102,12 +100,10 @@ const IssueSuggestionList = ({
[currentSection]: [...prevItems[currentSection], ...nextItems],
}));
}
e.stopPropagation();
return true;
}
if (e.key === "Enter") {
selectItem(currentSection, selectedIndex);
e.stopPropagation();
return true;
}
if (e.key === "Tab") {
@@ -115,7 +111,6 @@ const IssueSuggestionList = ({
const nextSectionIndex = (currentSectionIndex + 1) % sections.length;
setCurrentSection(sections[nextSectionIndex]);
setSelectedIndex(0);
e.stopPropagation();
return true;
}
return false;
@@ -150,7 +145,7 @@ const IssueSuggestionList = ({
<div
id="issue-list-container"
ref={commandListContainer}
className="fixed z-[10] max-h-80 w-60 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
className=" fixed z-[10] max-h-80 w-60 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
>
{sections.map((section) => {
const sectionItems = displayedItems[section];
@@ -193,29 +188,35 @@ const IssueSuggestionList = ({
</div>
) : null;
};
export const IssueListRenderer = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
const container = document.querySelector(".frame-renderer") as HTMLElement;
component = new ReactRenderer(IssueSuggestionList, {
props,
// @ts-ignore
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
popup = tippy(".frame-renderer", {
flipbehavior: ["bottom", "top"],
appendTo: () => document.querySelector(".frame-renderer") as HTMLElement,
flip: true,
flipOnUpdate: true,
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector("#editor-container"),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
container.addEventListener("scroll", () => {
popup?.[0].destroy();
});
},
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component?.updateProps(props);
@@ -230,10 +231,20 @@ export const IssueListRenderer = () => {
popup?.[0].hide();
return true;
}
// @ts-ignore
return component?.ref?.onKeyDown(props);
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
if (navigationKeys.includes(props.event.key)) {
// @ts-ignore
component?.ref?.onKeyDown(props);
return true;
}
return false;
},
onExit: (e) => {
const container = document.querySelector(".frame-renderer") as HTMLElement;
if (container) {
container.removeEventListener("scroll", () => {});
}
popup?.[0].destroy();
setTimeout(() => {
component?.destroy();
@@ -1,11 +1,3 @@
import { IssueWidget } from "src/ui/extensions/widgets/issue-embed-widget/issue-widget-node";
import { IIssueEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types";
interface IssueWidgetExtensionProps {
issueEmbedConfig?: IIssueEmbedConfig;
}
export const IssueWidgetExtension = ({ issueEmbedConfig }: IssueWidgetExtensionProps) =>
IssueWidget.configure({
issueEmbedConfig,
});
export const IssueWidgetPlaceholder = () => IssueWidget.configure({});
@@ -1,77 +1,33 @@
// @ts-nocheck
import { useState, useEffect } from "react";
import { Button } from "@plane/ui";
import { NodeViewWrapper } from "@tiptap/react";
import { Avatar, AvatarGroup, Loader, PriorityIcon } from "@plane/ui";
import { Calendar, AlertTriangle } from "lucide-react";
import { Crown } from "lucide-react";
export const IssueWidgetCard = (props) => {
const [loading, setLoading] = useState<number>(1);
const [issueDetails, setIssueDetails] = useState();
useEffect(() => {
props.issueEmbedConfig
.fetchIssue(props.node.attrs.entity_identifier)
.then((issue) => {
setIssueDetails(issue);
setLoading(0);
})
.catch(() => {
setLoading(-1);
});
}, []);
const completeIssueEmbedAction = () => {
props.issueEmbedConfig.clickAction(issueDetails.id, props.node.attrs.title);
};
return (
<NodeViewWrapper className="issue-embed-component m-2">
{loading == 0 ? (
<div
onClick={completeIssueEmbedAction}
className={`${
props.selected ? "border-custom-primary-200 border-[2px]" : ""
} w-full cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs`}
>
<h5 className="text-xs text-custom-text-300">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
</h5>
<h4 className="break-words text-sm font-medium">{issueDetails.name}</h4>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<div>
<PriorityIcon priority={issueDetails.priority} />
export const IssueWidgetCard = (props) => (
<NodeViewWrapper className="issue-embed-component m-2">
<div
className={`${
props.selected ? "border-custom-primary-200 border-[2px]" : ""
} w-full h-[100px] cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 shadow-custom-shadow-2xs`}
>
<h5 className="h-[20%] text-xs text-custom-text-300 p-2">
{props.node.attrs.project_identifier}-{props.node.attrs.sequence_id}
</h5>
<div className="relative h-[71%]">
<div className="h-full backdrop-filter backdrop-blur-[30px] bg-custom-background-80 bg-opacity-30 flex items-center w-full justify-between gap-5 mt-2.5 pl-4 pr-5 py-3 max-md:max-w-full max-md:flex-wrap relative">
<div className="flex gap-2 items-center">
<div className="rounded">
<Crown className="m-2" size={16} color="#FFBA18" />
</div>
<div>
<AvatarGroup size="sm">
{issueDetails.assignee_details.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} className={"m-0"} />
))}
</AvatarGroup>
<div className="text-custom-text text-sm">
Embed and access issues in pages seamlessly, upgrade to plane pro now.
</div>
{issueDetails.target_date && (
<div className="flex h-5 items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs text-custom-text-100">
<Calendar className="h-3 w-3" strokeWidth={1.5} />
{new Date(issueDetails.target_date).toLocaleDateString()}
</div>
)}
</div>
<a href="https://plane.so/pricing" target="_blank" rel="noreferrer">
<Button>Upgrade</Button>
</a>
</div>
) : loading == -1 ? (
<div className="flex items-center gap-[8px] rounded border-2 border-[#D97706] bg-[#FFFBEB] pb-[10px] pl-[13px] pt-[10px] text-[#D97706]">
<AlertTriangle color={"#D97706"} />
{"This Issue embed is not found in any project. It can no longer be updated or accessed from here."}
</div>
) : (
<div className="w-full space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs">
<Loader className={"px-6"}>
<Loader.Item height={"30px"} />
<div className={"mt-3 space-y-2"}>
<Loader.Item height={"20px"} width={"70%"} />
<Loader.Item height={"20px"} width={"60%"} />
</div>
</Loader>
</div>
)}
</NodeViewWrapper>
);
};
</div>
</div>
</NodeViewWrapper>
);
@@ -34,9 +34,7 @@ export const IssueWidget = Node.create({
},
addNodeView() {
return ReactNodeViewRenderer((props: Object) => (
<IssueWidgetCard {...props} issueEmbedConfig={this.options.issueEmbedConfig} />
));
return ReactNodeViewRenderer((props: Object) => <IssueWidgetCard {...props} />);
},
parseHTML() {
@@ -1,9 +0,0 @@
export interface IEmbedConfig {
issueEmbedConfig: IIssueEmbedConfig;
}
export interface IIssueEmbedConfig {
fetchIssue: (issueId: string) => Promise<any>;
clickAction: (issueId: string, issueTitle: string) => void;
issues: Array<any>;
}
@@ -10,7 +10,6 @@ import { DocumentDetails } from "src/types/editor-types";
import { PageRenderer } from "src/ui/components/page-renderer";
import { getMenuOptions } from "src/utils/menu-options";
import { useRouter } from "next/router";
import { IEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types";
interface IDocumentEditor {
// document info
@@ -47,7 +46,6 @@ interface IDocumentEditor {
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
embedConfig?: IEmbedConfig;
}
interface DocumentEditorProps extends IDocumentEditor {
forwardedRef?: React.Ref<EditorHandle>;
@@ -75,17 +73,23 @@ const DocumentEditor = ({
duplicationConfig,
pageLockConfig,
pageArchiveConfig,
embedConfig,
updatePageTitle,
cancelUploadImage,
onActionCompleteHandler,
rerenderOnPropsChange,
}: IDocumentEditor) => {
// const [alert, setAlert] = useState<string>("")
const { markings, updateMarkings } = useEditorMarkings();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
const router = useRouter();
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
const editor = useEditor({
onChange(json, html) {
updateMarkings(json);
@@ -104,7 +108,7 @@ const DocumentEditor = ({
cancelUploadImage,
rerenderOnPropsChange,
forwardedRef,
extensions: DocumentEditorExtensions(uploadFile, embedConfig?.issueEmbedConfig, setIsSubmitting),
extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting),
});
if (!editor) {
@@ -145,13 +149,14 @@ const DocumentEditor = ({
documentDetails={documentDetails}
isSubmitting={isSubmitting}
/>
<div className="flex h-full w-full overflow-y-auto">
<div className="flex h-full w-full overflow-y-auto frame-renderer">
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72">
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
</div>
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)]">
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer
onActionCompleteHandler={onActionCompleteHandler}
hideDragHandle={hideDragHandleOnMouseLeave}
readonly={false}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
@@ -4,12 +4,11 @@ import { useState, forwardRef, useEffect } from "react";
import { EditorHeader } from "src/ui/components/editor-header";
import { PageRenderer } from "src/ui/components/page-renderer";
import { SummarySideBar } from "src/ui/components/summary-side-bar";
import { IssueWidgetExtension } from "src/ui/extensions/widgets/issue-embed-widget";
import { IEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types";
import { useEditorMarkings } from "src/hooks/use-editor-markings";
import { DocumentDetails } from "src/types/editor-types";
import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "src/types/menu-actions";
import { getMenuOptions } from "src/utils/menu-options";
import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget";
interface IDocumentReadOnlyEditor {
value: string;
@@ -29,7 +28,6 @@ interface IDocumentReadOnlyEditor {
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
embedConfig?: IEmbedConfig;
}
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
@@ -51,7 +49,6 @@ const DocumentReadOnlyEditor = ({
pageDuplicationConfig,
pageLockConfig,
pageArchiveConfig,
embedConfig,
rerenderOnPropsChange,
onActionCompleteHandler,
}: DocumentReadOnlyEditorProps) => {
@@ -63,7 +60,7 @@ const DocumentReadOnlyEditor = ({
value,
forwardedRef,
rerenderOnPropsChange,
extensions: [IssueWidgetExtension({ issueEmbedConfig: embedConfig?.issueEmbedConfig })],
extensions: [IssueWidgetPlaceholder()],
});
useEffect(() => {
@@ -105,11 +102,11 @@ const DocumentReadOnlyEditor = ({
documentDetails={documentDetails}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
/>
<div className="flex h-full w-full overflow-y-auto">
<div className="flex h-full w-full overflow-y-auto frame-renderer">
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-80">
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
</div>
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)]">
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer
onActionCompleteHandler={onActionCompleteHandler}
updatePageTitle={() => Promise.resolve()}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor-extensions",
"version": "0.14.0",
"version": "0.15.0",
"description": "Package that powers Plane's Editor with extensions",
"private": true,
"main": "./dist/index.mjs",
@@ -1,6 +1,6 @@
{
"name": "@plane/lite-text-editor",
"version": "0.14.0",
"version": "0.15.0",
"description": "Package that powers Plane's Comment Editor",
"private": true,
"main": "./dist/index.mjs",
@@ -1,6 +1,6 @@
{
"name": "@plane/rich-text-editor",
"version": "0.14.0",
"version": "0.15.0",
"description": "Rich Text Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "eslint-config-custom",
"private": true,
"version": "0.14.0",
"version": "0.15.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.14.0",
"version": "0.15.0",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tsconfig",
"version": "0.14.0",
"version": "0.15.0",
"private": true,
"files": [
"base.json",
+1 -2
View File
@@ -1,7 +1,6 @@
{
"name": "@plane/types",
"version": "0.14.0",
"version": "0.15.0",
"private": true,
"main": "./src/index.d.ts"
}
+5 -5
View File
@@ -1,14 +1,14 @@
export interface IAppConfig {
email_password_login: boolean;
file_size_limit: number;
google_client_id: string | null;
github_app_name: string | null;
github_client_id: string | null;
magic_login: boolean;
slack_client_id: string | null;
posthog_api_key: string | null;
posthog_host: string | null;
google_client_id: string | null;
has_openai_configured: boolean;
has_unsplash_configured: boolean;
is_smtp_configured: boolean;
magic_login: boolean;
posthog_api_key: string | null;
posthog_host: string | null;
slack_client_id: string | null;
}
+1 -2
View File
@@ -47,7 +47,6 @@ export interface ICycle {
};
workspace: string;
workspace_detail: IWorkspaceLite;
issues?: TIssue[];
}
export type TAssigneesDistribution = {
@@ -62,7 +61,7 @@ export type TAssigneesDistribution = {
};
export type TCompletionChartDistribution = {
[key: string]: number;
[key: string]: number | null;
};
export type TLabelsDistribution = {
+65
View File
@@ -0,0 +1,65 @@
import { TIssue } from "../issues/base";
export enum EInboxStatus {
PENDING = -2,
REJECT = -1,
SNOOZED = 0,
ACCEPTED = 1,
DUPLICATE = 2,
}
export type TInboxStatus =
| EInboxStatus.PENDING
| EInboxStatus.REJECT
| EInboxStatus.SNOOZED
| EInboxStatus.ACCEPTED
| EInboxStatus.DUPLICATE;
export type TInboxIssueDetail = {
id?: string;
source: "in-app";
status: TInboxStatus;
duplicate_to: string | undefined;
snoozed_till: Date | undefined;
};
export type TInboxIssueDetailMap = Record<
string,
Record<string, TInboxIssueDetail>
>; // inbox_id -> issue_id -> TInboxIssueDetail
export type TInboxIssueDetailIdMap = Record<string, string[]>; // inbox_id -> issue_id[]
export type TInboxIssueExtendedDetail = TIssue & {
issue_inbox: TInboxIssueDetail[];
};
// property type checks
export type TInboxPendingStatus = {
status: EInboxStatus.PENDING;
};
export type TInboxRejectStatus = {
status: EInboxStatus.REJECT;
};
export type TInboxSnoozedStatus = {
status: EInboxStatus.SNOOZED;
snoozed_till: Date;
};
export type TInboxAcceptedStatus = {
status: EInboxStatus.ACCEPTED;
};
export type TInboxDuplicateStatus = {
status: EInboxStatus.DUPLICATE;
duplicate_to: string; // issue_id
};
export type TInboxDetailedStatus =
| TInboxPendingStatus
| TInboxRejectStatus
| TInboxSnoozedStatus
| TInboxAcceptedStatus
| TInboxDuplicateStatus;
+27
View File
@@ -0,0 +1,27 @@
export type TInboxIssueFilterOptions = {
priority: string[];
inbox_status: number[];
};
export type TInboxIssueQueryParams = "priority" | "inbox_status";
export type TInboxIssueFilters = { filters: TInboxIssueFilterOptions };
export type TInbox = {
id: string;
name: string;
description: string;
workspace: string;
project: string;
is_default: boolean;
view_props: TInboxIssueFilters;
created_by: string;
updated_by: string;
created_at: Date;
updated_at: Date;
pending_issue_count: number;
};
export type TInboxDetailMap = Record<string, TInbox>; // inbox_id -> TInbox
export type TInboxDetailIdMap = Record<string, string[]>; // project_id -> inbox_id[]
+2
View File
@@ -0,0 +1,2 @@
export * from "./inbox";
export * from "./inbox-issue";
+4
View File
@@ -13,7 +13,11 @@ export * from "./pages";
export * from "./ai";
export * from "./estimate";
export * from "./importer";
// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable
export * from "./inbox";
export * from "./inbox/root";
export * from "./analytics";
export * from "./calendar";
export * from "./notifications";
+2 -9
View File
@@ -109,17 +109,10 @@ export type IssuePriorities = {
export interface IIssueLabel {
id: string;
created_at: Date;
updated_at: Date;
name: string;
description: string;
color: string;
created_by: string;
updated_by: string;
project: string;
project_detail: IProjectLite;
workspace: string;
workspace_detail: IWorkspaceLite;
project_id: string;
workspace_id: string;
parent: string | null;
sort_order: number;
}
+58
View File
@@ -0,0 +1,58 @@
export * from "./issue_activity";
export * from "./issue_comment";
export * from "./issue_comment_reaction";
import { TIssuePriorities } from "../issues";
// root types
export type TIssueActivityWorkspaceDetail = {
name: string;
slug: string;
id: string;
};
export type TIssueActivityProjectDetail = {
id: string;
identifier: string;
name: string;
cover_image: string;
description: string | null;
emoji: string | null;
icon_prop: {
name: string;
color: string;
} | null;
};
export type TIssueActivityIssueDetail = {
id: string;
sequence_id: boolean;
sort_order: boolean;
name: string;
description_html: string;
priority: TIssuePriorities;
start_date: string;
target_date: string;
is_draft: boolean;
};
export type TIssueActivityUserDetail = {
id: string;
first_name: string;
last_name: string;
avatar: string;
is_bot: boolean;
display_name: string;
};
export type TIssueActivityComment =
| {
id: string;
activity_type: "COMMENT";
created_at?: string;
}
| {
id: string;
activity_type: "ACTIVITY";
created_at?: string;
};
+41
View File
@@ -0,0 +1,41 @@
import {
TIssueActivityWorkspaceDetail,
TIssueActivityProjectDetail,
TIssueActivityIssueDetail,
TIssueActivityUserDetail,
} from "./base";
export type TIssueActivity = {
id: string;
workspace: string;
workspace_detail: TIssueActivityWorkspaceDetail;
project: string;
project_detail: TIssueActivityProjectDetail;
issue: string;
issue_detail: TIssueActivityIssueDetail;
actor: string;
actor_detail: TIssueActivityUserDetail;
created_at: string;
updated_at: string;
created_by: string | undefined;
updated_by: string | undefined;
attachments: any[];
verb: string;
field: string | undefined;
old_value: string | undefined;
new_value: string | undefined;
comment: string | undefined;
old_identifier: string | undefined;
new_identifier: string | undefined;
epoch: number;
issue_comment: string | null;
};
export type TIssueActivityMap = {
[issue_id: string]: TIssueActivity;
};
export type TIssueActivityIdMap = {
[issue_id: string]: string[];
};
+39
View File
@@ -0,0 +1,39 @@
import {
TIssueActivityWorkspaceDetail,
TIssueActivityProjectDetail,
TIssueActivityIssueDetail,
TIssueActivityUserDetail,
} from "./base";
export type TIssueComment = {
id: string;
workspace: string;
workspace_detail: TIssueActivityWorkspaceDetail;
project: string;
project_detail: TIssueActivityProjectDetail;
issue: string;
issue_detail: TIssueActivityIssueDetail;
actor: string;
actor_detail: TIssueActivityUserDetail;
created_at: string;
updated_at: string;
created_by: string | undefined;
updated_by: string | undefined;
attachments: any[];
comment_reactions: any[];
comment_stripped: string;
comment_html: string;
comment_json: object;
external_id: string | undefined;
external_source: string | undefined;
access: "EXTERNAL" | "INTERNAL";
};
export type TIssueCommentMap = {
[issue_id: string]: TIssueComment;
};
export type TIssueCommentIdMap = {
[issue_id: string]: string[];
};
@@ -1,20 +1,20 @@
export type TIssueCommentReaction = {
id: string;
comment: string;
actor: string;
reaction: string;
workspace: string;
project: string;
created_at: Date;
updated_at: Date;
reaction: string;
created_by: string;
updated_by: string;
project: string;
workspace: string;
actor: string;
comment: string;
};
export type TIssueCommentReactionMap = {
[issue_id: string]: TIssueCommentReaction;
[reaction_id: string]: TIssueCommentReaction;
};
export type TIssueCommentReactionIdMap = {
[issue_id: string]: string[];
[comment_id: string]: { [reaction: string]: string[] };
};
+1 -2
View File
@@ -4,9 +4,8 @@ export * from "./issue_reaction";
export * from "./issue_link";
export * from "./issue_attachment";
export * from "./issue_relation";
export * from "./issue_activity";
export * from "./issue_comment_reaction";
export * from "./issue_sub_issues";
export * from "./activity/base";
export type TLoader = "init-loader" | "mutation" | undefined;
-41
View File
@@ -1,41 +0,0 @@
export type TIssueActivity = {
access?: "EXTERNAL" | "INTERNAL";
actor: string;
actor_detail: IUserLite;
attachments: any[];
comment?: string;
comment_html?: string;
comment_stripped?: string;
created_at: Date;
created_by: string;
field: string | null;
id: string;
issue: string | null;
issue_comment?: string | null;
issue_detail: {
description_html: string;
id: string;
name: string;
priority: string | null;
sequence_id: string;
} | null;
new_identifier: string | null;
new_value: string | null;
old_identifier: string | null;
old_value: string | null;
project: string;
project_detail: IProjectLite;
updated_at: Date;
updated_by: string;
verb: string;
workspace: string;
workspace_detail?: IWorkspaceLite;
};
export type TIssueActivityMap = {
[issue_id: string]: TIssueActivity;
};
export type TIssueActivityIdMap = {
[issue_id: string]: string[];
};
+1 -1
View File
@@ -13,7 +13,7 @@ export type TIssueReaction = {
};
export type TIssueReactionMap = {
[issue_id: string]: TIssueReaction;
[reaction_id: string]: TIssueReaction;
};
export type TIssueReactionIdMap = {
+1 -12
View File
@@ -1,11 +1,5 @@
import { EUserProjectRoles } from "constants/project";
import type {
IUser,
IUserLite,
IWorkspace,
IWorkspaceLite,
TStateGroups,
} from ".";
import type { IUser, IUserLite, IWorkspace, IWorkspaceLite, TStateGroups } from ".";
export interface IProject {
archive_in: number;
@@ -58,11 +52,6 @@ export interface IProjectLite {
id: string;
name: string;
identifier: string;
emoji: string | null;
icon_prop: {
name: string;
color: string;
} | null;
}
type ProjectPreferences = {
+2 -9
View File
@@ -5,20 +5,13 @@ export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "
export interface IState {
readonly id: string;
color: string;
readonly created_at: Date;
readonly created_by: string;
default: boolean;
description: string;
group: TStateGroups;
name: string;
project: string;
readonly project_detail: IProjectLite;
project_id: string;
sequence: number;
readonly slug: string;
readonly updated_at: Date;
readonly updated_by: string;
workspace: string;
workspace_detail: IWorkspaceLite;
workspace_id: string;
}
export interface IStateLite {
+8
View File
@@ -166,6 +166,14 @@ export interface IUserProjectsRole {
[projectId: string]: EUserProjectRoles;
}
export interface IUserEmailNotificationSettings {
property_change: boolean;
state_change: boolean;
comment: boolean;
mention: boolean;
issue_completed: boolean;
}
// export interface ICurrentUser {
// id: readonly string;
// avatar: string;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.14.0",
"version": "0.15.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
+16 -20
View File
@@ -1,17 +1,15 @@
import * as React from "react";
import ReactDOM from "react-dom";
// react-poppper
import { Menu } from "@headlessui/react";
import { usePopper } from "react-popper";
import { ChevronDown, MoreHorizontal } from "lucide-react";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// headless ui
import { Menu } from "@headlessui/react";
// type
import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper";
// icons
import { ChevronDown, MoreHorizontal } from "lucide-react";
// helpers
import { cn } from "../../helpers";
// types
import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper";
const CustomMenu = (props: ICustomMenuDropdownProps) => {
const {
@@ -29,7 +27,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
noChevron = false,
optionsClassName = "",
verticalEllipsis = false,
width = "auto",
portalElement,
menuButtonOnClick,
tabIndex,
@@ -63,17 +60,16 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
static
>
<div
className={`my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none ${
maxHeight === "lg"
? "max-h-60"
: maxHeight === "md"
? "max-h-48"
: maxHeight === "rg"
? "max-h-36"
: maxHeight === "sm"
? "max-h-28"
: ""
} ${width === "auto" ? "min-w-[12rem] whitespace-nowrap" : width} ${optionsClassName}`}
className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
{
"max-h-60": maxHeight === "lg",
"max-h-48": maxHeight === "md",
"max-h-36": maxHeight === "rg",
"max-h-28": maxHeight === "sm",
},
optionsClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
@@ -92,7 +88,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={`relative w-min text-left ${className}`}
className={cn("relative w-min text-left", className)}
onKeyDown={handleKeyDown}
>
{({ open }) => (
@@ -1,14 +1,12 @@
import React, { useRef, useState } from "react";
// react-popper
import { usePopper } from "react-popper";
import { Combobox } from "@headlessui/react";
import { Check, ChevronDown, Search } from "lucide-react";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// headless ui
import { Combobox } from "@headlessui/react";
// icons
import { Check, ChevronDown, Search } from "lucide-react";
// helpers
import { cn } from "../../helpers";
// types
import { ICustomSearchSelectProps } from "./helper";
@@ -29,9 +27,9 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
onChange,
options,
onOpen,
onClose,
optionsClassName = "",
value,
width = "auto",
tabIndex,
} = props;
const [query, setQuery] = useState("");
@@ -61,7 +59,10 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const closeDropdown = () => {
setIsOpen(false);
onClose && onClose();
};
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
@@ -70,7 +71,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={`relative flex-shrink-0 text-left ${className}`}
className={cn("relative flex-shrink-0 text-left", className)}
onKeyDown={handleKeyDown}
{...comboboxProps}
>
@@ -114,37 +115,33 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
</Combobox.Button>
)}
{isOpen && (
<Combobox.Options as={React.Fragment} static>
<Combobox.Options className="fixed z-10" static>
<div
className={`z-10 my-1 min-w-[10rem] rounded-md border border-custom-border-300 bg-custom-background-90 p-2 text-xs shadow-custom-shadow-rg focus:outline-none ${
width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width
} ${optionsClassName}`}
className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
optionsClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3 w-3 text-custom-text-200" />
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type to search..."
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div
className={`mt-2 space-y-1 ${
maxHeight === "lg"
? "max-h-60"
: maxHeight === "md"
? "max-h-48"
: maxHeight === "rg"
? "max-h-36"
: maxHeight === "sm"
? "max-h-28"
: ""
} overflow-y-scroll`}
className={cn("mt-2 space-y-1 overflow-y-scroll", {
"max-h-60": maxHeight === "lg",
"max-h-48": maxHeight === "md",
"max-h-36": maxHeight === "rg",
"max-h-28": maxHeight === "sm",
})}
>
{filteredOptions ? (
filteredOptions.length > 0 ? (
@@ -152,37 +149,31 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${
active || selected ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
className={({ active }) =>
cn(
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
{
"bg-custom-background-80": active,
}
)
}
onClick={() => {
if (!multiple) closeDropdown();
}}
>
{({ active, selected }) => (
{({ selected }) => (
<>
{option.content}
{multiple ? (
<div
className={`flex items-center justify-center rounded border border-custom-border-400 p-0.5 ${
active || selected ? "opacity-100" : "opacity-0"
}`}
>
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
</div>
) : (
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
)}
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
{footerOption}

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