Compare commits

...

242 Commits

Author SHA1 Message Date
Dakshesh Jain
387d206fac chore: slack integrations 2023-05-12 16:28:49 +05:30
Aaryan Khandelwal
bf865f399f fix: kanban board horizontal scroll (#1038)
* fix: kanban board horizontal scroll

* chore: droppable placeholder position
2023-05-12 12:41:31 +05:30
pablohashescobar
6a78948113 fix: analytics (#1037)
* fix: most issue created by user keys

* fix: cycle and module filters for GET method
2023-05-12 12:22:42 +05:30
pablohashescobar
6e9235e5fe fix: analytic query params (#1035)
* fix: query params for analytics

* fix: default analytics filters
2023-05-11 20:53:54 +05:30
dependabot[bot]
f2a68874f1 chore(deps): bump django in /apiserver/requirements (#1036)
Bumps [django](https://github.com/django/django) from 3.2.18 to 3.2.19.
- [Commits](https://github.com/django/django/compare/3.2.18...3.2.19)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-11 20:25:08 +05:30
Anmol Singh Bhatia
9cb3794dde fix: page block padding fix (#1034) 2023-05-11 18:41:13 +05:30
Anmol Singh Bhatia
1329145173 feat: custom theming (#1028)
* chore: custom theme types and constants

* feat: custom theming

* feat: preferences tab added in profile settings

* feat: remove unneccessary file

* feat:theme apply on page load

* fix: theme switch dropdown fix

* feat: color picker input, theme icon added, chore: code refactor

* style: color picker icon added

* fix: mutation fix

* fix: palette sequence fix

* chore: default custom theme palette updated

* style: join project and not authorized page theming

* fix: merge conflict

* fix: build fix and preferences tab layout fix
2023-05-11 18:40:17 +05:30
Anmol Singh Bhatia
44d49b5500 style: page detail (#1030)
* style: page detail header styling

* style: page detail ui

* style: page block, create block styling
2023-05-11 18:03:37 +05:30
Aaryan Khandelwal
1a534a3c19 feat: plane analytics (#1029)
* chore: global bar graph component

* chore: global pie graph component

* chore: global line graph component

* chore: removed unnecessary file

* chore: refactored global chart components to accept all props

* chore: function to convert response to chart data

* chore: global calendar graph component added

* chore: global scatter plot graph component

* feat: analytics boilerplate created

* chore: null value for segment and project

* chore: clean up file

* chore: change project query param key

* chore: export, refresh buttons, analytics table

* fix: analytics fetch key error

* chore: show only integer values in the y-axis

* chore: custom x-axis tick values and bar colors

* refactor: divide analytics page into granular components

* chore: convert analytics page to modal, save analytics modal

* fix: build error

* fix: modal overflow issues, analytics loading screen

* chore: custom tooltip, refactor: graphs folder structure

* refactor: folder structure, chore: x-axis tick values for larger data

* chore: code cleanup

* chore: remove unnecessary files

* fix: refresh analytics button on error

* feat: scope and demand analytics

* refactor: scope and demand and custom analytics folder structure

* fix: dynamic import type

* chore: minor updates

* feat: project, cycle and module level analytics

* style: project analytics modal

* fix: merge conflicts
2023-05-11 17:38:46 +05:30
Aaryan Khandelwal
d7928f853d chore: global graph components (#1014)
* chore: global bar graph component

* chore: global pie graph component

* chore: global line graph component

* chore: removed unnecessary file

* chore: refactored global chart components to accept all props

* chore: global calendar graph component added

* chore: global scatter plot graph component
2023-05-11 17:11:04 +05:30
Dakshesh Jain
a0553722c9 fix: settings editor state according to ai response (#1032)
* fix: placeholder and ai response not getting appended in textarea

* fix: settings editor state according to ai response
2023-05-11 17:02:20 +05:30
Anmol Singh Bhatia
34f4580b94 style: issue due date selector width fix (#1033) 2023-05-11 17:01:58 +05:30
pablohashescobar
a1d7a4ea55 fix: created by null for bulk operations (#1026) 2023-05-11 16:58:35 +05:30
pablohashescobar
abaa65b4b7 feat: analytics (#1018)
* dev: initialize plane analytics

* dev: plane analytics endpoint

* dev: update endpoint to give data with segments as well

* dev: analytics with count and effort paramters

* feat: analytics endpoints

* feat: saved analytics

* dev: remove print logs

* dev: rename x_axis to dimension in response

* dev: remove color queries

* dev: update query for None values

* feat: analytics export

* dev: update code structure send color when state or label and fix none count

* dev: uncomment try catch block

* dev: fix segment keyerror

* dev: default analytics endpoint

* dev: fix segmented results

* dev: default analytics endpoint and colors for segment

* dev: total issues and open issues by state

* dev: segment colors

* dev: fix total issue annotate

* dev: effort segmentation

* dev: total estimates and open estimates

* fix: effort when not segmented

* dev: send avatar for default analytics
2023-05-11 16:57:03 +05:30
pablohashescobar
fb165d080e feat: estimate points data in cycles list endpoint (#1015)
* feat: estimate points data in cycles list endpoint

* dev: prefetch for assignees

* dev: update sum for estimate points
2023-05-11 16:56:48 +05:30
Dakshesh Jain
083562b24c fix: placeholder and ai response not getting appended in textarea (#1031) 2023-05-11 13:41:16 +05:30
Anmol Singh Bhatia
88d3fa549a style: page block hover effect (#1017) 2023-05-11 02:27:27 +05:30
Anmol Singh Bhatia
c7deb00f2a feat: calendar view display properties (#1024)
* fix: calendar mutation fix, chore: calendar type added

* feat: calendar view display property added

* feat: calendar header, single date and single issue component added, chore: code refactor

* chore: partialupdateissue function updated

* fix: dropdown overflow fix, style: issue card styling

* fix: calendar weekly view row height fix

* feat: calendar issue card ellipsis added, fix: edit and delete mutation fix

* style: plus icon , add issue button padding and onhover effect fix

* style: calendar issue card

* style: weekly view height
2023-05-11 02:27:14 +05:30
Dakshesh Jain
4f2b106852 fix: rich text editor (#1008)
* fix: undo/redo, placeholder overlapping with text, horizontal cursor

refractor: removed a lot of state-management that was not required

* fix: forwarding ref to remirror for getting extra helper functions

* fix: icon type error

* fix: value type not supported error on page block

* style: spacing, and UX for add link

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-05-11 02:15:49 +05:30
Aaryan Khandelwal
df96d40cfa fix: views issues mutation, sidebar link highlight (#1025)
* fix: views issues mutation, sidebar link highlight

* fix: show only specific states when type filter is set

* fix: delete comment mutation

* style: bulk delete issues modal

* fix: project settings features mutation
2023-05-11 02:15:39 +05:30
vamsi
4884ecd668 dev: migrations for estimate point values 2023-05-05 19:48:38 +05:30
vamsi
c16c5b1cb2 Merge branch 'develop' of https://github.com/makeplane/plane into develop 2023-05-05 19:46:45 +05:30
guru_sainath
a69593a9e8 fix: handled token expiry validation (#1016) 2023-05-05 18:01:58 +05:30
Aaryan Khandelwal
a1de3f581f fix: layout height and overflow (#1004)
* fix: kanban height issue

* dev: Layout fixes

* dev: layout changes

* fix: layout overflow settings and fixed header

* style: filters padding fixed

* fix: hide filters if none are applied

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-05-05 17:07:29 +05:30
Rhea Jain
443878994a fix: breadcrumbs and tab updated (#1007) 2023-05-05 15:46:05 +05:30
Anmol Singh Bhatia
86cb23777e fix: bug fixes (#1000)
* fix: issue sidebar cycle and module dropdown fix

* style: my issue page

* style: date picker theming

* fix: cycle modal

* style: date picker

* fix: info icon fix

* feat: integration banner

* feat: project integration banner

* fix: module card progress bar fix

* style: integration banner

* style: workspace sidebar

* fix: cycle date checker

* fix: calendar page view dropdown
2023-05-05 15:45:53 +05:30
Aaryan Khandelwal
93c105c495 chore: track events for estimates and importers (#1012) 2023-05-05 15:45:38 +05:30
Aaryan Khandelwal
b34cf0c471 fix: ai button not working on creating a page block (#1013)
* fix: ai button not working on creating page block

* fix: build error
2023-05-05 15:45:10 +05:30
pablohashescobar
fd96c54b43 fix: cycle date check endpoint for updation (#1006)
* fix: cycle date check endpoint for updation

* dev: update the cycle date check endpoint to exclude current cycle when updating
2023-05-05 15:13:22 +05:30
pablohashescobar
993cf3faba chore: add assignee avatar in cycle endpoint (#996)
* chore: add assignee avatar in cycle endpoint

* dev: update the structure to return avatar and firstname

* dev: return distinct users

* dev: update the structure to return id

* dev: update the prefetch queryset to distinct by id

* dev: remove id from distinct

* dev: add unique condition
2023-05-05 15:13:03 +05:30
pablohashescobar
1bf1b63fff fix: estimate points update (#1003)
* fix: estimate points hub

* fix: estimate points update

* fix: estimate points bulk_update
2023-05-05 15:12:38 +05:30
pablohashescobar
336220bd98 feat: return workspace and project details in estimate endpoints (#1009) 2023-05-05 15:12:22 +05:30
pablohashescobar
5b0dc43bae docs: update readme (#1010)
docs: update readme
2023-05-05 15:12:01 +05:30
pablohashescobar
e0bec31586 feat: workspace detail for imports (#1011) 2023-05-05 15:11:45 +05:30
pablohashescobar
a2825208b8 docs: update self hosting section in readme (#1002)
docs: update self hosting section in readme
2023-05-03 23:53:53 +05:30
pablohashescobar
c3387ba974 fix: environment variables for the services (#1001) 2023-05-03 21:31:06 +05:30
pablohashescobar
baa9c30449 fix: single docker file (#999) 2023-05-03 16:48:04 +05:30
pablohashescobar
849e2d658a dev:: update docker file for frontend (#998) 2023-05-03 16:39:13 +05:30
pablohashescobar
c7f1090914 fix: docker setup (#987)
* removing dependencies from .env

* dev: Passing the arguments from docker compose to DockerWeb in nextjs to define base environment variables

* dev: removed env from docker-compose and taking the env from shell

* dev: Updated docker file and used console in signin to test the env from docker

* dev: Docker setting env variables via shell

* removed env variables and args

* Update Dockerfile.web

* Update Dockerfile.web

* Update signin.tsx

* .

* .

* dev: Added BASE_URL from docker

* dev: Updated docker config

* dev: scripts for replacing variable during runtime

* dev: entrypoint script

* dev: update replace env script and update docker entrypoint command for frontend

* dev: update replace env script to not update process.env

* dev: update docker file to add missing variables as well

* fix: updated docker compose yml and web

* dev: create start script to run docker and update script for replacing variables

* dev: update setup script and env example script to create variables in the root of the project

* .

* dev: update docker compose hub

* dev: update docker compose hub command

* dev: update docker compose yml and env example

* dev: update docker compose hub

* dev: single docker

---------

Co-authored-by: Narayana <narayana.vadapalli1996@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-05-03 13:36:55 +05:30
Vamsi Kurama
563bb12b9b Merge pull request #993 from makeplane/stage-release
promote: stage-release to prod
2023-05-03 01:03:31 +05:30
vamsi
e3d43298df Merge branch 'stage-release' of https://github.com/makeplane/plane into stage-release 2023-05-03 00:57:04 +05:30
pablohashescobar
b36565298f Merge pull request #992 from makeplane/develop
promote: develop to stage-release
2023-05-03 00:55:14 +05:30
guru_sainath
b26e8bd956 update: UI for google auth button and loadin toggle in signin screen (#991) 2023-05-03 00:48:24 +05:30
pablohashescobar
e5703dbe70 Merge pull request #990 from makeplane/develop
promote: develop to stage-release
2023-05-02 23:37:25 +05:30
Anmol Singh Bhatia
498d6d2b02 style: onboarding logo and style (#989) 2023-05-02 23:31:36 +05:30
pablohashescobar
1d22817ede fix: import user invite emails (#986) 2023-05-02 20:09:54 +05:30
Anmol Singh Bhatia
483c49d0ff fix: ui and bug fixes (#980)
* style: sub issue theming

* style: shortcut modal theming

* style: blocking and blocked by modal theming

* fix: filter labels dropdown width fix

* style: display properties

* chore: workspace invite

* style: invite co workers theming

* style: create workspace theming

* style: attachment theming

* style: workspace sidebar theming

* style: issue property theming

* style: create module modal lead icon

* style: label list modal theming

* delete attachment and member modal theming

* style: transfer issue modal

* style: delete estimate modal theming

* style: module form status

* style: delete state modal theming

* style: shortcut modal

* style: onboarding logo

* style: onboarding command menu
2023-05-02 20:00:33 +05:30
guru_sainath
0fa9451633 fix: workspace integration github repos issue resolved (#988) 2023-05-02 18:15:24 +05:30
pablohashescobar
46237c5431 Merge pull request #866 from makeplane/develop
promote: develop to stage-release
2023-05-02 01:57:04 +05:30
vamsi
20e400487f dev: migrations SlackProjectSync; new attributes to cycle, importer and module 2023-05-02 01:55:15 +05:30
pablohashescobar
99fb3c9bfe chore: log cycle delete and cycle issue delete activities (#975)
* chore: log cycle delete and cycle issue delete activities

* dev: remove print logs
2023-05-02 01:21:48 +05:30
pablohashescobar
88200a93bf feat: delete imported data as well when the import is deleted (#974) 2023-05-02 00:53:08 +05:30
pablohashescobar
b2ad071608 fix: bulk modules importer (#973) 2023-05-02 00:52:52 +05:30
pablohashescobar
21992f540f chore: importer user invite email (#972) 2023-05-02 00:52:36 +05:30
pablohashescobar
742731cbe6 chore: log module delete and module issue delete activities (#976) 2023-05-02 00:51:04 +05:30
pablohashescobar
c6878b9b0f fix: page access update (#977) 2023-05-02 00:50:41 +05:30
pablohashescobar
887cac5612 feat: filter issues by estimate points (#978) 2023-05-02 00:50:10 +05:30
guru_sainath
982566b5b4 Merge pull request #982 from makeplane/fix/slack-integration
fix: slack with project integration bug fixes
2023-05-02 00:47:36 +05:30
gurusainath
2dc5655886 fix: slack with project integration bug fixes 2023-05-02 00:40:47 +05:30
guru_sainath
160b4a4390 Merge pull request #970 from makeplane/refactor/estimates_modal
refactor: estimates payload generate function
2023-04-29 14:17:29 +05:30
guru_sainath
f187d9512a Merge pull request #969 from makeplane/style/workspace_sidebar
style: workspace sidebar
2023-04-29 14:15:41 +05:30
Aaryan Khandelwal
53d3ea1979 refactor: estimates payload generate function 2023-04-29 03:27:03 +05:30
anmolsinghbhatia
13d76d4325 style: workspace sidebar 2023-04-28 20:51:47 +05:30
guru_sainath
25338cc804 Merge pull request #968 from makeplane/style/onboarding_screens
style: onboarding screens, invitations page
2023-04-28 20:50:40 +05:30
Aaryan Khandelwal
eaa750b025 fix: initial onboarding step 2023-04-28 20:47:29 +05:30
Aaryan Khandelwal
81d9c70026 style: onboarding screens, invitations page 2023-04-28 20:46:07 +05:30
guru_sainath
93fb4fe1e9 Merge pull request #959 from makeplane/feat/product_update_modal
feat: product update modal
2023-04-28 19:38:33 +05:30
guru_sainath
b8e6d072cc Merge pull request #967 from makeplane/style/ui_fixes
style: command k item and cycle icon theming
2023-04-28 19:03:16 +05:30
anmolsinghbhatia
1220cebe50 style: command k item and cycle icon theming 2023-04-28 18:52:53 +05:30
anmolsinghbhatia
9464b5c00e style: product update modal 2023-04-28 18:29:01 +05:30
anmolsinghbhatia
3fae0f39c0 chore: product update interface 2023-04-28 18:28:43 +05:30
Kunal Vishwakarma
73a757e337 fix: added the decoded url for slack integration popup (#958) 2023-04-28 17:55:57 +05:30
Kunal Vishwakarma
bb40b7feb5 chore: removed email span from invite member page (#956) 2023-04-28 17:49:38 +05:30
Aaryan Khandelwal
3175ce9136 style: create project modal (#957) 2023-04-28 17:49:29 +05:30
Anmol Singh Bhatia
0b9b4bb289 fix: cycle & module mutation , feat: cycle & module toast alerts (#962)
* fix: cycle remove issue mutation

* fix: module remove issue mutation

* feat: issue removed toast alert added
2023-04-28 17:49:16 +05:30
Anmol Singh Bhatia
f0f24b6fc4 style: module , cycle & icon styling (#963)
* style: target icon updated

* style: cycle card theming

* style: module card theming

* style: no current cycle message
2023-04-28 17:49:04 +05:30
Anmol Singh Bhatia
429dffb055 fix: cycle & module sidebar default tab fix (#964) 2023-04-28 17:48:40 +05:30
guru_sainath
6e5c85cd6e Merge pull request #961 from makeplane/style/issue_list_responsive
style: responsive design for issue list in 'list' view
2023-04-28 10:49:54 +05:30
Dakshesh Jain
2adcb163fb Merge branch 'develop' of https://github.com/makeplane/plane into style/issue_list_responsive 2023-04-25 19:44:19 +05:30
Dakshesh Jain
028a350cd1 style: issue list dropdown on next line 2023-04-25 19:43:52 +05:30
Anmol Singh Bhatia
d021a5696a fix: sub issues list fix (#960) 2023-04-25 19:00:20 +05:30
Dakshesh Jain
a5c18e37c1 Merge branch 'develop' of https://github.com/makeplane/plane into style/issue_list_responsive 2023-04-25 18:36:24 +05:30
Dakshesh Jain
8e611664a8 style: made issue list view responsive 2023-04-25 18:35:58 +05:30
anmolsinghbhatia
3480b450f2 fix: build fix 2023-04-25 18:10:23 +05:30
anmolsinghbhatia
a7d9591c44 feat: product updates tag and date added 2023-04-25 18:02:13 +05:30
anmolsinghbhatia
1364c842e0 feat: product updates button added 2023-04-25 17:33:53 +05:30
anmolsinghbhatia
eb99b4adc9 feat: markdown to custom component 2023-04-25 16:54:12 +05:30
anmolsinghbhatia
6684dd4ab6 feat: product updates service added 2023-04-25 16:51:50 +05:30
Kunal Vishwakarma
529ed4432c chore: fixed sidebar highlight not working (#952) 2023-04-25 12:10:05 +05:30
Kunal Vishwakarma
8c1ad69f0c chore: changed pages empty state image (#954) 2023-04-25 12:09:29 +05:30
Kunal Vishwakarma
c23de32b03 chore: added create label inside of pages label select (#944)
* chore: added create label inside of pages label select

* chore: used footer instead of props
2023-04-25 12:08:56 +05:30
Rhea Jain
83ac1f4e4c chore: invite text update (#955) 2023-04-25 12:07:02 +05:30
Anmol Singh Bhatia
cee9695a4a feat: sub issues progress (#895) 2023-04-25 11:38:13 +05:30
Aaryan Khandelwal
c9f866e538 style: profile settings, activity theming (#951) 2023-04-24 18:53:30 +05:30
Aaryan Khandelwal
7d96adcb70 chore: empty state for estimates (#950) 2023-04-24 18:53:18 +05:30
Aaryan Khandelwal
c5b034385f chore: added custom toggle switch everywhere (#949) 2023-04-24 18:53:07 +05:30
Aaryan Khandelwal
ff7f31c35b style: calendar view (#948)
* style: calendar view

* chore: add issue button positioning
2023-04-24 18:10:44 +05:30
Aaryan Khandelwal
7234d6f68b style: workspace and project settings (#946) 2023-04-24 13:21:09 +05:30
Aaryan Khandelwal
d8a5b8d848 style: imports theming (#945) 2023-04-24 13:20:41 +05:30
Aaryan Khandelwal
5412e09701 chore: empty input fields text (#943) 2023-04-24 11:28:05 +05:30
Aaryan Khandelwal
7116acc331 style: auth pages theming (#942) 2023-04-24 11:20:14 +05:30
Aaryan Khandelwal
ae26b17cab style: filters list theming (#941) 2023-04-24 11:20:02 +05:30
Aaryan Khandelwal
2ec8fbab34 style: modals theming (#940)
* style: existing issues list modal

* style: parent issues list modal

* style: issue modal

* style: cycle modal

* style: module modal

* style: view modal

* style: page modal

* style: delete modals
2023-04-24 11:19:53 +05:30
Aaryan Khandelwal
213dc3f8e8 style: signin page theming (#938) 2023-04-24 11:19:43 +05:30
Aaryan Khandelwal
4b02886c40 chore: remove outline button component (#937) 2023-04-23 01:43:58 +05:30
Aaryan Khandelwal
8eddc4b304 fix: points not updating while editing estimate (#935) 2023-04-23 00:00:46 +05:30
Aaryan Khandelwal
169a60723b style: project settings theming (#936)
* style: project and workspace members theming

* style: project features theming

* style: project settings states theming

* style: project settings labels theming

* style: project settings integrations theming
2023-04-22 23:46:19 +05:30
Kunal Vishwakarma
c80094581e feat: frontend slack integration (#932)
* feat: slack integration frontend

* feat: slack integration frontend complete

* Co-authored-by: Aaryan Khandelwal <aaryan610@users.noreply.github.com>
2023-04-22 21:54:50 +05:30
pablohashescobar
d99f669b89 dev: add s3 url for staging (#934) 2023-04-22 18:21:22 +05:30
Aaryan Khandelwal
99dd1b9f0c chore: state delete validation (#930) 2023-04-22 18:19:35 +05:30
pablohashescobar
48e77ea81b remove: state delete issue validation endpoint (#929) 2023-04-22 18:18:08 +05:30
pablohashescobar
0be6738715 dev: back migration for integrations (#933) 2023-04-22 18:17:53 +05:30
Anmol Singh Bhatia
d041d8be6b fix: cycle date validation (#922) 2023-04-22 18:17:17 +05:30
pablohashescobar
e53847c59e feat: module view props (#882) 2023-04-22 18:16:16 +05:30
pablohashescobar
16781a71fe feat: cycle view props (#881) 2023-04-22 18:16:05 +05:30
pablohashescobar
fb4535b294 feat: slack integration (#874)
* feat: init slack integration

* dev: create model and update existing view for slack

* dev: update slack sync model and create view to install slack

* dev: workspace integration query

* dev: update the metadata validation for access_token and team_id and save config to database

* dev: update validation for team_id

* dev: update validation

* dev: update validations

* dev: remove bot access token field from sync

* dev: handle integrity exception
2023-04-22 18:15:52 +05:30
pablohashescobar
33a904bc3e chore: added estimate bulk endpoint for retrieving and updating (#919)
* chore: added estimate bulk endpoint for retrieving and updating

* chore: estimate endpoints

* fix: retrieve project estimate

* dev: handle integrity error check
2023-04-22 01:04:20 +05:30
pablohashescobar
0d264838a9 remove: print exception instead use capture to log it (#926) 2023-04-22 01:04:07 +05:30
Aaryan Khandelwal
c638b6aba6 chore: new estimates workflow (#927)
* chore: new services for estimates

* chore: new estimates workflow
2023-04-22 00:59:57 +05:30
Aaryan Khandelwal
cb814dd68b chore: added new restricted workspace slugs (#928) 2023-04-22 00:57:16 +05:30
Aaryan Khandelwal
e17c824119 style: dashboard styling (#924) 2023-04-22 00:56:17 +05:30
Anmol Singh Bhatia
68930c256f style: sidebar theming (#925)
* style: sidebar workspace dropdown theming

* style: progress chart and progress bar theming

* style: module and cycle sidebar theming
2023-04-22 00:15:45 +05:30
Anmol Singh Bhatia
e59137f6f2 style: cycle and module theming (#923)
* style: cycle theming

* style: module theming
2023-04-21 17:40:57 +05:30
Aaryan Khandelwal
7f235739bd style: projects page theming (#921) 2023-04-21 17:40:49 +05:30
Vamsi Kurama
20162050c3 Merge pull request #833 from kylewlacy/self-hosting-tweaks
Add a few more options for self-hosting
2023-04-21 16:47:36 +05:30
Aaryan Khandelwal
06ad0d0f7a style: views theming (#920) 2023-04-21 16:30:16 +05:30
Aaryan Khandelwal
cfd7e1d154 fix: theming fixes (#914) 2023-04-21 15:48:06 +05:30
Anmol Singh Bhatia
fdf7ea1f82 feat: copy page link added (#915) 2023-04-21 15:46:28 +05:30
Anmol Singh Bhatia
8f12d3d01b style: calendar theming (#918) 2023-04-21 15:45:57 +05:30
Aaryan Khandelwal
62dc6c2f3f fix: comment box placeholder (#909) 2023-04-21 12:31:34 +05:30
Aaryan Khandelwal
6f03022f65 chore: changed light mode colors (#913) 2023-04-21 10:46:04 +05:30
Aaryan Khandelwal
f2701a12ea style: pages theming (#912) 2023-04-21 02:15:21 +05:30
Anmol Singh Bhatia
9129a6cde2 feat: calendar filters (#908)
* feat: hiding unnecessary filters for calendar view

* feat: filters for calendar view

* feat: module and cycle calendar view filters
2023-04-21 01:42:09 +05:30
Rhea Jain
2ba4594b29 chore: attachements button text update (#910) 2023-04-21 01:41:47 +05:30
Aaryan Khandelwal
165d16e32b style: issue details page (#911) 2023-04-21 00:34:22 +05:30
Aaryan Khandelwal
73388195ef chore: page block input (#907)
* fix: sidebar workspace dropdown logo

* chore: changed textarea to input for page block
2023-04-20 19:04:10 +05:30
pablohashescobar
4dda4ec610 chore: single endpoint to create estimate and estimate points (#897) 2023-04-20 18:14:38 +05:30
pablohashescobar
e68a5382f9 feat: sub issue state distribution (#885)
* feat: sub issue state distribution

* dev: update the response structure to match consistency

* dev: update the query for sub issue distribution to include 0 issue groups as well
2023-04-20 18:14:24 +05:30
pablohashescobar
5b6caadd6f chore: state delete validations endpoint (#880) 2023-04-20 18:14:05 +05:30
pablohashescobar
73a8bbb31f feat: release information endpoint (#876)
* dev: init release notes

* feat: API endpoint to fetch get last 5 release information
2023-04-20 18:13:49 +05:30
Aaryan Khandelwal
952d35dd79 style: list and kanban view theming (#906)
* fix: sidebar workspace dropdown logo

* style: list and kanban view theming
2023-04-20 18:13:21 +05:30
Anmol Singh Bhatia
170b3d6eec fix: cycle and module sidebar fix (#905) 2023-04-20 18:09:01 +05:30
Anmol Singh Bhatia
d04a422054 feat: calendar add new issue (#901) 2023-04-20 14:11:11 +05:30
Aaryan Khandelwal
affc7655f7 fix: sidebar workspace dropdown logo (#903) 2023-04-20 13:56:09 +05:30
Aaryan Khandelwal
50c78628b3 feat: themes (#902)
* chore: add next theme and initial setup

* chore: add dark mode colors to layouts

* chore: user general setting page theming

* chore: dashboard theming

* chore: project page theming

* chore: workspace setting page theming

* chore: my issue page theming

* chore: cmdk theming

* chore: change hardcode bg and text color to theme

* chore: change color name according to the design

* style: fix card in the dashboard

* style: fix merge conflict design issues

* style: add light high contrast and dark high contrast

* style: fix cmd k menu color and selection

* feat: change theme from cmdk menu

* chore: add multiple theme field to custom theme

* chore: removed custom theming

* fix: build error

---------

Co-authored-by: Saheb Giri <iamsahebgiri@gmail.com>
2023-04-20 13:42:16 +05:30
Aaryan Khandelwal
3c2f5d12ed feat: themes (#902)
* chore: add next theme and initial setup

* chore: add dark mode colors to layouts

* chore: user general setting page theming

* chore: dashboard theming

* chore: project page theming

* chore: workspace setting page theming

* chore: my issue page theming

* chore: cmdk theming

* chore: change hardcode bg and text color to theme

* chore: change color name according to the design

* style: fix card in the dashboard

* style: fix merge conflict design issues

* style: add light high contrast and dark high contrast

* style: fix cmd k menu color and selection

* feat: change theme from cmdk menu

* chore: add multiple theme field to custom theme

* chore: removed custom theming

* fix: build error

---------

Co-authored-by: Saheb Giri <iamsahebgiri@gmail.com>
2023-04-20 13:41:24 +05:30
Rhea Jain
9f04933957 chore: upload button and github banner text update (#899)
* chore: upload button and github banner text update

* chore: attachments button text fix
2023-04-20 12:16:13 +05:30
Anmol Singh Bhatia
8d37a3e58b feat: cycle list tab context (#900) 2023-04-20 12:11:33 +05:30
Aaryan Khandelwal
4f61c5d552 fix: pages access mutation (#896) 2023-04-20 12:09:55 +05:30
Aaryan Khandelwal
7149d20601 chore: change pages icon (#894) 2023-04-20 12:09:48 +05:30
Aaryan Khandelwal
d30a88832a fix: reset estimates modal form after create/update (#893) 2023-04-20 12:09:35 +05:30
Rhea Jain
ebce364104 chore: changed loading text (#898) 2023-04-19 20:52:52 +05:30
Aaryan Khandelwal
c5206a7792 feat: separate filters for cycles and modules (#892) 2023-04-19 17:25:31 +05:30
Anmol Singh Bhatia
390b837561 fix: invite button validation , style: workspace screen (#877)
* fix: invite button validation

* style: workspace screen tab height
2023-04-19 15:41:46 +05:30
Anmol Singh Bhatia
fb1932e309 fix : create issue modal (#875)
* fix: label list bug fix

* fix: assignee and label count removed

* fix: assignee and label fix
2023-04-19 15:41:17 +05:30
Dakshesh Jain
ac125965eb fix: add tlds (#851) 2023-04-19 15:40:47 +05:30
Dakshesh Jain
63a36fb25d feat: jira importer (#879)
* feat: jira importer

* fix: yarn lock

* fix: displaying correct count of users that are been imported

* fix: showing workspace member in import users
2023-04-19 15:40:31 +05:30
pablohashescobar
2b280935a1 chore: update state create endpoint to send error response on integrity error (#869)
* chore: update state create endpoint to send error response on integrity error

* dev: update status code for general exception
2023-04-18 12:25:22 +05:30
Kunal Vishwakarma
be5ef61428 fix: join project button (#873) 2023-04-18 10:55:32 +05:30
Aaryan Khandelwal
1627a587ee refactor: workspace integrations code (#872) 2023-04-18 10:54:45 +05:30
Aaryan Khandelwal
682a1477fb chore: create/update estimate points validation (#871) 2023-04-18 10:54:19 +05:30
Kyle Lacy
ea87823478 Add $NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS to add domains for Next Image plugin 2023-04-17 16:30:36 -07:00
Kyle Lacy
b8c06b3121 Add $AWS_S3_ENDPOINT_URL to .env.example 2023-04-17 14:08:47 -07:00
Kyle Lacy
ca2366aa9b Use env var to set AWS_S3_ENDPOINT_URL setting 2023-04-17 14:07:00 -07:00
Aaryan Khandelwal
acff6396f9 chore: create/update state duplicate name error (#870) 2023-04-18 01:15:39 +05:30
Aaryan Khandelwal
fa5c994ddc chore: remove redundant console logs (#868) 2023-04-18 01:15:26 +05:30
Aaryan Khandelwal
5f20e65ca6 style: list view styling reduced (#867) 2023-04-18 01:15:10 +05:30
Kyle Lacy
792162ae66 Remove redundant $REDIS_BROKER_SSL setting
A fix was already pushed upstream that made this setting unnecessary
2023-04-17 12:37:58 -07:00
Kyle Lacy
e22f552ea0 Merge branch 'develop' into self-hosting-tweaks 2023-04-17 12:30:24 -07:00
pablohashescobar
396fbc4ebb fix: redis url for docker in production settings (#865) 2023-04-17 22:38:52 +05:30
Vamsi Kurama
d2a58bf04a Merge pull request #862 from makeplane/stage-release
promote: staging to production v0.5
2023-04-17 19:08:10 +05:30
Vamsi Kurama
c51407c85e Merge branch 'master' into stage-release 2023-04-17 19:07:33 +05:30
Vamsi Kurama
3ed937378f Merge pull request #861 from makeplane/develop
promote: patches from develop to stage-release
2023-04-17 18:48:21 +05:30
Dakshesh Jain
0fa3a8c3e3 fix: removed useEffect for project detail fetch error (#860) 2023-04-17 18:38:07 +05:30
Aaryan Khandelwal
bd0cfef02f fix: delete import (#859) 2023-04-17 18:34:18 +05:30
Dakshesh Jain
7aa9e0bba1 fix: project authorization api fetch keys (#858) 2023-04-17 18:22:04 +05:30
Vamsi Kurama
718f62a898 Merge pull request #857 from makeplane/develop
promote: develop to stage-release
2023-04-17 17:47:19 +05:30
Kunal Vishwakarma
d26d01ace4 chore: filters spacing (#856) 2023-04-17 17:42:02 +05:30
Vamsi Kurama
60e44fc1a2 Merge pull request #847 from makeplane/chore/my_issues_endpoint
chore: my issues endpoint to return attachment and link count
2023-04-17 17:39:38 +05:30
Anmol Singh Bhatia
45eaa23ed0 style: dashboard content (#855) 2023-04-17 17:36:02 +05:30
Aaryan Khandelwal
600fedd5ba fix: undefined block content (#854) 2023-04-17 17:21:15 +05:30
Aaryan Khandelwal
6de54089cd fix: show proper issues list if no filters are present (#853) 2023-04-17 17:10:36 +05:30
Aaryan Khandelwal
dc53708109 chore: show workspace members (#852) 2023-04-17 16:41:16 +05:30
Aaryan Khandelwal
f5351e4419 fix: remove filters function (#846) 2023-04-17 15:03:56 +05:30
Aaryan Khandelwal
819508d5fc fix: remirror placeholder (#849) 2023-04-17 15:03:44 +05:30
Kunal Vishwakarma
0beb654069 chore: worked on making issue list padding consistent (#850) 2023-04-17 15:01:22 +05:30
Anmol Singh Bhatia
98cef0e1e8 fix: my issue page display property (#848) 2023-04-17 14:45:51 +05:30
Aaryan Khandelwal
8a6036a20a fix: drop to delete box zindex (#843) 2023-04-17 14:13:32 +05:30
pablohashescobar
85b6c78e75 chore: my issues endpoint to return attachment and link count 2023-04-17 14:03:19 +05:30
Anmol Singh Bhatia
3f401b0fc5 feat: page tab context (#845)
* feat: page list tab context added

* fix: build fix
2023-04-17 13:30:39 +05:30
Anmol Singh Bhatia
365c758a25 fix: create update view modal fix (#842) 2023-04-17 12:24:30 +05:30
Aaryan Khandelwal
ac98381f23 chore: remove estimate option from my issues (#839) 2023-04-17 12:00:30 +05:30
Kunal Vishwakarma
3e436179fe chore: added default state for issues (#840) 2023-04-17 11:54:22 +05:30
Aaryan Khandelwal
e23075b7b9 chore: no estimates option, estimates activity (#838) 2023-04-17 11:30:48 +05:30
pablohashescobar
61761fedc5 chore: remove view filter validation while creating or updating view (#836) 2023-04-17 11:15:38 +05:30
Kunal Vishwakarma
5a36a7931f fix: removed extra spaces form estimate points brackets (#837) 2023-04-17 11:15:06 +05:30
Aaryan Khandelwal
363c5c8ec4 fix: join project mutation (#835)
* fix: join project mutation

* chore: remove imports
2023-04-17 10:27:20 +05:30
Kyle Lacy
5e5d1a4699 Update .env.example with newly-added env vars 2023-04-15 12:56:43 -07:00
Kyle Lacy
e2294f9105 Add EMAIL_FROM setting to change sender email for messages 2023-04-15 12:51:23 -07:00
Kyle Lacy
f757d8232b Add setting to disable extra TLS config for Celery broker connection 2023-04-15 12:32:54 -07:00
Kyle Lacy
6af54ebbe7 Fix typo in `.env.example 2023-04-15 12:32:00 -07:00
Kyle Lacy
3913cf571f Make SMTP port and TLS configurable 2023-04-15 12:00:02 -07:00
Aaryan Khandelwal
8638170a98 fix: cycles and modules sidebar mutation (#831) 2023-04-14 19:40:00 +05:30
Vamsi Kurama
ebff5d8c54 Merge pull request #830 from makeplane/develop
promote: dev to stage-release
2023-04-14 17:06:19 +05:30
vamsi
b7ce69c220 dev: migrations estimate points and themes 2023-04-14 17:04:02 +05:30
Vamsi Kurama
e4da207df5 Merge pull request #768 from makeplane/feat/workspace_themes
feat: workspace themes
2023-04-14 16:56:32 +05:30
Vamsi Kurama
3a0c5bab76 Merge pull request #804 from makeplane/fix/magic_sign_in
fix: connection error when signing in with code
2023-04-14 16:56:20 +05:30
Vamsi Kurama
1cd1505c7d Merge pull request #805 from makeplane/chore/celery_production_settings
chore: production settings for celery
2023-04-14 16:56:00 +05:30
Vamsi Kurama
bc7fab96c3 Merge pull request #812 from makeplane/fix/parent_issue_search
fix: parent issue search
2023-04-14 16:55:20 +05:30
Vamsi Kurama
a358260a22 Merge pull request #817 from makeplane/chore/issue_estimate_points
chore: set default value as null for estimate point
2023-04-14 16:54:43 +05:30
Dakshesh Jain
3b103da6a3 chore: added unsplash flag for self-hosted (#829)
* chore: added unsplash flag for self hosted

* fix: removed actual code and only using flag

* refactor: removed extra variable
2023-04-14 16:52:31 +05:30
Aaryan Khandelwal
23b4145565 chore: added session recorder key to env (#827) 2023-04-14 16:51:24 +05:30
Anmol Singh Bhatia
5848c326c7 fix: cycle and module sidebar fix (#828) 2023-04-14 16:44:06 +05:30
Aaryan Khandelwal
ce253b3cc9 refactor: drag function (#826) 2023-04-14 16:41:28 +05:30
Vamsi Kurama
3817511024 Merge pull request #824 from makeplane/feat/session_recorder
feat: session recorder
2023-04-14 16:09:31 +05:30
Kunal Vishwakarma
f50872f2a9 fix: empty issue design (#821)
* fix: empty issue design

* chore: removed unused imports
2023-04-14 16:04:16 +05:30
Anmol Singh Bhatia
a0b8f7188f fix: cycle card (#825) 2023-04-14 16:03:11 +05:30
Aaryan Khandelwal
2950877767 feat: session recorder integrated 2023-04-14 15:27:14 +05:30
Aaryan Khandelwal
c7d930f89b chore: add env flag to enable session recorder conditionally (#822) 2023-04-14 15:17:35 +05:30
pablohashescobar
81da8715d5 chore: update bug report template to include browser environment as well (#820)
* chore: update bug report template to include browser environment as well

* chore: update type to dropdown
2023-04-14 00:45:03 +05:30
pablohashescobar
b4c8323886 chore: set default value as null for estimate point 2023-04-13 19:54:34 +05:30
Anmol Singh Bhatia
c3ffd233a6 style: workspace url (#816) 2023-04-13 19:33:22 +05:30
Aaryan Khandelwal
3fa6185b63 fix: drag and drop function (#815)
* fix: kanban drag and drop

* fix: kanban board issue dnd mutation
2023-04-13 19:09:55 +05:30
Dakshesh Jain
6de94efc7d style: removed static 'app.plane.so' (#813) 2023-04-13 18:28:23 +05:30
pablohashescobar
0cd6d9d570 fix: parent issue search 2023-04-13 17:49:09 +05:30
Aaryan Khandelwal
657241c9c1 feat: clarity script added (#807) 2023-04-13 16:17:21 +05:30
Anmol Singh Bhatia
dc9ce5101c fix: workspace url error message (#809) 2023-04-13 15:46:25 +05:30
Kunal Vishwakarma
3457411c6a style: issue list (#798)
* style: issue list

* chore: changed the empty state images
2023-04-13 15:39:05 +05:30
Aaryan Khandelwal
b7a7508d5d fix: workspace dashboard duplicate keys (#803) 2023-04-13 15:38:43 +05:30
Dakshesh Jain
484a88d881 fix: unusual redirection on onboarding (#808) 2023-04-13 15:38:00 +05:30
pablohashescobar
cd69b06e5e fix: error message for jira importers (#794) 2023-04-13 00:34:53 +05:30
pablohashescobar
c4609b95cd fix: remove length check condition when updating issue property (#791) 2023-04-13 00:34:37 +05:30
pablohashescobar
6eb7ec0697 fix: typo in url for bulk creating labels (#788) 2023-04-13 00:34:23 +05:30
pablohashescobar
e232d39f0e feat: track estimate points in issue activity (#762)
* feat: track estimate points in issue activity

* dev: update comment
2023-04-13 00:34:12 +05:30
pablohashescobar
8a26fd0a97 fix: issue link activity (#761) 2023-04-13 00:34:02 +05:30
pablohashescobar
537a82028e fix: attachment activity (#760) 2023-04-13 00:33:50 +05:30
pablohashescobar
440ed08728 fix: issue attachment delete (#759) 2023-04-13 00:33:37 +05:30
pablohashescobar
c199e76038 chore: production settings for celery 2023-04-13 00:31:06 +05:30
pablohashescobar
b40fd4bbc2 fix: connection error when signing in with code 2023-04-12 19:55:31 +05:30
pablohashescobar
bc457846fe chore: move theme setting in user level from workspace level 2023-04-10 23:19:01 +05:30
pablohashescobar
b6c911f484 feat: workspace themes 2023-04-10 18:14:09 +05:30
Vamsi Kurama
3d6f2dd3dc Merge pull request #703 from makeplane/stage-release
promote: stage-release to production
2023-04-04 19:38:37 +05:30
436 changed files with 17798 additions and 9738 deletions

20
.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Replace with your instance Public IP
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
NEXT_PUBLIC_GOOGLE_CLIENTID=""
NEXT_PUBLIC_GITHUB_APP_NAME=""
NEXT_PUBLIC_GITHUB_ID=""
NEXT_PUBLIC_SENTRY_DSN=""
NEXT_PUBLIC_ENABLE_OAUTH=0
NEXT_PUBLIC_ENABLE_SENTRY=0
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
NEXT_PUBLIC_TRACK_EVENTS=0
NEXT_PUBLIC_SLACK_CLIENT_ID=""
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
AWS_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_S3_BUCKET_NAME=""
OPENAI_API_KEY=""
GPT_ENGINE=""

View File

@@ -44,6 +44,15 @@ body:
- Deploy preview
validations:
required: true
type: dropdown
id: browser
attributes:
label: Browser
options:
- Google Chrome
- Mozilla Firefox
- Safari
- Other
- type: dropdown
id: version
attributes:

View File

@@ -1,4 +1,4 @@
name: Build Api Server Docker Image
name: Build and Push Backend Docker Image
on:
push:
@@ -10,11 +10,8 @@ on:
jobs:
build_push_backend:
name: Build Api Server Docker Image
name: Build and Push Api Server Docker Image
runs-on: ubuntu-20.04
permissions:
contents: read
packages: write
steps:
- name: Check out the repo
@@ -28,20 +25,33 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Github Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.1.0
with:
registry: "ghcr.io"
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
registry: "registry.hub.docker.com"
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
id: ghmeta
uses: docker/metadata-action@v4.3.0
with:
images: makeplane/plane-backend
- name: Extract metadata (tags, labels) for Docker (Github)
id: dkrmeta
uses: docker/metadata-action@v4.3.0
with:
images: ghcr.io/${{ github.repository }}-backend
- name: Build Api Server
- name: Build and Push to GitHub Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
@@ -50,5 +60,18 @@ jobs:
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.ghmeta.outputs.tags }}
labels: ${{ steps.ghmeta.outputs.labels }}
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.dkrmeta.outputs.tags }}
labels: ${{ steps.dkrmeta.outputs.labels }}

View File

@@ -1,4 +1,4 @@
name: Build Frontend Docker Image
name: Build and Push Frontend Docker Image
on:
push:
@@ -12,9 +12,6 @@ jobs:
build_push_frontend:
name: Build Frontend Docker Image
runs-on: ubuntu-20.04
permissions:
contents: read
packages: write
steps:
- name: Check out the repo
@@ -35,13 +32,26 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
registry: "registry.hub.docker.com"
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
id: ghmeta
uses: docker/metadata-action@v4.3.0
with:
images: makeplane/plane-frontend
- name: Extract metadata (tags, labels) for Docker (Github)
id: meta
uses: docker/metadata-action@v4.3.0
with:
images: ghcr.io/${{ github.repository }}-frontend
- name: Build Frontend Server
- name: Build and Push to GitHub Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
@@ -50,5 +60,18 @@ jobs:
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.ghmeta.outputs.tags }}
labels: ${{ steps.ghmeta.outputs.labels }}
- name: Build and Push to Docker Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/app/Dockerfile.web
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.dkrmeta.outputs.tags }}
labels: ${{ steps.dkrmeta.outputs.labels }}

View File

@@ -3,6 +3,7 @@ RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
COPY . .
@@ -16,7 +17,7 @@ FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
@@ -26,9 +27,16 @@ RUN yarn install
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build --filter=app
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL}
FROM python:3.11.1-alpine3.17 AS backend
@@ -108,6 +116,16 @@ COPY nginx/nginx-single-docker-image.conf /etc/nginx/http.d/default.conf
COPY nginx/supervisor.conf /code/supervisor.conf
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
CMD ["supervisord","-c","/code/supervisor.conf"]

View File

@@ -26,7 +26,7 @@
</a>
</p>
Meet Plane. An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️.
Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️.
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
@@ -58,11 +58,18 @@ cd plane
> If running in a cloud env replace localhost with public facing IP address of the VM
- Export Environment Variables
```bash
set -a
source .env
set +a
```
- Run Docker compose up
```bash
docker-compose up
docker-compose -f docker-compose-hub.yml up
```
<strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong>

View File

@@ -1,24 +0,0 @@
DJANGO_SETTINGS_MODULE="plane.settings.production"
# Database
DATABASE_URL=postgres://plane:xyzzyspoon@db:5432/plane
# Cache
REDIS_URL=redis://redis:6379/
# SMPT
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
# AWS
AWS_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_S3_BUCKET_NAME=""
# FE
WEB_URL="localhost/"
# OAUTH
GITHUB_CLIENT_SECRET=""
# Flags
DISABLE_COLLECTSTATIC=1
DOCKERIZED=1
# GPT Envs
OPENAI_API_KEY=0
GPT_ENGINE=0

View File

@@ -3,7 +3,15 @@ import uuid
import random
from django.contrib.auth.hashers import make_password
from plane.db.models import ProjectIdentifier
from plane.db.models import Issue, IssueComment, User, Project, ProjectMember, Label
from plane.db.models import (
Issue,
IssueComment,
User,
Project,
ProjectMember,
Label,
Integration,
)
# Update description and description html values for old descriptions
@@ -174,3 +182,29 @@ def update_label_color():
except Exception as e:
print(e)
print("Failed")
def create_slack_integration():
try:
_ = Integration.objects.create(provider="slack", network=2, title="Slack")
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_integration_verified():
try:
integrations = Integration.objects.all()
updated_integrations = []
for integration in integrations:
integration.verified = True
updated_integrations.append(integration)
Integration.objects.bulk_update(
updated_integrations, ["verified"], batch_size=10
)
print("Sucess")
except Exception as e:
print(e)
print("Failed")

View File

@@ -11,6 +11,7 @@ from .workspace import (
TeamSerializer,
WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer,
WorkspaceThemeSerializer,
)
from .project import (
ProjectSerializer,
@@ -61,10 +62,13 @@ from .integration import (
GithubRepositorySerializer,
GithubRepositorySyncSerializer,
GithubCommentSyncSerializer,
SlackProjectSyncSerializer,
)
from .importer import ImporterSerializer
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
from .estimate import EstimateSerializer, EstimatePointSerializer
from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer
from .analytic import AnalyticViewSerializer

View File

@@ -0,0 +1,30 @@
from .base import BaseSerializer
from plane.db.models import AnalyticView
from plane.utils.issue_filters import issue_filters
class AnalyticViewSerializer(BaseSerializer):
class Meta:
model = AnalyticView
fields = "__all__"
read_only_fields = [
"workspace",
"query",
]
def create(self, validated_data):
query_params = validated_data.get("query_dict", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
return AnalyticView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)

View File

@@ -19,10 +19,32 @@ class CycleSerializer(BaseSerializer):
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
assignees = serializers.SerializerMethodField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
def get_assignees(self, obj):
members = [
{
"avatar": assignee.avatar,
"first_name": assignee.first_name,
"id": assignee.id,
}
for issue_cycle in obj.issue_cycle.all()
for assignee in issue_cycle.issue.assignees.all()
]
# Use a set comprehension to return only the unique objects
unique_objects = {frozenset(item.items()) for item in members}
# Convert the set back to a list of dictionaries
unique_list = [dict(item) for item in unique_objects]
return unique_list
class Meta:
model = Cycle
fields = "__all__"

View File

@@ -2,9 +2,13 @@
from .base import BaseSerializer
from plane.db.models import Estimate, EstimatePoint
from plane.api.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer
class EstimateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Estimate
fields = "__all__"
@@ -23,3 +27,18 @@ class EstimatePointSerializer(BaseSerializer):
"workspace",
"project",
]
class EstimateReadSerializer(BaseSerializer):
points = EstimatePointSerializer(read_only=True, many=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Estimate
fields = "__all__"
read_only_fields = [
"points",
"name",
"description",
]

View File

@@ -2,12 +2,14 @@
from .base import BaseSerializer
from .user import UserLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from plane.db.models import Importer
class ImporterSerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = Importer

View File

@@ -5,3 +5,4 @@ from .github import (
GithubIssueSyncSerializer,
GithubCommentSyncSerializer,
)
from .slack import SlackProjectSyncSerializer

View File

@@ -0,0 +1,14 @@
# Module imports
from plane.api.serializers import BaseSerializer
from plane.db.models import SlackProjectSync
class SlackProjectSyncSerializer(BaseSerializer):
class Meta:
model = SlackProjectSync
fields = "__all__"
read_only_fields = [
"project",
"workspace",
"workspace_integration",
]

View File

@@ -183,7 +183,7 @@ class IssueCreateSerializer(BaseSerializer):
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
if blockers is not None and len(blockers):
if blockers is not None:
IssueBlocker.objects.filter(block=instance).delete()
IssueBlocker.objects.bulk_create(
[
@@ -200,7 +200,7 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
if assignees is not None and len(assignees):
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create(
[
@@ -217,7 +217,7 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
if labels is not None and len(labels):
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete()
IssueLabel.objects.bulk_create(
[
@@ -234,7 +234,7 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
if blocks is not None and len(blocks):
if blocks is not None:
IssueBlocker.objects.filter(blocked_by=instance).delete()
IssueBlocker.objects.bulk_create(
[

View File

@@ -25,22 +25,18 @@ class IssueViewSerializer(BaseSerializer):
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
if not bool(query_params):
raise serializers.ValidationError(
{"query_data": ["Query data field cannot be empty"]}
)
validated_data["query"] = issue_filters(query_params, "POST")
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
return IssueView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if not bool(query_params):
raise serializers.ValidationError(
{"query_data": ["Query data field cannot be empty"]}
)
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)

View File

@@ -5,8 +5,15 @@ from rest_framework import serializers
from .base import BaseSerializer
from .user import UserLiteSerializer
from plane.db.models import User, Workspace, WorkspaceMember, Team, TeamMember
from plane.db.models import Workspace, WorkspaceMember, Team, WorkspaceMemberInvite
from plane.db.models import (
User,
Workspace,
WorkspaceMember,
Team,
TeamMember,
WorkspaceMemberInvite,
WorkspaceTheme,
)
class WorkSpaceSerializer(BaseSerializer):
@@ -100,3 +107,13 @@ class WorkspaceLiteSerializer(BaseSerializer):
"id",
]
read_only_fields = fields
class WorkspaceThemeSerializer(BaseSerializer):
class Meta:
model = WorkspaceTheme
fields = "__all__"
read_only_fields = [
"workspace",
"actor",
]

View File

@@ -42,6 +42,7 @@ from plane.api.views import (
UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
## End Workspaces
# File Assets
FileAssetEndpoint,
@@ -80,8 +81,6 @@ from plane.api.views import (
StateViewSet,
## End States
# Estimates
EstimateViewSet,
EstimatePointViewSet,
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
## End Estimates
@@ -132,6 +131,7 @@ from plane.api.views import (
GithubIssueSyncViewSet,
GithubCommentSyncViewSet,
BulkCreateGithubIssueSyncEndpoint,
SlackProjectSyncViewSet,
## End Integrations
# Importer
ServiceIssueImportSummaryEndpoint,
@@ -145,6 +145,16 @@ from plane.api.views import (
# Gpt
GPTIntegrationEndpoint,
## End Gpt
# Release Notes
ReleaseNotesEndpoint,
## End Release Notes
# Analytics
AnalyticsEndpoint,
AnalyticViewViewset,
SavedAnalyticEndpoint,
ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint,
## End Analytics
)
@@ -350,6 +360,27 @@ urlpatterns = [
WorkspaceMemberUserViewsEndpoint.as_view(),
name="workspace-member-details",
),
path(
"workspaces/<str:slug>/workspace-themes/",
WorkspaceThemeViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="workspace-themes",
),
path(
"workspaces/<str:slug>/workspace-themes/<uuid:pk>/",
WorkspaceThemeViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="workspace-themes",
),
## End Workspaces ##
# Projects
path(
@@ -485,62 +516,34 @@ urlpatterns = [
name="project-state",
),
# End States ##
# States
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/",
EstimateViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-estimates",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:pk>/",
EstimateViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-estimates",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/",
EstimatePointViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-estimate-points",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/<uuid:pk>/",
EstimatePointViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-estimates",
),
# Estimates
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-estimates/",
ProjectEstimatePointEndpoint.as_view(),
name="project-estimate-points",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/bulk-estimate-points/",
BulkEstimatePointEndpoint.as_view(),
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/",
BulkEstimatePointEndpoint.as_view(
{
"get": "list",
"post": "create",
}
),
name="bulk-create-estimate-points",
),
# End States ##
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/",
BulkEstimatePointEndpoint.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="bulk-create-estimate-points",
),
# End Estimates ##
# Shortcuts
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/shortcuts/",
@@ -759,7 +762,7 @@ urlpatterns = [
name="project-issue-labels",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/lk-create-labels/",
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-create-labels/",
BulkCreateIssueLabelsEndpoint.as_view(),
name="project-bulk-labels",
),
@@ -1215,6 +1218,26 @@ urlpatterns = [
),
),
## End Github Integrations
# Slack Integration
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/",
SlackProjectSyncViewSet.as_view(
{
"post": "create",
"get": "list",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/<uuid:pk>/",
SlackProjectSyncViewSet.as_view(
{
"delete": "destroy",
"get": "retrieve",
}
),
),
## End Slack Integration
## End Integrations
# Importer
path(
@@ -1262,4 +1285,45 @@ urlpatterns = [
name="importer",
),
## End Gpt
# Release Notes
path(
"release-notes/",
ReleaseNotesEndpoint.as_view(),
name="release-notes",
),
## End Release Notes
# Analytics
path(
"workspaces/<str:slug>/analytics/",
AnalyticsEndpoint.as_view(),
name="plane-analytics",
),
path(
"workspaces/<str:slug>/analytic-view/",
AnalyticViewViewset.as_view({"get": "list", "post": "create"}),
name="analytic-view",
),
path(
"workspaces/<str:slug>/analytic-view/<uuid:pk>/",
AnalyticViewViewset.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
name="analytic-view",
),
path(
"workspaces/<str:slug>/saved-analytic-view/<uuid:analytic_id>/",
SavedAnalyticEndpoint.as_view(),
name="saved-analytic-view",
),
path(
"workspaces/<str:slug>/export-analytics/",
ExportAnalyticsEndpoint.as_view(),
name="export-analytics",
),
path(
"workspaces/<str:slug>/default-analytics/",
DefaultAnalyticsEndpoint.as_view(),
name="default-analytics",
),
## End Analytics
]

View File

@@ -40,6 +40,7 @@ from .workspace import (
UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
)
from .state import StateViewSet
from .shortcut import ShortCutViewSet
@@ -105,6 +106,7 @@ from .integration import (
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
BulkCreateGithubIssueSyncEndpoint,
SlackProjectSyncViewSet,
)
from .importer import (
@@ -132,8 +134,17 @@ from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .gpt import GPTIntegrationEndpoint
from .estimate import (
EstimateViewSet,
EstimatePointViewSet,
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
)
from .release import ReleaseNotesEndpoint
from .analytic import (
AnalyticsEndpoint,
AnalyticViewViewset,
SavedAnalyticEndpoint,
ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint,
)

View File

@@ -0,0 +1,283 @@
# Django imports
from django.db.models import (
Count,
Sum,
F,
)
from django.db.models.functions import ExtractMonth
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from plane.api.views import BaseAPIView, BaseViewSet
from plane.api.permissions import WorkSpaceAdminPermission
from plane.db.models import Issue, AnalyticView, Workspace, State, Label
from plane.api.serializers import AnalyticViewSerializer
from plane.utils.analytics_plot import build_graph_plot
from plane.bgtasks.analytic_plot_export import analytic_export_task
from plane.utils.issue_filters import issue_filters
class AnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def get(self, request, slug):
try:
x_axis = request.GET.get("x_axis", False)
y_axis = request.GET.get("y_axis", False)
if not x_axis or not y_axis:
return Response(
{"error": "x-axis and y-axis dimensions are required"},
status=status.HTTP_400_BAD_REQUEST,
)
segment = request.GET.get("segment", False)
filters = issue_filters(request.GET, "GET")
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
total_issues = queryset.count()
distribution = build_graph_plot(
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
)
colors = dict()
if x_axis in ["state__name", "state__group"] or segment in [
"state__name",
"state__group",
]:
if x_axis in ["state__name", "state__group"]:
key = "name" if x_axis == "state__name" else "group"
else:
key = "name" if segment == "state__name" else "group"
colors = (
State.objects.filter(
workspace__slug=slug, project_id__in=filters.get("project__in")
).values(key, "color")
if filters.get("project__in", False)
else State.objects.filter(workspace__slug=slug).values(key, "color")
)
if x_axis in ["labels__name"] or segment in ["labels__name"]:
colors = (
Label.objects.filter(
workspace__slug=slug, project_id__in=filters.get("project__in")
).values("name", "color")
if filters.get("project__in", False)
else Label.objects.filter(workspace__slug=slug).values(
"name", "color"
)
)
return Response(
{
"total": total_issues,
"distribution": distribution,
"extras": {"colors": colors},
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class AnalyticViewViewset(BaseViewSet):
permission_classes = [
WorkSpaceAdminPermission,
]
model = AnalyticView
serializer_class = AnalyticViewSerializer
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(workspace_id=workspace.id)
def get_queryset(self):
return self.filter_queryset(
super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))
)
class SavedAnalyticEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def get(self, request, slug, analytic_id):
try:
analytic_view = AnalyticView.objects.get(
pk=analytic_id, workspace__slug=slug
)
filter = analytic_view.query
queryset = Issue.objects.filter(**filter)
x_axis = analytic_view.query_dict.get("x_axis", False)
y_axis = analytic_view.query_dict.get("y_axis", False)
if not x_axis or not y_axis:
return Response(
{"error": "x-axis and y-axis dimensions are required"},
status=status.HTTP_400_BAD_REQUEST,
)
segment = request.GET.get("segment", False)
distribution = build_graph_plot(
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
)
total_issues = queryset.count()
return Response(
{"total": total_issues, "distribution": distribution},
status=status.HTTP_200_OK,
)
except AnalyticView.DoesNotExist:
return Response(
{"error": "Analytic View Does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ExportAnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug):
try:
x_axis = request.data.get("x_axis", False)
y_axis = request.data.get("y_axis", False)
if not x_axis or not y_axis:
return Response(
{"error": "x-axis and y-axis dimensions are required"},
status=status.HTTP_400_BAD_REQUEST,
)
analytic_export_task.delay(
email=request.user.email, data=request.data, slug=slug
)
return Response(
{
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class DefaultAnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def get(self, request, slug):
try:
filters = issue_filters(request.GET, "GET")
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
total_issues = queryset.count()
total_issues_classified = (
queryset.annotate(state_group=F("state__group"))
.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
)
open_issues = queryset.filter(
state__group__in=["backlog", "unstarted", "started"]
).count()
open_issues_classified = (
queryset.filter(state__group__in=["backlog", "unstarted", "started"])
.annotate(state_group=F("state__group"))
.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
)
issue_completed_month_wise = (
queryset.filter(completed_at__isnull=False)
.annotate(month=ExtractMonth("completed_at"))
.values("month")
.annotate(count=Count("*"))
.order_by("month")
)
most_issue_created_user = (
queryset.exclude(created_by=None)
.values("created_by__email", "created_by__avatar")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
most_issue_closed_user = (
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
.values("assignees__email", "assignees__avatar")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
pending_issue_user = (
queryset.filter(completed_at__isnull=True)
.values("assignees__email", "assignees__avatar")
.annotate(count=Count("id"))
.order_by("-count")
)
open_estimate_sum = (
Issue.objects.filter(
state__group__in=["backlog", "unstarted", "started"]
).aggregate(open_estimate_sum=Sum("estimate_point"))
)["open_estimate_sum"]
total_estimate_sum = Issue.objects.aggregate(
total_estimate_sum=Sum("estimate_point")
)["total_estimate_sum"]
return Response(
{
"total_issues": total_issues,
"total_issues_classified": total_issues_classified,
"open_issues": open_issues,
"open_issues_classified": open_issues_classified,
"issue_completed_month_wise": issue_completed_month_wise,
"most_issue_created_user": most_issue_created_user,
"most_issue_closed_user": most_issue_closed_user,
"pending_issue_user": pending_issue_user,
"open_estimate_sum": open_estimate_sum,
"total_estimate_sum": total_estimate_sum,
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -10,7 +10,7 @@ from rest_framework.views import APIView
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import NotFound
from sentry_sdk import capture_exception
from django_filters.rest_framework import DjangoFilterBackend
# Module imports
@@ -39,7 +39,7 @@ class BaseViewSet(ModelViewSet, BasePaginator):
try:
return self.model.objects.all()
except Exception as e:
print(e)
capture_exception(e)
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)
def dispatch(self, request, *args, **kwargs):

View File

@@ -3,7 +3,7 @@ import json
# Django imports
from django.db import IntegrityError
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef, Count, Prefetch
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef, Count, Prefetch, Sum
from django.core import serializers
from django.utils import timezone
from django.utils.decorators import method_decorator
@@ -24,6 +24,7 @@ from plane.api.serializers import (
)
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import (
User,
Cycle,
CycleIssue,
Issue,
@@ -48,6 +49,28 @@ class CycleViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"), owned_by=self.request.user
)
def perform_destroy(self, instance):
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
)
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
{
"cycle_id": str(self.kwargs.get("pk")),
"issues": [str(issue_id) for issue_id in cycle_issues],
}
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
return super().perform_destroy(instance)
def get_queryset(self):
subquery = CycleFavorite.objects.filter(
user=self.request.user,
@@ -96,6 +119,25 @@ class CycleViewSet(BaseViewSet):
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
)
)
.order_by("-is_favorite", "name")
.distinct()
)
@@ -181,6 +223,22 @@ class CycleIssueViewSet(BaseViewSet):
cycle_id=self.kwargs.get("cycle_id"),
)
def perform_destroy(self, instance):
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
{
"cycle_id": str(self.kwargs.get("cycle_id")),
"issues": [str(instance.issue_id)],
}
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
return super().perform_destroy(instance)
def get_queryset(self):
return self.filter_queryset(
super()
@@ -286,9 +344,9 @@ class CycleIssueViewSet(BaseViewSet):
# Get all CycleIssues already created
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
records_to_update = []
update_cycle_issue_activity = []
record_to_create = []
records_to_update = []
for issue in issues:
cycle_issue = [
@@ -333,7 +391,7 @@ class CycleIssueViewSet(BaseViewSet):
# Capture Issue Activity
issue_activity.delay(
type="issue.activity.updated",
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": issues}),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
@@ -463,6 +521,27 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")
)
@@ -507,6 +586,27 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")
)
@@ -580,6 +680,27 @@ class CompletedCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")
)
@@ -655,6 +776,27 @@ class DraftCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")
)

View File

@@ -10,110 +10,11 @@ from sentry_sdk import capture_exception
from .base import BaseViewSet, BaseAPIView
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Project, Estimate, EstimatePoint
from plane.api.serializers import EstimateSerializer, EstimatePointSerializer
class EstimateViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
model = Estimate
serializer_class = EstimateSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.distinct()
)
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
class EstimatePointViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
model = EstimatePoint
serializer_class = EstimatePointSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.filter(estimate_id=self.kwargs.get("estimate_id"))
.select_related("project")
.select_related("workspace")
.distinct()
)
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
estimate_id=self.kwargs.get("estimate_id"),
)
def create(self, request, slug, project_id, estimate_id):
try:
serializer = EstimatePointSerializer(data=request.data)
if serializer.is_valid():
serializer.save(estimate_id=estimate_id, project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"error": "The estimate point is already taken"},
status=status.HTTP_410_GONE,
)
else:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, estimate_id, pk):
try:
estimate_point = EstimatePoint.objects.get(
pk=pk,
estimate_id=estimate_id,
project_id=project_id,
workspace__slug=slug,
)
serializer = EstimatePointSerializer(
estimate_point, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save(estimate_id=estimate_id, project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except EstimatePoint.DoesNotExist:
return Response(
{"error": "Estimate Point does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"error": "The estimate point value is already taken"},
status=status.HTTP_410_GONE,
)
else:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
from plane.api.serializers import (
EstimateSerializer,
EstimatePointSerializer,
EstimateReadSerializer,
)
class ProjectEstimatePointEndpoint(BaseAPIView):
@@ -141,17 +42,35 @@ class ProjectEstimatePointEndpoint(BaseAPIView):
)
class BulkEstimatePointEndpoint(BaseAPIView):
class BulkEstimatePointEndpoint(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
model = Estimate
serializer_class = EstimateSerializer
def post(self, request, slug, project_id, estimate_id):
def list(self, request, slug, project_id):
try:
estimate = Estimate.objects.get(
pk=estimate_id, workspace__slug=slug, project=project_id
estimates = Estimate.objects.filter(
workspace__slug=slug, project_id=project_id
).prefetch_related("points").select_related("workspace", "project")
serializer = EstimateReadSerializer(estimates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def create(self, request, slug, project_id):
try:
if not request.data.get("estimate", False):
return Response(
{"error": "Estimate is required"},
status=status.HTTP_400_BAD_REQUEST,
)
estimate_points = request.data.get("estimate_points", [])
if not len(estimate_points) or len(estimate_points) > 8:
@@ -160,6 +79,18 @@ class BulkEstimatePointEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
estimate_serializer = EstimateSerializer(data=request.data.get("estimate"))
if not estimate_serializer.is_valid():
return Response(
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
try:
estimate = estimate_serializer.save(project_id=project_id)
except IntegrityError:
return Response(
{"errror": "Estimate with the name already exists"},
status=status.HTTP_400_BAD_REQUEST,
)
estimate_points = EstimatePoint.objects.bulk_create(
[
EstimatePoint(
@@ -178,9 +109,17 @@ class BulkEstimatePointEndpoint(BaseAPIView):
ignore_conflicts=True,
)
serializer = EstimatePointSerializer(estimate_points, many=True)
estimate_point_serializer = EstimatePointSerializer(
estimate_points, many=True
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
{
"estimate": estimate_serializer.data,
"estimate_points": estimate_point_serializer.data,
},
status=status.HTTP_200_OK,
)
except Estimate.DoesNotExist:
return Response(
{"error": "Estimate does not exist"},
@@ -193,14 +132,58 @@ class BulkEstimatePointEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
def patch(self, request, slug, project_id, estimate_id):
def retrieve(self, request, slug, project_id, estimate_id):
try:
estimate = Estimate.objects.get(
pk=estimate_id, workspace__slug=slug, project_id=project_id
)
serializer = EstimateReadSerializer(estimate)
return Response(
serializer.data,
status=status.HTTP_200_OK,
)
except Estimate.DoesNotExist:
return Response(
{"error": "Estimate does not exist"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, estimate_id):
try:
if not request.data.get("estimate", False):
return Response(
{"error": "Estimate is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not len(request.data.get("estimate_points", [])):
return Response(
{"error": "Estimate points are required"},
status=status.HTTP_400_BAD_REQUEST,
)
estimate = Estimate.objects.get(pk=estimate_id)
estimate_serializer = EstimateSerializer(
estimate, data=request.data.get("estimate"), partial=True
)
if not estimate_serializer.is_valid():
return Response(
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
try:
estimate = estimate_serializer.save()
except IntegrityError:
return Response(
{"errror": "Estimate with the name already exists"},
status=status.HTTP_400_BAD_REQUEST,
)
estimate_points_data = request.data.get("estimate_points", [])
estimate_points = EstimatePoint.objects.filter(
@@ -212,7 +195,6 @@ class BulkEstimatePointEndpoint(BaseAPIView):
estimate_id=estimate_id,
)
print(estimate_points)
updated_estimate_points = []
for estimate_point in estimate_points:
# Find the data for that estimate point
@@ -221,24 +203,50 @@ class BulkEstimatePointEndpoint(BaseAPIView):
for point in estimate_points_data
if point.get("id") == str(estimate_point.id)
]
print(estimate_point_data)
if len(estimate_point_data):
estimate_point.value = estimate_point_data[0].get(
"value", estimate_point.value
)
updated_estimate_points.append(estimate_point)
EstimatePoint.objects.bulk_update(
updated_estimate_points, ["value"], batch_size=10
try:
EstimatePoint.objects.bulk_update(
updated_estimate_points, ["value"], batch_size=10,
)
except IntegrityError as e:
return Response(
{"error": "Values need to be unique for each key"},
status=status.HTTP_400_BAD_REQUEST,
)
estimate_point_serializer = EstimatePointSerializer(estimate_points, many=True)
return Response(
{
"estimate": estimate_serializer.data,
"estimate_points": estimate_point_serializer.data,
},
status=status.HTTP_200_OK,
)
serializer = EstimatePointSerializer(estimate_points, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Estimate.DoesNotExist:
return Response(
{"error": "Estimate does not exist"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, estimate_id):
try:
estimate = Estimate.objects.get(
pk=estimate_id, workspace__slug=slug, project_id=project_id
)
estimate.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,

View File

@@ -28,6 +28,7 @@ from plane.db.models import (
Module,
ModuleLink,
ModuleIssue,
Label,
)
from plane.api.serializers import (
ImporterSerializer,
@@ -65,29 +66,35 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
)
if service == "jira":
# Check for all the keys
params = {
"project_key": "Project key is required",
"api_token": "API token is required",
"email": "Email is required",
"cloud_hostname": "Cloud hostname is required",
}
for key, error_message in params.items():
if not request.GET.get(key, False):
return Response(
{"error": error_message}, status=status.HTTP_400_BAD_REQUEST
)
project_key = request.GET.get("project_key", "")
api_token = request.GET.get("api_token", "")
email = request.GET.get("email", "")
cloud_hostname = request.GET.get("cloud_hostname", "")
if (
not bool(project_key)
or not bool(api_token)
or not bool(email)
or not bool(cloud_hostname)
):
return Response(
{
"error": "Project name, Project key, API token, Cloud hostname and email are requied"
},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
jira_project_issue_summary(
email, api_token, project_key, cloud_hostname
),
status=status.HTTP_200_OK,
response = jira_project_issue_summary(
email, api_token, project_key, cloud_hostname
)
if "error" in response:
return Response(response, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
response,
status=status.HTTP_200_OK,
)
return Response(
{"error": "Service not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
@@ -98,7 +105,7 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@@ -229,9 +236,20 @@ class ImportServiceEndpoint(BaseAPIView):
def delete(self, request, slug, service, pk):
try:
importer = Importer.objects.filter(
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
# Delete all imported Issues
imported_issues = importer.imported_data.get("issues", [])
Issue.objects.filter(id__in=imported_issues).delete()
# Delete all imported Labels
imported_labels = importer.imported_data.get("labels", [])
Label.objects.filter(id__in=imported_labels).delete()
if importer.service == "jira":
imported_modules = importer.imported_data.get("modules", [])
Module.objects.filter(id__in=imported_modules).delete()
importer.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
@@ -241,6 +259,27 @@ class ImportServiceEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
def patch(self, request, slug, service, pk):
try:
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
serializer = ImporterSerializer(importer, 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)
except Importer.DoesNotExist:
return Response(
{"error": "Importer Does not exists"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UpdateServiceImportStatusEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service, importer_id):
@@ -324,6 +363,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
start_date=issue_data.get("start_date", None),
target_date=issue_data.get("target_date", None),
priority=issue_data.get("priority", None),
created_by=request.user,
)
)
@@ -361,7 +401,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for label_id in labels_list
]
@@ -381,7 +420,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for assignee_id in assignees_list
]
@@ -400,6 +438,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
workspace_id=project.workspace_id,
comment=f"{request.user.email} importer the issue from {service}",
verb="created",
created_by=request.user,
)
for issue in issues
],
@@ -418,7 +457,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for comment in comments_list
]
@@ -435,7 +473,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue, issue_data in zip(issues, issues_data)
]
@@ -473,7 +510,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for module in modules_data
],
@@ -481,48 +517,57 @@ class BulkImportModulesEndpoint(BaseAPIView):
ignore_conflicts=True,
)
_ = ModuleLink.objects.bulk_create(
[
ModuleLink(
module=module,
url=module_data.get("link", {}).get("url", "https://plane.so"),
title=module_data.get("link", {}).get(
"title", "Original Issue"
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for module, module_data in zip(modules, modules_data)
],
batch_size=100,
ignore_conflicts=True,
)
modules = Module.objects.filter(id__in=[module.id for module in modules])
bulk_module_issues = []
for module, module_data in zip(modules, modules_data):
module_issues_list = module_data.get("module_issues_list", [])
bulk_module_issues = bulk_module_issues + [
ModuleIssue(
issue_id=issue,
module=module,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in module_issues_list
]
if len(modules) == len(modules_data):
_ = ModuleLink.objects.bulk_create(
[
ModuleLink(
module=module,
url=module_data.get("link", {}).get(
"url", "https://plane.so"
),
title=module_data.get("link", {}).get(
"title", "Original Issue"
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for module, module_data in zip(modules, modules_data)
],
batch_size=100,
ignore_conflicts=True,
)
_ = ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=100, ignore_conflicts=True
)
bulk_module_issues = []
for module, module_data in zip(modules, modules_data):
module_issues_list = module_data.get("module_issues_list", [])
bulk_module_issues = bulk_module_issues + [
ModuleIssue(
issue_id=issue,
module=module,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for issue in module_issues_list
]
serializer = ModuleSerializer(modules, many=True)
return Response(
{"modules": serializer.data}, status=status.HTTP_201_CREATED
)
_ = ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=100, ignore_conflicts=True
)
serializer = ModuleSerializer(modules, many=True)
return Response(
{"modules": serializer.data}, status=status.HTTP_201_CREATED
)
else:
return Response(
{"message": "Modules created but issues could not be imported"},
status=status.HTTP_200_OK,
)
except Project.DoesNotExist:
return Response(
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND

View File

@@ -6,3 +6,4 @@ from .github import (
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
)
from .slack import SlackProjectSyncViewSet

View File

@@ -27,6 +27,7 @@ from plane.utils.integrations.github import (
)
from plane.api.permissions import WorkSpaceAdminPermission
class IntegrationViewSet(BaseViewSet):
serializer_class = IntegrationSerializer
model = Integration
@@ -101,7 +102,6 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
WorkSpaceAdminPermission,
]
def get_queryset(self):
return (
super()
@@ -112,21 +112,30 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
def create(self, request, slug, provider):
try:
installation_id = request.data.get("installation_id", None)
if not installation_id:
return Response(
{"error": "Installation ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
integration = Integration.objects.get(provider=provider)
config = {}
if provider == "github":
installation_id = request.data.get("installation_id", None)
if not installation_id:
return Response(
{"error": "Installation ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
metadata = get_github_metadata(installation_id)
config = {"installation_id": installation_id}
if provider == "slack":
metadata = request.data.get("metadata", {})
access_token = metadata.get("access_token", False)
team_id = metadata.get("team", {}).get("id", False)
if not metadata or not access_token or not team_id:
return Response(
{"error": "Access token and team id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
config = {"team_id": team_id, "access_token": access_token}
# Create a bot user
bot_user = User.objects.create(
email=f"{uuid.uuid4().hex}@plane.so",

View File

@@ -0,0 +1,59 @@
# Django import
from django.db import IntegrityError
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from plane.api.views import BaseViewSet, BaseAPIView
from plane.db.models import SlackProjectSync, WorkspaceIntegration, ProjectMember
from plane.api.serializers import SlackProjectSyncSerializer
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
class SlackProjectSyncViewSet(BaseViewSet):
permission_classes = [
ProjectBasePermission,
]
serializer_class = SlackProjectSyncSerializer
model = SlackProjectSync
def create(self, request, slug, project_id, workspace_integration_id):
try:
serializer = SlackProjectSyncSerializer(data=request.data)
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
)
if serializer.is_valid():
serializer.save(
project_id=project_id,
workspace_integration_id=workspace_integration_id,
)
workspace_integration = WorkspaceIntegration.objects.get(
pk=workspace_integration_id, workspace__slug=slug
)
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor, role=20, project_id=project_id
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response({"error": "Slack is already enabled for the project"}, status=status.HTTP_400_BAD_REQUEST)
except WorkspaceIntegration.DoesNotExist:
return Response(
{"error": "Workspace Integration does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -1,13 +1,14 @@
# Python imports
import json
import random
from itertools import groupby, chain
from itertools import chain
# Django imports
from django.db.models import Prefetch, OuterRef, Func, F, Q
from django.db.models import Prefetch, OuterRef, Func, F, Q, Count
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db.models.functions import Coalesce
# Third Party imports
from rest_framework.response import Response
@@ -46,6 +47,7 @@ from plane.db.models import (
Label,
IssueLink,
IssueAttachment,
State,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@@ -246,6 +248,20 @@ class UserWorkSpaceIssues(BaseAPIView):
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by("-created_at")
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
serializer = IssueLiteSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -576,8 +592,31 @@ class SubIssuesEndpoint(BaseAPIView):
.prefetch_related("labels")
)
serializer = IssueLiteSerializer(sub_issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
state_distribution = (
State.objects.filter(workspace__slug=slug, project_id=project_id)
.annotate(
state_count=Count(
"state_issue",
filter=Q(state_issue__parent_id=issue_id),
)
)
.order_by("group")
.values("group", "state_count")
)
result = {item["group"]: item["state_count"] for item in state_distribution}
serializer = IssueLiteSerializer(
sub_issues,
many=True,
)
return Response(
{
"sub_issues": serializer.data,
"state_distribution": result,
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
@@ -751,7 +790,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
serializer.save(project_id=project_id, issue_id=issue_id)
issue_activity.delay(
type="attachment.activity.created",
requested_data=request.data,
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
@@ -772,10 +811,11 @@ class IssueAttachmentEndpoint(BaseAPIView):
def delete(self, request, slug, project_id, issue_id, pk):
try:
issue_attachment = IssueAttachment.objects.get(pk=pk)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=request.data,
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),

View File

@@ -109,6 +109,28 @@ class ModuleViewSet(BaseViewSet):
.order_by("-is_favorite", "name")
)
def perform_destroy(self, instance):
module_issues = list(
ModuleIssue.objects.filter(module_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
)
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(self.kwargs.get("pk")),
"issues": [str(issue_id) for issue_id in module_issues],
}
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
return super().perform_destroy(instance)
def create(self, request, slug, project_id):
try:
project = Project.objects.get(workspace__slug=slug, pk=project_id)
@@ -158,6 +180,22 @@ class ModuleIssueViewSet(BaseViewSet):
module_id=self.kwargs.get("module_id"),
)
def perform_destroy(self, instance):
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(self.kwargs.get("module_id")),
"issues": [str(instance.issue_id)],
}
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
return super().perform_destroy(instance)
def get_queryset(self):
return self.filter_queryset(
super()
@@ -302,7 +340,7 @@ class ModuleIssueViewSet(BaseViewSet):
# Capture Issue Activity
issue_activity.delay(
type="issue.activity.updated",
type="module.activity.created",
requested_data=json.dumps({"modules_list": issues}),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),

View File

@@ -14,7 +14,7 @@ from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework import status
from sentry_sdk import capture_exception
# sso authentication
from google.oauth2 import id_token
from google.auth.transport import requests as google_auth_request
@@ -48,7 +48,7 @@ def validate_google_token(token, client_id):
}
return data
except Exception as e:
print(e)
capture_exception(e)
raise exceptions.AuthenticationFailed("Error with Google connection.")
@@ -305,8 +305,7 @@ class OauthEndpoint(BaseAPIView):
)
return Response(data, status=status.HTTP_201_CREATED)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{
"error": "Something went wrong. Please try again later or contact the support team."

View File

@@ -96,6 +96,36 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
# Only update access if the page owner is the requesting user
if (
page.access != request.data.get("access", page.access)
and page.owned_by_id != request.user.id
):
return Response(
{
"error": "Access cannot be updated since this page is owned by someone else"
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = PageSerializer(page, 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)
except Page.DoesNotExist:
return Response(
{"error": "Page Does not exist"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class PageBlockViewSet(BaseViewSet):
serializer_class = PageBlockSerializer
@@ -344,7 +374,7 @@ class RecentPagesEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,

View File

@@ -161,6 +161,7 @@ class ProjectViewSet(BaseViewSet):
workspace=serializer.instance.workspace,
group=state["group"],
default=state.get("default", False),
created_by=request.user,
)
for state in states
]
@@ -344,6 +345,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
workspace=invitation.project.workspace,
member=request.user,
role=invitation.role,
created_by=request.user,
)
for invitation in project_invitations
]
@@ -465,6 +467,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
project_id=project_id,
member_id=member,
workspace=workspace,
created_by=request.user,
)
)
@@ -612,6 +615,7 @@ class ProjectJoinEndpoint(BaseAPIView):
if workspace_role >= 15
else (15 if workspace_role == 10 else workspace_role),
workspace=workspace,
created_by=request.user,
)
for project_id in project_ids
],

View File

@@ -0,0 +1,21 @@
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from .base import BaseAPIView
from plane.utils.integrations.github import get_release_notes
class ReleaseNotesEndpoint(BaseAPIView):
def get(self, request):
try:
release_notes = get_release_notes()
return Response(release_notes, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -195,7 +195,7 @@ class GlobalSearchEndpoint(BaseAPIView):
return Response({"results": results}, status=status.HTTP_200_OK)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@@ -221,6 +221,10 @@ class IssueSearchEndpoint(BaseAPIView):
issue = Issue.objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True
).exclude(
pk__in=Issue.objects.filter(parent__isnull=False).values_list(
"parent_id", flat=True
)
)
if blocker_blocked_by == "true" and issue_id:
issues = issues.filter(blocker_issues=issue_id, blocked_issues=issue_id)

View File

@@ -1,6 +1,9 @@
# Python imports
from itertools import groupby
# Django imports
from django.db import IntegrityError
# Third party imports
from rest_framework.response import Response
from rest_framework import status
@@ -8,10 +11,10 @@ from sentry_sdk import capture_exception
# Module imports
from . import BaseViewSet
from . import BaseViewSet, BaseAPIView
from plane.api.serializers import StateSerializer
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import State
from plane.db.models import State, Issue
class StateViewSet(BaseViewSet):
@@ -36,6 +39,25 @@ class StateViewSet(BaseViewSet):
.distinct()
)
def create(self, request, slug, project_id):
try:
serializer = StateSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response(
{"error": "State with the name already exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def list(self, request, slug, project_id):
try:
state_dict = dict()
@@ -66,6 +88,17 @@ class StateViewSet(BaseViewSet):
{"error": "Default state cannot be deleted"}, status=False
)
# Check for any issues in the state
issue_exist = Issue.objects.filter(state=pk).exists()
if issue_exist:
return Response(
{
"error": "The state is not empty, only empty states can be deleted"
},
status=status.HTTP_400_BAD_REQUEST,
)
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except State.DoesNotExist:

View File

@@ -18,10 +18,6 @@ from plane.api.permissions import ProjectEntityPermission
from plane.db.models import (
IssueView,
Issue,
IssueBlocker,
IssueLink,
CycleIssue,
ModuleIssue,
IssueViewFavorite,
)
from plane.utils.issue_filters import issue_filters

View File

@@ -36,6 +36,7 @@ from plane.api.serializers import (
WorkSpaceMemberInviteSerializer,
UserLiteSerializer,
ProjectMemberSerializer,
WorkspaceThemeSerializer,
)
from plane.api.views.base import BaseAPIView
from . import BaseViewSet
@@ -48,6 +49,7 @@ from plane.db.models import (
ProjectMember,
IssueActivity,
Issue,
WorkspaceTheme,
)
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
from plane.bgtasks.workspace_invitation_task import workspace_invitation
@@ -143,7 +145,6 @@ class UserWorkSpacesEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
@@ -222,6 +223,7 @@ class InviteWorkspaceEndpoint(BaseAPIView):
algorithm="HS256",
),
role=email.get("role", 10),
created_by=request.user,
)
)
except ValidationError:
@@ -331,7 +333,6 @@ class JoinWorkspaceEndpoint(BaseAPIView):
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
@@ -381,6 +382,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
workspace=invitation.workspace,
member=request.user,
role=invitation.role,
created_by=request.user,
)
for invitation in workspace_invitations
],
@@ -752,3 +754,34 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceThemeViewSet(BaseViewSet):
permission_classes = [
WorkSpaceAdminPermission,
]
model = WorkspaceTheme
serializer_class = WorkspaceThemeSerializer
def get_queryset(self):
return super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))
def create(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
serializer = WorkspaceThemeSerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace=workspace, actor=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -0,0 +1,138 @@
# Python imports
import csv
import io
# Django imports
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Issue
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters
row_mapping = {
"state__name": "State",
"state__group": "State Group",
"labels__name": "Label",
"assignees__email": "Assignee Email",
"start_date": "Start Date",
"target_date": "Due Date",
"completed_at": "Completed At",
"created_at": "Created At",
"issue_count": "Issue Count",
"effort": "Effort",
}
@shared_task
def analytic_export_task(email, data, slug):
try:
filters = issue_filters(data, "POST")
queryset = Issue.objects.filter(**filters, workspace__slug=slug)
x_axis = data.get("x_axis", False)
y_axis = data.get("y_axis", False)
segment = data.get("segment", False)
distribution = build_graph_plot(
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
)
key = "count" if y_axis == "issue_count" else "effort"
if segment:
row_zero = [
row_mapping.get(x_axis, "X-Axis"),
]
segment_zero = []
for item in distribution:
current_dict = distribution.get(item)
for current in current_dict:
segment_zero.append(current.get("segment"))
segment_zero = list(set(segment_zero))
row_zero = row_zero + segment_zero
rows = []
for item in distribution:
generated_row = []
data = distribution.get(item)
for segment in segment_zero[1:]:
value = [x for x in data if x.get("segment") == segment]
if len(value):
generated_row.append(value[0].get(key))
else:
generated_row.append("")
rows.append(tuple(generated_row))
rows = [tuple(row_zero)] + rows
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
# Write CSV data to the buffer
for row in rows:
writer.writerow(row)
subject = "Your Export is ready"
html_content = render_to_string("emails/exports/analytics.html", {})
text_content = strip_tags(html_content)
msg = EmailMultiAlternatives(
subject, text_content, settings.EMAIL_FROM, [email]
)
msg.attach(f"{slug}-analytics.csv", csv_buffer.read())
msg.send(fail_silently=False)
else:
row_zero = [
row_mapping.get(x_axis, "X-Axis"),
row_mapping.get(y_axis, "Y-Axis"),
]
rows = []
for item in distribution:
rows.append(
tuple(
[
item,
distribution.get(item)[0].get("count")
if y_axis == "issue_count"
else distribution.get(item)[0].get("effort"),
]
)
)
rows = [tuple(row_zero)] + rows
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
# Write CSV data to the buffer
for row in rows:
writer.writerow(row)
subject = "Your Export is ready"
html_content = render_to_string("emails/exports/analytics.html", {})
text_content = strip_tags(html_content)
msg = EmailMultiAlternatives(
subject, text_content, settings.EMAIL_FROM, [email]
)
msg.attach(f"{slug}-analytics.csv", csv_buffer.read())
msg.send(fail_silently=False)
except Exception as e:
print(e)
capture_exception(e)
return

View File

@@ -2,6 +2,7 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
@@ -20,7 +21,7 @@ def email_verification(first_name, email, token, current_site):
realtivelink = "/request-email-verification/" + "?token=" + str(token)
abs_url = "http://" + current_site + realtivelink
from_email_string = f"Team Plane <team@mailer.plane.so>"
from_email_string = settings.EMAIL_FROM
subject = f"Verify your Email!"

View File

@@ -2,6 +2,7 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
@@ -18,7 +19,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
realtivelink = f"/email-verify/?uidb64={uidb64}&token={token}/"
abs_url = "http://" + current_site + realtivelink
from_email_string = f"Team Plane <team@mailer.plane.so>"
from_email_string = settings.EMAIL_FROM
subject = f"Verify your Email!"

View File

@@ -27,6 +27,7 @@ from plane.db.models import (
User,
)
from .workspace_invitation_task import workspace_invitation
from plane.bgtasks.user_welcome_task import send_welcome_email
@shared_task
@@ -40,7 +41,7 @@ def service_importer(service, importer_id):
# Check if we need to import users as well
if len(users):
# For all invited users create the uers
# For all invited users create the users
new_users = User.objects.bulk_create(
[
User(
@@ -56,6 +57,15 @@ def service_importer(service, importer_id):
ignore_conflicts=True,
)
[
send_welcome_email.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()
@@ -68,7 +78,11 @@ def service_importer(service, importer_id):
# Add new users to Workspace and project automatically
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(member=user, workspace_id=importer.workspace_id)
WorkspaceMember(
member=user,
workspace_id=importer.workspace_id,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
@@ -81,6 +95,7 @@ def service_importer(service, importer_id):
project_id=importer.project_id,
workspace_id=importer.workspace_id,
member=user,
created_by=importer.created_by,
)
for user in workspace_users
],

View File

@@ -136,7 +136,6 @@ def track_priority(
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
)
)
print(issue_activities)
# Track chnages in state of the issue
@@ -506,119 +505,6 @@ def track_blockings(
)
def track_cycles(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Updated Records:
updated_records = current_instance.get("updated_cycle_issues", [])
created_records = json.loads(current_instance.get("created_cycle_issues", []))
for updated_record in updated_records:
old_cycle = Cycle.objects.filter(
pk=updated_record.get("old_cycle_id", None)
).first()
new_cycle = Cycle.objects.filter(
pk=updated_record.get("new_cycle_id", None)
).first()
issue_activities.append(
IssueActivity(
issue_id=updated_record.get("issue_id"),
actor=actor,
verb="updated",
old_value=old_cycle.name,
new_value=new_cycle.name,
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
)
)
for created_record in created_records:
cycle = Cycle.objects.filter(
pk=created_record.get("fields").get("cycle")
).first()
issue_activities.append(
IssueActivity(
issue_id=created_record.get("fields").get("issue"),
actor=actor,
verb="created",
old_value="",
new_value=cycle.name,
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added cycle {cycle.name}",
new_identifier=cycle.id,
)
)
def track_modules(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Updated Records:
updated_records = current_instance.get("updated_module_issues", [])
created_records = json.loads(current_instance.get("created_module_issues", []))
for updated_record in updated_records:
old_module = Module.objects.filter(
pk=updated_record.get("old_module_id", None)
).first()
new_module = Module.objects.filter(
pk=updated_record.get("new_module_id", None)
).first()
issue_activities.append(
IssueActivity(
issue_id=updated_record.get("issue_id"),
actor=actor,
verb="updated",
old_value=old_module.name,
new_value=new_module.name,
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id,
new_identifier=new_module.id,
)
)
for created_record in created_records:
module = Module.objects.filter(
pk=created_record.get("fields").get("module")
).first()
issue_activities.append(
IssueActivity(
issue_id=created_record.get("fields").get("issue"),
actor=actor,
verb="created",
old_value="",
new_value=module.name,
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added module {module.name}",
new_identifier=module.id,
)
)
def create_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
@@ -634,6 +520,40 @@ def create_issue_activity(
)
def track_estimate_points(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
if current_instance.get("estimate_point") != requested_data.get("estimate_point"):
if requested_data.get("estimate_point") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("estimate_point"),
new_value=requested_data.get("estimate_point"),
field="estimate_point",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the estimate point to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("estimate_point"),
new_value=requested_data.get("estimate_point"),
field="estimate_point",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the estimate point to {requested_data.get('estimate_point')}",
)
)
def update_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
@@ -649,8 +569,7 @@ def update_issue_activity(
"assignees_list": track_assignees,
"blocks_list": track_blocks,
"blockers_list": track_blockings,
"cycles_list": track_cycles,
"modules_list": track_modules,
"estimate_point": track_estimate_points,
}
requested_data = json.loads(requested_data) if requested_data is not None else None
@@ -753,6 +672,177 @@ def delete_comment_activity(
)
def create_cycle_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
# Updated Records:
updated_records = current_instance.get("updated_cycle_issues", [])
created_records = json.loads(current_instance.get("created_cycle_issues", []))
for updated_record in updated_records:
old_cycle = Cycle.objects.filter(
pk=updated_record.get("old_cycle_id", None)
).first()
new_cycle = Cycle.objects.filter(
pk=updated_record.get("new_cycle_id", None)
).first()
issue_activities.append(
IssueActivity(
issue_id=updated_record.get("issue_id"),
actor=actor,
verb="updated",
old_value=old_cycle.name,
new_value=new_cycle.name,
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
)
)
for created_record in created_records:
cycle = Cycle.objects.filter(
pk=created_record.get("fields").get("cycle")
).first()
issue_activities.append(
IssueActivity(
issue_id=created_record.get("fields").get("issue"),
actor=actor,
verb="created",
old_value="",
new_value=cycle.name,
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added cycle {cycle.name}",
new_identifier=cycle.id,
)
)
def delete_cycle_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
cycle_id = requested_data.get("cycle_id", "")
cycle = Cycle.objects.filter(pk=cycle_id).first()
issues = requested_data.get("issues")
for issue in issues:
issue_activities.append(
IssueActivity(
issue_id=issue,
actor=actor,
verb="deleted",
old_value=cycle.name if cycle is not None else "",
new_value="",
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed this issue from {cycle.name if cycle is not None else None}",
old_identifier=cycle.id if cycle is not None else None,
)
)
def create_module_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
# Updated Records:
updated_records = current_instance.get("updated_module_issues", [])
created_records = json.loads(current_instance.get("created_module_issues", []))
for updated_record in updated_records:
old_module = Module.objects.filter(
pk=updated_record.get("old_module_id", None)
).first()
new_module = Module.objects.filter(
pk=updated_record.get("new_module_id", None)
).first()
issue_activities.append(
IssueActivity(
issue_id=updated_record.get("issue_id"),
actor=actor,
verb="updated",
old_value=old_module.name,
new_value=new_module.name,
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id,
new_identifier=new_module.id,
)
)
for created_record in created_records:
module = Module.objects.filter(
pk=created_record.get("fields").get("module")
).first()
issue_activities.append(
IssueActivity(
issue_id=created_record.get("fields").get("issue"),
actor=actor,
verb="created",
old_value="",
new_value=module.name,
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added module {module.name}",
new_identifier=module.id,
)
)
def delete_module_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
module_id = requested_data.get("module_id", "")
module = Module.objects.filter(pk=module_id).first()
issues = requested_data.get("issues")
for issue in issues:
issue_activities.append(
IssueActivity(
issue_id=issue,
actor=actor,
verb="deleted",
old_value=module.name if module is not None else "",
new_value="",
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed this issue from {module.name if module is not None else None}",
old_identifier=module.id if module is not None else None,
)
)
def create_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
@@ -772,7 +862,6 @@ def create_link_activity(
field="link",
new_value=requested_data.get("url", ""),
new_identifier=requested_data.get("id", None),
issue_comment_id=requested_data.get("id", None),
)
)
@@ -799,7 +888,6 @@ def update_link_activity(
old_identifier=current_instance.get("id"),
new_value=requested_data.get("url", ""),
new_identifier=current_instance.get("id", None),
issue_comment_id=current_instance.get("id", None),
)
)
@@ -833,13 +921,12 @@ def create_attachment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created a attachment",
comment=f"{actor.email} created an attachment",
verb="created",
actor=actor,
field="attachment",
new_value=requested_data.get("url", ""),
new_identifier=requested_data.get("id", None),
issue_comment_id=requested_data.get("id", None),
new_value=current_instance.get("access", ""),
new_identifier=current_instance.get("id", None),
)
)
@@ -878,6 +965,10 @@ def issue_activity(
"comment.activity.created": create_comment_activity,
"comment.activity.updated": update_comment_activity,
"comment.activity.deleted": delete_comment_activity,
"cycle.activity.created": create_cycle_issue_activity,
"cycle.activity.deleted": delete_cycle_issue_activity,
"module.activity.created": create_module_issue_activity,
"module.activity.deleted": delete_module_issue_activity,
"link.activity.created": create_link_activity,
"link.activity.updated": update_link_activity,
"link.activity.deleted": delete_link_activity,
@@ -915,6 +1006,5 @@ def issue_activity(
)
return
except Exception as e:
print(e)
capture_exception(e)
return

View File

@@ -2,6 +2,7 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
@@ -14,7 +15,7 @@ def magic_link(email, key, token, current_site):
realtivelink = f"/magic-sign-in/?password={token}&key={key}"
abs_url = "http://" + current_site + realtivelink
from_email_string = f"Team Plane <team@mailer.plane.so>"
from_email_string = settings.EMAIL_FROM
subject = f"Login for Plane"
@@ -29,6 +30,5 @@ def magic_link(email, key, token, current_site):
msg.send()
return
except Exception as e:
print(e)
capture_exception(e)
return

View File

@@ -2,6 +2,7 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
@@ -22,7 +23,7 @@ def project_invitation(email, project_id, token, current_site):
relativelink = f"/project-member-invitation/{project_member_invite.id}"
abs_url = "http://" + current_site + relativelink
from_email_string = f"Team Plane <team@mailer.plane.so>"
from_email_string = settings.EMAIL_FROM
subject = f"{project.created_by.first_name or project.created_by.email} invited you to join {project.name} on Plane"
@@ -49,6 +50,5 @@ def project_invitation(email, project_id, token, current_site):
except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist) as e:
return
except Exception as e:
print(e)
capture_exception(e)
return

View File

@@ -0,0 +1,56 @@
# Django imports
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
# Module imports
from plane.db.models import User
@shared_task
def send_welcome_email(user_id, created, message):
try:
instance = User.objects.get(pk=user_id)
if created and not instance.is_bot:
first_name = instance.first_name.capitalize()
to_email = instance.email
from_email_string = settings.EMAIL_FROM
subject = f"Welcome to Plane ✈️!"
context = {"first_name": first_name, "email": instance.email}
html_content = render_to_string(
"emails/auth/user_welcome_email.html", context
)
text_content = strip_tags(html_content)
msg = EmailMultiAlternatives(
subject, text_content, from_email_string, [to_email]
)
msg.attach_alternative(html_content, "text/html")
msg.send()
# 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=message,
)
except SlackApiError as e:
print(f"Got an error: {e.response['error']}")
return
except Exception as e:
capture_exception(e)
return

View File

@@ -27,7 +27,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
)
abs_url = "http://" + current_site + realtivelink
from_email_string = f"Team Plane <team@mailer.plane.so>"
from_email_string = settings.EMAIL_FROM
subject = f"{invitor or email} invited you to join {workspace.name} on Plane"

View File

@@ -0,0 +1,48 @@
# Generated by Django 3.2.18 on 2023-04-14 11:33
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0027_auto_20230409_0312'),
]
operations = [
migrations.AddField(
model_name='user',
name='theme',
field=models.JSONField(default=dict),
),
migrations.AlterField(
model_name='issue',
name='estimate_point',
field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(7)]),
),
migrations.CreateModel(
name='WorkspaceTheme',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=300)),
('colors', models.JSONField(default=dict)),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='themes', to=settings.AUTH_USER_MODEL)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacetheme_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacetheme_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='themes', to='db.workspace')),
],
options={
'verbose_name': 'Workspace Theme',
'verbose_name_plural': 'Workspace Themes',
'db_table': 'workspace_themes',
'ordering': ('-created_at',),
'unique_together': {('workspace', 'name')},
},
),
]

View File

@@ -0,0 +1,58 @@
# Generated by Django 3.2.18 on 2023-05-01 19:56
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0028_auto_20230414_1703'),
]
operations = [
migrations.AddField(
model_name='cycle',
name='view_props',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='importer',
name='imported_data',
field=models.JSONField(null=True),
),
migrations.AddField(
model_name='module',
name='view_props',
field=models.JSONField(default=dict),
),
migrations.CreateModel(
name='SlackProjectSync',
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)),
('access_token', models.CharField(max_length=300)),
('scopes', models.TextField()),
('bot_user_id', models.CharField(max_length=50)),
('webhook_url', models.URLField(max_length=1000)),
('data', models.JSONField(default=dict)),
('team_id', models.CharField(max_length=30)),
('team_name', models.CharField(max_length=300)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='slackprojectsync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_slackprojectsync', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='slackprojectsync_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_slackprojectsync', to='db.workspace')),
('workspace_integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slack_syncs', to='db.workspaceintegration')),
],
options={
'verbose_name': 'Slack Project Sync',
'verbose_name_plural': 'Slack Project Syncs',
'db_table': 'slack_project_syncs',
'ordering': ('-created_at',),
'unique_together': {('team_id', 'project')},
},
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.2.18 on 2023-05-05 14:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('db', '0029_auto_20230502_0126'),
]
operations = [
migrations.AlterUniqueTogether(
name='estimatepoint',
unique_together=set(),
),
]

View File

@@ -8,6 +8,7 @@ from .workspace import (
Team,
WorkspaceMemberInvite,
TeamMember,
WorkspaceTheme,
)
from .project import (
@@ -58,6 +59,7 @@ from .integration import (
GithubRepositorySync,
GithubIssueSync,
GithubCommentSync,
SlackProjectSync,
)
from .importer import Importer
@@ -65,3 +67,5 @@ from .importer import Importer
from .page import Page, PageBlock, PageFavorite, PageLabel
from .estimate import Estimate, EstimatePoint
from .analytic import AnalyticView

View File

@@ -0,0 +1,25 @@
# Django models
from django.db import models
from django.conf import settings
from .base import BaseModel
class AnalyticView(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", related_name="analytics", on_delete=models.CASCADE
)
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
query = models.JSONField()
query_dict = models.JSONField(default=dict)
class Meta:
verbose_name = "Analytic"
verbose_name_plural = "Analytics"
db_table = "analytic_views"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the analytic view"""
return f"{self.name} <{self.workspace.name}>"

View File

@@ -16,6 +16,7 @@ class Cycle(ProjectBaseModel):
on_delete=models.CASCADE,
related_name="owned_by_cycle",
)
view_props = models.JSONField(default=dict)
class Meta:
verbose_name = "Cycle"

View File

@@ -39,7 +39,6 @@ class EstimatePoint(ProjectBaseModel):
return f"{self.estimate.name} <{self.key}> <{self.value}>"
class Meta:
unique_together = ["value", "estimate"]
verbose_name = "Estimate Point"
verbose_name_plural = "Estimate Points"
db_table = "estimate_points"

View File

@@ -33,6 +33,7 @@ class Importer(ProjectBaseModel):
token = models.ForeignKey(
"db.APIToken", on_delete=models.CASCADE, related_name="importer"
)
imported_data = models.JSONField(null=True)
class Meta:
verbose_name = "Importer"

View File

@@ -1,2 +1,3 @@
from .base import Integration, WorkspaceIntegration
from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync
from .slack import SlackProjectSync

View File

@@ -0,0 +1,32 @@
# Python imports
import uuid
# Django imports
from django.db import models
# Module imports
from plane.db.models import ProjectBaseModel
class SlackProjectSync(ProjectBaseModel):
access_token = models.CharField(max_length=300)
scopes = models.TextField()
bot_user_id = models.CharField(max_length=50)
webhook_url = models.URLField(max_length=1000)
data = models.JSONField(default=dict)
team_id = models.CharField(max_length=30)
team_name = models.CharField(max_length=300)
workspace_integration = models.ForeignKey(
"db.WorkspaceIntegration", related_name="slack_syncs", on_delete=models.CASCADE
)
def __str__(self):
"""Return the repo name"""
return f"{self.project.name}"
class Meta:
unique_together = ["team_id", "project"]
verbose_name = "Slack Project Sync"
verbose_name_plural = "Slack Project Syncs"
db_table = "slack_project_syncs"
ordering = ("-created_at",)

View File

@@ -39,7 +39,7 @@ class Issue(ProjectBaseModel):
related_name="state_issue",
)
estimate_point = models.IntegerField(
default=0, validators=[MinValueValidator(0), MaxValueValidator(7)]
validators=[MinValueValidator(0), MaxValueValidator(7)], null=True, blank=True
)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict)

View File

@@ -39,6 +39,7 @@ class Module(ProjectBaseModel):
through="ModuleMember",
through_fields=("module", "member"),
)
view_props = models.JSONField(default=dict)
class Meta:
unique_together = ["name", "project"]

View File

@@ -72,6 +72,7 @@ class User(AbstractBaseUser, PermissionsMixin):
my_issues_prop = models.JSONField(null=True)
role = models.CharField(max_length=300, null=True, blank=True)
is_bot = models.BooleanField(default=False)
theme = models.JSONField(default=dict)
USERNAME_FIELD = "email"
@@ -108,7 +109,7 @@ def send_welcome_email(sender, instance, created, **kwargs):
if created and not instance.is_bot:
first_name = instance.first_name.capitalize()
to_email = instance.email
from_email_string = f"Team Plane <team@mailer.plane.so>"
from_email_string = settings.EMAIL_FROM
subject = f"Welcome to Plane ✈️!"

View File

@@ -36,7 +36,6 @@ class Workspace(BaseModel):
ordering = ("-created_at",)
class WorkspaceMember(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member"
@@ -111,7 +110,6 @@ class Team(BaseModel):
class TeamMember(BaseModel):
workspace = models.ForeignKey(
Workspace, on_delete=models.CASCADE, related_name="team_member"
)
@@ -129,3 +127,24 @@ class TeamMember(BaseModel):
verbose_name_plural = "Team Members"
db_table = "team_members"
ordering = ("-created_at",)
class WorkspaceTheme(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="themes"
)
name = models.CharField(max_length=300)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes"
)
colors = models.JSONField(default=dict)
def __str__(self):
return str(self.name) + str(self.actor.email)
class Meta:
unique_together = ["workspace", "name"]
verbose_name = "Workspace Theme"
verbose_name_plural = "Workspace Themes"
db_table = "workspace_themes"
ordering = ("-created_at",)

View File

@@ -174,11 +174,12 @@ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# Host for sending e-mail.
EMAIL_HOST = os.environ.get("EMAIL_HOST")
# Port for sending e-mail.
EMAIL_PORT = 587
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
# Optional SMTP authentication information for EMAIL_HOST.
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = True
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1"
EMAIL_FROM = os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>")
SIMPLE_JWT = {
@@ -210,4 +211,4 @@ SIMPLE_JWT = {
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_ACCEPT_CONTENT = ['application/json']

View File

@@ -83,3 +83,6 @@ LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL")
CELERY_BROKER_URL = os.environ.get("REDIS_URL")
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)

View File

@@ -1,5 +1,7 @@
"""Production settings and globals."""
from urllib.parse import urlparse
import ssl
import certifi
import dj_database_url
from urllib.parse import urlparse
@@ -103,7 +105,7 @@ if (
AWS_S3_ADDRESSING_STYLE = "auto"
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = ""
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
AWS_S3_KEY_PREFIX = ""
@@ -237,5 +239,16 @@ SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL")
CELERY_BROKER_URL = os.environ.get("REDIS_URL")
redis_url = os.environ.get("REDIS_URL")
broker_url = (
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
)
if DOCKERIZED:
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
else:
CELERY_RESULT_BACKEND = broker_url
CELERY_BROKER_URL = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)

View File

@@ -6,8 +6,10 @@ from urllib.parse import urlparse
def redis_instance():
# connect to redis
if settings.DOCKERIZED or os.environ.get(
"DJANGO_SETTINGS_MODULE", "plane.settings.local"
if (
settings.DOCKERIZED
or os.environ.get("DJANGO_SETTINGS_MODULE", "plane.settings.production")
== "plane.settings.local"
):
ri = redis.Redis.from_url(settings.REDIS_URL, db=0)
else:

View File

@@ -80,7 +80,7 @@ AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
AWS_S3_ADDRESSING_STYLE = "auto"
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = ""
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
AWS_S3_KEY_PREFIX = ""
@@ -203,4 +203,6 @@ redis_url = os.environ.get("REDIS_URL")
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
CELERY_RESULT_BACKEND = broker_url
CELERY_BROKER_URL = broker_url
CELERY_BROKER_URL = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)

View File

@@ -0,0 +1,57 @@
# Python imports
from itertools import groupby
# Django import
from django.db import models
from django.db.models import Count, DateField, F, Sum, Value, Case, When, Q
from django.db.models.functions import Cast, Concat, Coalesce
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
if x_axis in ["created_at", "completed_at"]:
queryset = queryset.annotate(dimension=Cast(x_axis, DateField()))
x_axis = "dimension"
else:
queryset = queryset.annotate(dimension=F(x_axis))
x_axis = "dimension"
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
queryset = queryset.exclude(x_axis__is_null=True)
queryset = queryset.values(x_axis)
# Group queryset by x_axis field
if y_axis == "issue_count":
queryset = (
queryset.annotate(
is_null=Case(
When(dimension__isnull=True, then=Value("None")),
default=Value("not_null"),
output_field=models.CharField(max_length=8),
),
dimension_ex=Coalesce("dimension", Value("null")),
)
.values("dimension")
)
if segment:
queryset = queryset.annotate(segment=F(segment)).values("dimension", "segment")
else:
queryset = queryset.values("dimension")
queryset = queryset.annotate(count=Count("*")).order_by("dimension")
if y_axis == "effort":
queryset = queryset.annotate(effort=Sum("estimate_point")).order_by(x_axis)
if segment:
queryset = queryset.annotate(segment=F(segment)).values("dimension", "segment", "effort")
else:
queryset = queryset.values("dimension", "effort")
result_values = list(queryset)
grouped_data = {}
for date, items in groupby(result_values, key=lambda x: x[str("dimension")]):
grouped_data[str(date)] = list(items)
return grouped_data

View File

@@ -5,6 +5,7 @@ from urllib.parse import urlparse, parse_qs
from datetime import datetime, timedelta
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.backends import default_backend
from django.conf import settings
def get_jwt_token():
@@ -128,3 +129,24 @@ def get_github_repo_details(access_tokens_url, owner, repo):
).json()
return open_issues, total_labels, collaborators
def get_release_notes():
token = settings.GITHUB_ACCESS_TOKEN
if token:
headers = {
"Authorization": "Bearer " + str(token),
"Accept": "application/vnd.github.v3+json",
}
else:
headers = {
"Accept": "application/vnd.github.v3+json",
}
url = "https://api.github.com/repos/makeplane/plane/releases?per_page=5&page=1"
response = requests.get(url, headers=headers)
if response.status_code != 200:
return {"error": "Unable to render information from Github Repository"}
return response.json()

View File

@@ -13,6 +13,17 @@ def filter_state(params, filter, method):
return filter
def filter_estimate_point(params, filter, method):
if method == "GET":
estimate_points = params.get("estimate_point").split(",")
if len(estimate_points) and "" not in estimate_points:
filter["estimate_point__in"] = estimate_points
else:
if params.get("estimate_point", None) and len(params.get("estimate_point")):
filter["estimate_point__in"] = params.get("estimate_point")
return filter
def filter_priority(params, filter, method):
if method == "GET":
priorties = params.get("priority").split(",")
@@ -187,11 +198,45 @@ def filter_issue_state_type(params, filter, method):
return filter
def filter_project(params, filter, method):
if method == "GET":
projects = params.get("project").split(",")
if len(projects) and "" not in projects:
filter["project__in"] = projects
else:
if params.get("project", None) and len(params.get("project")):
filter["project__in"] = params.get("project")
return filter
def filter_cycle(params, filter, method):
if method == "GET":
cycles = params.get("cycle").split(",")
if len(cycles) and "" not in cycles:
filter["issue_cycle__cycle_id__in"] = cycles
else:
if params.get("cycle", None) and len(params.get("cycle")):
filter["issue_cycle__cycle_id__in"] = params.get("cycle")
return filter
def filter_module(params, filter, method):
if method == "GET":
modules = params.get("module").split(",")
if len(modules) and "" not in modules:
filter["issue_module__module_id__in"] = modules
else:
if params.get("module", None) and len(params.get("module")):
filter["issue_module__module_id__in"] = params.get("module")
return filter
def issue_filters(query_params, method):
filter = dict()
ISSUE_FILTER = {
"state": filter_state,
"estimate_point": filter_estimate_point,
"priority": filter_priority,
"parent": filter_parent,
"labels": filter_labels,
@@ -204,6 +249,9 @@ def issue_filters(query_params, method):
"target_date": filter_target_date,
"completed_at": filter_completed_at,
"type": filter_issue_state_type,
"project": filter_project,
"cycle": filter_cycle,
"module": filter_module,
}
for key, value in ISSUE_FILTER.items():

View File

@@ -1,6 +1,6 @@
# base requirements
Django==3.2.18
Django==3.2.19
django-braces==1.15.0
django-taggit==3.1.0
psycopg2==2.9.5

View File

@@ -0,0 +1,4 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
Your Export is ready
</html>

View File

@@ -1,8 +0,0 @@
# Replace with your instance Public IP
# NEXT_PUBLIC_API_BASE_URL = "http://localhost"
NEXT_PUBLIC_GOOGLE_CLIENTID=""
NEXT_PUBLIC_GITHUB_APP_NAME=""
NEXT_PUBLIC_GITHUB_ID=""
NEXT_PUBLIC_SENTRY_DSN=""
NEXT_PUBLIC_ENABLE_OAUTH=0
NEXT_PUBLIC_ENABLE_SENTRY=0

View File

@@ -3,6 +3,7 @@ RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
COPY . .
@@ -12,10 +13,10 @@ RUN turbo prune --scope=app --docker
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
@@ -26,9 +27,17 @@ RUN yarn install
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build --filter=app
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL}
FROM node:18-alpine AS runner
WORKDIR /app
@@ -43,8 +52,20 @@ COPY --from=installer /app/apps/app/package.json .
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
# COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone/node_modules ./apps/app/node_modules
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
COPY --from=installer --chown=captain:plane /app/apps/app/.next ./apps/app/.next
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
USER captain
ENV NEXT_TELEMETRY_DISABLED 1

View File

@@ -92,13 +92,13 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
<>
<form className="space-y-5 py-5 px-5">
{(codeSent || codeResent) && (
<div className="rounded-md bg-green-50 p-4">
<div className="rounded-md bg-green-500/20 p-4">
<div className="flex">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-5 w-5 text-green-400" aria-hidden="true" />
<CheckCircleIcon className="h-5 w-5 text-green-500" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-green-800">
<p className="text-sm font-medium text-green-500">
{codeResent
? "Please check your mail for new code."
: "Please check your mail for code."}
@@ -141,7 +141,9 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
<button
type="button"
className={`mt-5 flex w-full justify-end text-xs outline-none ${
isResendDisabled ? "cursor-default text-gray-400" : "cursor-pointer text-theme"
isResendDisabled
? "cursor-default text-brand-secondary"
: "cursor-pointer text-brand-accent"
} `}
onClick={() => {
setIsCodeResending(true);
@@ -174,7 +176,8 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
className="w-full text-center"
size="md"
onClick={handleSubmit(handleSignin)}
loading={isSubmitting || (!isValid && isDirty)}
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</PrimaryButton>

View File

@@ -50,7 +50,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
if (!error?.response?.data) return;
Object.keys(error.response.data).forEach((key) => {
const err = error.response.data[key];
console.log("err", err);
console.log(err);
setError(key as keyof EmailPasswordFormValues, {
type: "manual",
message: Array.isArray(err) ? err.join(", ") : err,
@@ -94,7 +94,9 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
<div className="mt-2 flex items-center justify-between">
<div className="ml-auto text-sm">
<Link href={"/forgot-password"}>
<a className="font-medium text-theme hover:text-indigo-500">Forgot your password?</a>
<a className="font-medium text-brand-accent hover:text-brand-accent">
Forgot your password?
</a>
</Link>
</div>
</div>
@@ -102,7 +104,8 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
<SecondaryButton
type="submit"
className="w-full text-center"
loading={isSubmitting || (!isValid && isDirty)}
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign In"}
</SecondaryButton>

View File

@@ -33,11 +33,11 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
}, []);
return (
<div className="px-1 w-full">
<div className="w-full px-1">
<Link
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
>
<button className="flex w-full items-center justify-center gap-3 rounded-md border border-gray-200 p-2 text-sm font-medium text-gray-600 duration-300 hover:bg-gray-50">
<button className="flex w-full items-center justify-center gap-3 rounded-md border border-brand-base p-2 text-sm font-medium text-brand-secondary duration-300 hover:bg-brand-surface-2">
<Image src={githubImage} height={22} width={22} color="#000" alt="GitHub Logo" />
<span>Sign In with Github</span>
</button>

View File

@@ -47,7 +47,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
return (
<>
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
<div className="h-12" id="googleSignInButton" ref={googleSignInButton} />
<div className="overflow-hidden rounded" id="googleSignInButton" ref={googleSignInButton} />
</>
);
};

View File

@@ -0,0 +1,158 @@
import React from "react";
import { useRouter } from "next/router";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import analyticsService from "services/analytics.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// types
import { IAnalyticsParams, ISaveAnalyticsFormData } from "types";
// types
type Props = {
isOpen: boolean;
handleClose: () => void;
params?: IAnalyticsParams;
};
type FormValues = {
name: string;
description: string;
};
const defaultValues: FormValues = {
name: "",
description: "",
};
export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClose, params }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<FormValues>({
defaultValues,
});
const onClose = () => {
handleClose();
reset(defaultValues);
};
const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug) return;
const payload: ISaveAnalyticsFormData = {
name: formData.name,
description: formData.description,
query_dict: {
x_axis: "priority",
y_axis: "issue_count",
...params,
project: params?.project ? [params.project] : [],
},
};
await analyticsService
.saveAnalytics(workspaceSlug.toString(), payload)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Analytics saved successfully.",
});
onClose();
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Analytics could not be saved. Please try again.",
})
);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg border border-brand-base bg-brand-base px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
Save Analytics
</Dialog.Title>
<div className="mt-5">
<Input
type="text"
id="name"
name="name"
placeholder="Title"
autoComplete="off"
error={errors.name}
register={register}
width="full"
validations={{
required: "Title is required",
}}
/>
<TextArea
id="description"
name="description"
placeholder="Description"
className="mt-3 h-32 resize-none text-sm"
error={errors.description}
register={register}
/>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Analytics"}
</PrimaryButton>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,148 @@
import { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// services
import analyticsService from "services/analytics.service";
// components
import {
AnalyticsGraph,
AnalyticsSidebar,
AnalyticsTable,
CreateUpdateAnalyticsModal,
} from "components/analytics";
// ui
import { Loader, PrimaryButton } from "components/ui";
// types
import { convertResponseToBarGraphData } from "constants/analytics";
// types
import { IAnalyticsParams } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
const defaultValues: IAnalyticsParams = {
x_axis: "priority",
y_axis: "issue_count",
segment: null,
project: null,
};
type Props = {
isProjectLevel?: boolean;
fullScreen?: boolean;
};
export const CustomAnalytics: React.FC<Props> = ({ isProjectLevel = false, fullScreen = true }) => {
const [saveAnalyticsModal, setSaveAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
const params: IAnalyticsParams = {
x_axis: watch("x_axis"),
y_axis: watch("y_axis"),
segment: watch("segment"),
project: isProjectLevel ? projectId?.toString() : watch("project"),
cycle: isProjectLevel && cycleId ? cycleId.toString() : null,
module: isProjectLevel && moduleId ? moduleId.toString() : null,
};
const {
data: analytics,
error: analyticsError,
mutate: mutateAnalytics,
} = useSWR(
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
const yAxisKey = params.y_axis === "issue_count" ? "count" : "effort";
const barGraphData = convertResponseToBarGraphData(
analytics?.distribution,
watch("segment") ? true : false,
watch("y_axis")
);
return (
<>
<CreateUpdateAnalyticsModal
isOpen={saveAnalyticsModal}
handleClose={() => setSaveAnalyticsModal(false)}
params={params}
/>
<div
className={`overflow-y-auto ${
fullScreen ? "grid grid-cols-4 h-full" : "flex flex-col-reverse"
}`}
>
<div className="col-span-3">
{!analyticsError ? (
analytics ? (
analytics.total > 0 ? (
<>
<AnalyticsGraph
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
fullScreen={fullScreen}
/>
<AnalyticsTable
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
/>
</>
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">
No matching issues found. Try changing the parameters.
</p>
</div>
</div>
)
) : (
<Loader className="space-y-6 p-5">
<Loader.Item height="300px" />
<Loader className="space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<PrimaryButton onClick={mutateAnalytics}>Refresh</PrimaryButton>
</div>
</div>
</div>
)}
</div>
<div className={fullScreen ? "h-full" : ""}>
<AnalyticsSidebar
analytics={analytics}
params={params}
control={control}
setValue={setValue}
setSaveAnalyticsModal={setSaveAnalyticsModal}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,34 @@
// nivo
import { BarTooltipProps } from "@nivo/bar";
// types
import { IAnalyticsParams } from "types";
type Props = {
datum: BarTooltipProps<any>;
params: IAnalyticsParams;
};
export const CustomTooltip: React.FC<Props> = ({ datum, params }) => (
<div className="flex items-center gap-2 rounded-md border border-brand-base bg-brand-base p-2 text-xs">
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: datum.color,
}}
/>
<span
className={`font-medium text-brand-secondary ${
params.segment
? params.segment === "priority" || params.segment === "state__group"
? "capitalize"
: ""
: params.x_axis === "priority" || params.x_axis === "state__group"
? "capitalize"
: ""
}`}
>
{params.segment ? datum.id : datum.id === "count" ? "Issue count" : "Effort"}:
</span>
<span>{datum.value}</span>
</div>
);

View File

@@ -0,0 +1,102 @@
// nivo
import { BarDatum } from "@nivo/bar";
// components
import { CustomTooltip } from "./custom-tooltip";
// ui
import { BarGraph } from "components/ui";
// helpers
import { findStringWithMostCharacters } from "helpers/array.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
// constants
import { generateBarColor } from "constants/analytics";
type Props = {
analytics: IAnalyticsResponse;
barGraphData: {
data: BarDatum[];
xAxisKeys: string[];
};
params: IAnalyticsParams;
yAxisKey: "effort" | "count";
fullScreen: boolean;
};
export const AnalyticsGraph: React.FC<Props> = ({
analytics,
barGraphData,
params,
yAxisKey,
fullScreen,
}) => {
const generateYAxisTickValues = () => {
if (!analytics) return [];
let data: number[] = [];
if (params.segment)
// find the total no of issues in each segment
data = Object.keys(analytics.distribution).map((segment) => {
let totalSegmentIssues = 0;
analytics.distribution[segment].map((s) => {
totalSegmentIssues += s[yAxisKey] as number;
});
return totalSegmentIssues;
});
else data = barGraphData.data.map((d) => d[yAxisKey] as number);
const minValue = 0;
const maxValue = Math.max(...data);
const valueRange = maxValue - minValue;
let tickInterval = 1;
if (valueRange > 10) tickInterval = 2;
if (valueRange > 50) tickInterval = 5;
if (valueRange > 100) tickInterval = 10;
if (valueRange > 200) tickInterval = 50;
if (valueRange > 300) tickInterval = (Math.ceil(valueRange / 100) * 100) / 10;
const tickValues = [];
let tickValue = minValue;
while (tickValue <= maxValue) {
tickValues.push(tickValue);
tickValue += tickInterval;
}
if (!tickValues.includes(maxValue)) tickValues.push(maxValue);
return tickValues;
};
const longestXAxisLabel = findStringWithMostCharacters(barGraphData.data.map((d) => `${d.name}`));
return (
<BarGraph
data={barGraphData.data}
indexBy="name"
keys={barGraphData.xAxisKeys}
axisLeft={{
tickSize: 0,
tickPadding: 10,
tickValues: generateYAxisTickValues(),
}}
colors={(datum) =>
generateBarColor(
params.segment ? `${datum.id}` : `${datum.indexValue}`,
analytics,
params,
params.segment ? "segment" : "x_axis"
)
}
tooltip={(datum) => <CustomTooltip datum={datum} params={params} />}
height={fullScreen ? "400px" : "300px"}
margin={{ right: 20, bottom: longestXAxisLabel.length * 5 + 20 }}
theme={{
axis: {},
}}
/>
);
};

View File

@@ -0,0 +1,5 @@
export * from "./graph";
export * from "./create-update-analytics-modal";
export * from "./custom-analytics";
export * from "./sidebar";
export * from "./table";

View File

@@ -0,0 +1,232 @@
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Control, Controller, UseFormSetValue } from "react-hook-form";
// services
import analyticsService from "services/analytics.service";
// hooks
import useProjects from "hooks/use-projects";
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, CustomSelect, PrimaryButton } from "components/ui";
// icons
import { ArrowPathIcon, ArrowUpTrayIcon } from "@heroicons/react/24/outline";
// types
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
// constants
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "constants/analytics";
type Props = {
analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams;
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
setSaveAnalyticsModal: React.Dispatch<React.SetStateAction<boolean>>;
fullScreen: boolean;
isProjectLevel?: boolean;
};
export const AnalyticsSidebar: React.FC<Props> = ({
analytics,
params,
control,
setValue,
setSaveAnalyticsModal,
fullScreen,
isProjectLevel = false,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { projects } = useProjects();
const { setToastAlert } = useToast();
const exportAnalytics = () => {
if (!workspaceSlug) return;
const data: IExportAnalyticsFormData = {
x_axis: params.x_axis,
y_axis: params.y_axis,
};
if (params.segment) data.segment = params.segment;
if (params.project) data.project = [params.project];
analyticsService
.exportAnalytics(workspaceSlug.toString(), data)
.then((res) =>
setToastAlert({
type: "success",
title: "Success!",
message: res.message,
})
)
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "There was some error in exporting the analytics. Please try again.",
})
);
};
return (
<div
className={`gap-4 p-5 ${
fullScreen ? "border-l border-brand-base bg-brand-sidebar h-full" : ""
}`}
>
<div className={`sticky top-5 ${fullScreen ? "space-y-4" : "space-y-2"}`}>
<div className="flex items-center justify-between gap-2 flex-shrink-0">
<h5 className="text-lg font-medium">
{analytics?.total ?? 0}{" "}
<span className="text-xs font-normal text-brand-secondary">issues</span>
</h5>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
<div className="flex items-center gap-2">
<ArrowPathIcon className="h-3 w-3" />
Refresh
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={exportAnalytics}>
<div className="flex items-center gap-2">
<ArrowUpTrayIcon className="h-3 w-3" />
Export analytics as CSV
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className={`${fullScreen ? "space-y-4" : "grid items-center gap-4 grid-cols-3"}`}>
{isProjectLevel === false && (
<div>
<h6 className="text-xs text-brand-secondary">Project</h6>
<Controller
name="project"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={projects.find((p) => p.id === value)?.name ?? "All projects"}
onChange={onChange}
width="w-full"
maxHeight="lg"
>
<CustomSelect.Option value={null}>All projects</CustomSelect.Option>
{projects.map((project) => (
<CustomSelect.Option key={project.id} value={project.id}>
{project.name}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
)}
<div>
<h6 className="text-xs text-brand-secondary">Measure (y-axis)</h6>
<Controller
name="y_axis"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={
<span>
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}
</span>
}
onChange={onChange}
width="w-full"
>
{ANALYTICS_Y_AXIS_VALUES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Dimension (x-axis)</h6>
<Controller
name="x_axis"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={
<span>{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label}</span>
}
onChange={(val: string) => {
if (params.segment === val) setValue("segment", null);
onChange(val);
}}
width="w-full"
maxHeight="lg"
>
{ANALYTICS_X_AXIS_VALUES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Segment</h6>
<Controller
name="segment"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={
<span>
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label ?? (
<span className="text-brand-secondary">No value</span>
)}
</span>
}
onChange={onChange}
width="w-full"
maxHeight="lg"
>
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
{ANALYTICS_X_AXIS_VALUES.map((item) => {
if (params.x_axis === item.value) return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
);
})}
</CustomSelect>
)}
/>
</div>
</div>
{/* <div className="flex items-center justify-end gap-2">
<PrimaryButton className="py-1" onClick={() => setSaveAnalyticsModal(true)}>
Save analytics
</PrimaryButton>
</div> */}
</div>
</div>
);
};

View File

@@ -0,0 +1,116 @@
// nivo
import { BarDatum } from "@nivo/bar";
// icons
import { getPriorityIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
// constants
import {
ANALYTICS_X_AXIS_VALUES,
ANALYTICS_Y_AXIS_VALUES,
generateBarColor,
} from "constants/analytics";
type Props = {
analytics: IAnalyticsResponse;
barGraphData: {
data: BarDatum[];
xAxisKeys: string[];
};
params: IAnalyticsParams;
yAxisKey: "effort" | "count";
};
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => (
<div className="flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<table className="min-w-full divide-y divide-brand-base whitespace-nowrap border-y border-brand-base">
<thead className="bg-brand-base">
<tr className="divide-x divide-brand-base text-sm text-brand-base">
<th scope="col" className="py-3 px-2.5 text-left font-medium">
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label}
</th>
{params.segment ? (
barGraphData.xAxisKeys.map((key) => (
<th
key={`segment-${key}`}
scope="col"
className={`px-2.5 py-3 text-left font-medium ${
params.segment === "priority" || params.segment === "state__group"
? "capitalize"
: ""
}`}
>
<div className="flex items-center gap-2">
{params.segment === "priority" ? (
getPriorityIcon(key)
) : (
<span
className="h-3 w-3 flex-shrink-0 rounded"
style={{
backgroundColor: generateBarColor(key, analytics, params, "segment"),
}}
/>
)}
{key}
</div>
</th>
))
) : (
<th scope="col" className="py-3 px-2.5 text-left font-medium sm:pr-0">
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-brand-base">
{barGraphData.data.map((item, index) => (
<tr
key={`table-row-${index}`}
className="divide-x divide-brand-base text-xs text-brand-secondary"
>
<td
className={`flex items-center gap-2 whitespace-nowrap py-2 px-2.5 font-medium ${
params.x_axis === "priority" ? "capitalize" : ""
}`}
>
{params.x_axis === "priority" ? (
getPriorityIcon(`${item.name}`)
) : (
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: generateBarColor(
`${item.name}`,
analytics,
params,
"x_axis"
),
}}
/>
)}
{addSpaceIfCamelCase(`${item.name}`)}
</td>
{params.segment ? (
barGraphData.xAxisKeys.map((key, index) => (
<td
key={`segment-value-${index}`}
className="whitespace-nowrap py-2 px-2.5 sm:pr-0"
>
{item[key] ?? 0}
</td>
))
) : (
<td className="whitespace-nowrap py-2 px-2.5 sm:pr-0">{item[yAxisKey]}</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,4 @@
export * from "./custom-analytics";
export * from "./scope-and-demand";
export * from "./project-modal";
export * from "./workspace-modal";

View File

@@ -0,0 +1,93 @@
import React, { Fragment, useState } from "react";
// headless ui
import { Tab } from "@headlessui/react";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
type Props = {
isOpen: boolean;
onClose: () => void;
};
const tabsList = ["Scope and Demand", "Custom Analytics"];
export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
const [fullScreen, setFullScreen] = useState(false);
const handleClose = () => {
onClose();
};
return (
<div
className={`absolute top-0 z-30 h-full bg-brand-surface-1 ${
fullScreen ? "p-2 w-full" : "w-1/2"
} ${isOpen ? "right-0" : "-right-full"} duration-300 transition-all`}
>
<div
className={`flex h-full flex-col overflow-hidden border-brand-base bg-brand-surface-1 text-left ${
fullScreen ? "rounded-lg border" : "border-l"
}`}
>
<div
className={`flex items-center justify-between gap-2 border-b border-b-brand-base bg-brand-sidebar p-3 text-sm ${
fullScreen ? "" : "py-[1.3rem]"
}`}
>
<h3>Project Analytics</h3>
<div className="flex items-center gap-2">
<button
type="button"
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
onClick={() => setFullScreen((prevData) => !prevData)}
>
{fullScreen ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon className="h-3 w-3" />
)}
</button>
<button
type="button"
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
onClick={handleClose}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
<Tab.Group as={Fragment}>
<Tab.List className="space-x-2 border-b border-brand-base px-5 py-3">
{tabsList.map((tab) => (
<Tab
key={tab}
className={({ selected }) =>
`rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-base ${
selected ? "bg-brand-base" : ""
}`
}
>
{tab}
</Tab>
))}
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand fullScreen={fullScreen} />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics fullScreen={fullScreen} isProjectLevel />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
);
};

View File

@@ -0,0 +1,63 @@
// icons
import { PlayIcon } from "@heroicons/react/24/outline";
// types
import { IDefaultAnalyticsResponse } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
defaultAnalytics: IDefaultAnalyticsResponse;
};
export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
<div className="space-y-3 self-start rounded-[10px] border border-brand-base p-3">
<h5 className="text-xs text-red-500">DEMAND</h5>
<div>
<h4 className="text-brand-bas text-base font-medium">Total open tasks</h4>
<h3 className="mt-1 text-xl font-semibold">{defaultAnalytics.open_issues}</h3>
</div>
<div className="space-y-6">
{defaultAnalytics.open_issues_classified.map((group) => {
const percentage = ((group.state_count / defaultAnalytics.total_issues) * 100).toFixed(0);
return (
<div key={group.state_group} className="space-y-2">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-1">
<span
className="h-2 w-2 rounded-full"
style={{
backgroundColor: STATE_GROUP_COLORS[group.state_group],
}}
/>
<h6 className="capitalize">{group.state_group}</h6>
<span className="ml-1 rounded-3xl bg-brand-surface-2 px-2 py-0.5 text-[0.65rem] text-brand-secondary">
{group.state_count}
</span>
</div>
<p className="text-brand-secondary">{percentage}%</p>
</div>
<div className="bar relative h-1 w-full rounded bg-brand-base">
<div
className="absolute top-0 left-0 h-1 rounded duration-300"
style={{
width: `${percentage}%`,
backgroundColor: STATE_GROUP_COLORS[group.state_group],
}}
/>
</div>
</div>
);
})}
</div>
<div className="!mt-6 flex w-min items-center gap-2 whitespace-nowrap rounded-md border border-brand-base bg-brand-base p-2 text-xs">
<p className="flex items-center gap-1 text-brand-secondary">
<PlayIcon className="h-4 w-4 -rotate-90" aria-hidden="true" />
<span>Estimate Demand:</span>
</p>
<p className="font-medium">
{defaultAnalytics.open_estimate_sum}/{defaultAnalytics.total_estimate_sum}
</p>
</div>
</div>
);

View File

@@ -0,0 +1,3 @@
export * from "./demand";
export * from "./scope-and-demand";
export * from "./scope";

View File

@@ -0,0 +1,67 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import analyticsService from "services/analytics.service";
// components
import { AnalyticsDemand, AnalyticsScope } from "components/analytics";
// ui
import { Loader, PrimaryButton } from "components/ui";
// fetch-keys
import { DEFAULT_ANALYTICS } from "constants/fetch-keys";
type Props = {
fullScreen?: boolean;
};
export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const params = {
project: projectId ? projectId.toString() : null,
cycle: cycleId ? cycleId.toString() : null,
module: moduleId ? moduleId.toString() : null,
};
const {
data: defaultAnalytics,
error: defaultAnalyticsError,
mutate: mutateDefaultAnalytics,
} = useSWR(
workspaceSlug ? DEFAULT_ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug
? () => analyticsService.getDefaultAnalytics(workspaceSlug.toString(), params)
: null
);
return (
<>
{!defaultAnalyticsError ? (
defaultAnalytics ? (
<div className="h-full overflow-y-auto p-5 text-sm">
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "lg:grid-cols-2" : ""}`}>
<AnalyticsDemand defaultAnalytics={defaultAnalytics} />
<AnalyticsScope defaultAnalytics={defaultAnalytics} />
</div>
</div>
) : (
<Loader className="grid grid-cols-1 gap-5 p-5 lg:grid-cols-2">
<Loader.Item height="300px" />
<Loader.Item height="300px" />
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<PrimaryButton onClick={mutateDefaultAnalytics}>Refresh</PrimaryButton>
</div>
</div>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,98 @@
// ui
import { BarGraph, LineGraph } from "components/ui";
// types
import { IDefaultAnalyticsResponse } from "types";
// constants
import { MONTHS_LIST } from "constants/calendar";
type Props = {
defaultAnalytics: IDefaultAnalyticsResponse;
};
export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => {
const currentMonth = new Date().getMonth();
const startMonth = Math.floor(currentMonth / 3) * 3 + 1;
const quarterMonthsList = [startMonth, startMonth + 1, startMonth + 2];
return (
<div className="rounded-[10px] border border-brand-base">
<h5 className="p-3 text-xs text-green-500">SCOPE</h5>
<div className="divide-y divide-brand-base">
<div>
<h6 className="px-3 text-base font-medium">Pending issues</h6>
<BarGraph
data={defaultAnalytics.pending_issue_user}
indexBy="assignees__email"
keys={["count"]}
height="250px"
colors={() => `#f97316`}
tooltip={(datum) => (
<div className="rounded-md border border-brand-base bg-brand-base p-2 text-xs">
<span className="font-medium text-brand-secondary">
Issue count- {datum.indexValue ?? "No assignee"}:{" "}
</span>
{datum.value}
</div>
)}
axisBottom={{
tickValues: [],
}}
margin={{ top: 20 }}
/>
</div>
<div className="grid grid-cols-1 divide-y divide-brand-base sm:grid-cols-2 sm:divide-x">
<div className="p-3">
<h6 className="text-base font-medium">Most issues created</h6>
<div className="mt-3 space-y-3">
{defaultAnalytics.most_issue_created_user.map((user) => (
<div
key={user.assignees__email}
className="flex items-start justify-between gap-4 text-xs"
>
<span className="break-all text-brand-secondary">{user.assignees__email}</span>
<span className="flex-shrink-0">{user.count}</span>
</div>
))}
</div>
</div>
<div className="p-3">
<h6 className="text-base font-medium">Most issues closed</h6>
<div className="mt-3 space-y-3">
{defaultAnalytics.most_issue_closed_user.map((user) => (
<div
key={user.assignees__email}
className="flex items-start justify-between gap-4 text-xs"
>
<span className="break-all text-brand-secondary">{user.assignees__email}</span>
<span className="flex-shrink-0">{user.count}</span>
</div>
))}
</div>
</div>
</div>
<div className="py-3">
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
<LineGraph
data={[
{
id: "issues_closed",
color: "rgb(var(--color-accent))",
data: quarterMonthsList.map((month) => ({
x: MONTHS_LIST.find((m) => m.value === month)?.label.substring(0, 3),
y:
defaultAnalytics.issue_completed_month_wise.find((data) => data.month === month)
?.count || 0,
})),
},
]}
height="300px"
colors={(datum) => datum.color}
curve="monotoneX"
margin={{ top: 20 }}
enableArea
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,70 @@
import React, { Fragment } from "react";
// headless ui
import { Tab } from "@headlessui/react";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
type Props = {
isOpen: boolean;
onClose: () => void;
};
const tabsList = ["Scope and Demand", "Custom Analytics"];
export const AnalyticsWorkspaceModal: React.FC<Props> = ({ isOpen, onClose }) => {
const handleClose = () => {
onClose();
};
return (
<>
<div
className={`absolute z-20 h-full w-full bg-brand-surface-1 p-2 ${
isOpen ? "block" : "hidden"
}`}
>
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-brand-base bg-brand-surface-1 text-left">
<div className="flex items-center justify-between gap-2 border-b border-b-brand-base bg-brand-sidebar p-3 text-sm">
<h3>Workspace Analytics</h3>
<div>
<button
type="button"
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
onClick={handleClose}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
<Tab.Group as={Fragment}>
<Tab.List className="space-x-2 border-b border-brand-base px-5 py-3">
{tabsList.map((tab) => (
<Tab
key={tab}
className={({ selected }) =>
`rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-base ${
selected ? "bg-brand-base" : ""
}`
}
>
{tab}
</Tab>
))}
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
</>
);
};

View File

@@ -27,7 +27,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
description: "You are not authorized to view this page",
}}
>
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
<div className="h-44 w-72">
<Image
src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg}
@@ -36,24 +36,24 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
alt="ProjectSettingImg"
/>
</div>
<h1 className="text-xl font-medium text-gray-900">
<h1 className="text-xl font-medium text-brand-base">
Oops! You are not authorized to view this page
</h1>
<div className="w-full text-base text-gray-500 max-w-md ">
<div className="w-full max-w-md text-base text-brand-secondary">
{user ? (
<p className="">
<p>
You have signed in as {user.email}. <br />
<Link href={`/signin?next=${currentPath}`}>
<a className="text-gray-900 font-medium">Sign in</a>
<a className="font-medium text-brand-base">Sign in</a>
</Link>{" "}
with different account that has access to this page.
</p>
) : (
<p className="">
<p>
You need to{" "}
<Link href={`/signin?next=${currentPath}`}>
<a className="text-gray-900 font-medium">Sign in</a>
<a className="font-medium text-brand-base">Sign in</a>
</Link>{" "}
with an account that has access to this page.
</p>

View File

@@ -5,15 +5,16 @@ import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import projectService from "services/project.service";
// ui
import { PrimaryButton } from "components/ui";
// icon
// icons
import { AssignmentClipboardIcon } from "components/icons";
// img
// images
import JoinProjectImg from "public/auth/project-not-authorized.svg";
import projectService from "services/project.service";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
import { USER_PROJECT_VIEW } from "constants/fetch-keys";
export const JoinProject: React.FC = () => {
const [isJoiningProject, setIsJoiningProject] = useState(false);
@@ -22,13 +23,16 @@ export const JoinProject: React.FC = () => {
const { workspaceSlug, projectId } = router.query;
const handleJoin = () => {
if (!workspaceSlug || !projectId) return;
setIsJoiningProject(true);
projectService
.joinProject(workspaceSlug as string, {
project_ids: [projectId as string],
})
.then(() => {
mutate(PROJECT_MEMBERS(projectId as string));
.then(async () => {
await mutate(USER_PROJECT_VIEW(projectId.toString()));
setIsJoiningProject(false);
})
.catch((err) => {
console.error(err);
@@ -37,13 +41,13 @@ export const JoinProject: React.FC = () => {
};
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
<div className="h-44 w-72">
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
</div>
<h1 className="text-xl font-medium text-gray-900">You are not a member of this project</h1>
<h1 className="text-xl font-medium text-brand-base">You are not a member of this project</h1>
<div className="w-full max-w-md text-base text-gray-500 ">
<div className="w-full max-w-md text-base text-brand-secondary">
<p className="mx-auto w-full text-sm md:w-3/4">
You are not a member of this project, but you can join this project by clicking the button
below.

View File

@@ -20,12 +20,12 @@ export const NotAWorkspaceMember = () => {
<div className="space-y-8 text-center">
<div className="space-y-2">
<h3 className="text-lg font-semibold">Not Authorized!</h3>
<p className="text-sm text-gray-500 w-1/2 mx-auto">
<p className="mx-auto w-1/2 text-sm text-brand-secondary">
You{"'"}re not a member of this workspace. Please contact the workspace admin to get
an invitation or check your pending invitations.
</p>
</div>
<div className="flex items-center gap-2 justify-center">
<div className="flex items-center justify-center gap-2">
<Link href="/invitations">
<a>
<SecondaryButton>Check pending invites</SecondaryButton>

View File

@@ -16,7 +16,7 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
<div className="flex items-center">
<button
type="button"
className="grid h-8 w-8 flex-shrink-0 cursor-pointer place-items-center rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
className="grid h-8 w-8 flex-shrink-0 cursor-pointer place-items-center rounded border border-brand-base text-center text-sm hover:bg-brand-surface-1"
onClick={() => router.back()}
>
<ArrowLeftIcon className="h-3 w-3" />
@@ -37,7 +37,7 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
<>
{link ? (
<Link href={link}>
<a className="border-r-2 border-gray-300 px-3 text-sm">
<a className="border-r-2 border-brand-base px-3 text-sm">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon ?? null}
{title}

View File

@@ -0,0 +1,45 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
// cmdk
import { Command } from "cmdk";
import { THEMES_OBJ } from "constants/themes";
import { useTheme } from "next-themes";
import { SettingIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
};
export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
const [mounted, setMounted] = useState(false);
const { setTheme } = useTheme();
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<>
{THEMES_OBJ.map((theme) => (
<Command.Item
key={theme.value}
onSelect={() => {
setTheme(theme.value);
setIsPaletteOpen(false);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
{theme.label}
</div>
</Command.Item>
))}
</>
);
};

View File

@@ -14,7 +14,7 @@ import stateService from "services/state.service";
// types
import { IIssue } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATE_LIST } from "constants/fetch-keys";
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATES_LIST } from "constants/fetch-keys";
// icons
import { CheckIcon, getStateGroupIcon } from "components/icons";
@@ -28,7 +28,7 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) =
const { workspaceSlug, projectId, issueId } = router.query;
const { data: stateGroups, mutate: mutateIssueDetails } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null

View File

@@ -44,6 +44,7 @@ import {
ChangeIssueState,
ChangeIssuePriority,
ChangeIssueAssignee,
ChangeInterfaceTheme,
} from "components/command-palette";
import { BulkDeleteIssuesModal } from "components/core";
import { CreateUpdateCycleModal } from "components/cycles";
@@ -379,7 +380,7 @@ export const CommandPalette: React.FC = () => {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
@@ -392,7 +393,7 @@ export const CommandPalette: React.FC = () => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-brand-base divide-opacity-10 rounded-xl border border-brand-base bg-brand-surface-2 shadow-2xl transition-all">
<Command
filter={(value, search) => {
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
@@ -415,7 +416,7 @@ export const CommandPalette: React.FC = () => {
>
{issueId && issueDetails && (
<div className="flex p-3">
<p className="overflow-hidden truncate rounded-md bg-gray-100 p-1 px-2 text-xs font-medium text-gray-500">
<p className="overflow-hidden truncate rounded-md bg-brand-surface-1 p-1 px-2 text-xs font-medium text-brand-secondary">
{issueDetails.project_detail?.identifier}-{issueDetails.sequence_id}{" "}
{issueDetails?.name}
</p>
@@ -423,11 +424,11 @@ export const CommandPalette: React.FC = () => {
)}
<div className="relative">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-secondary"
aria-hidden="true"
/>
<Command.Input
className="w-full border-0 border-b bg-transparent p-4 pl-11 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
className="w-full border-0 border-b border-brand-base bg-transparent p-4 pl-11 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => {
@@ -441,7 +442,9 @@ export const CommandPalette: React.FC = () => {
resultsCount === 0 &&
searchTerm !== "" &&
debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-gray-500">No results found.</div>
<div className="my-4 text-center text-brand-secondary">
No results found.
</div>
)}
{(isLoading || isSearching) && (
@@ -502,8 +505,11 @@ export const CommandPalette: React.FC = () => {
value={value}
className="focus:outline-none"
>
<div className="flex items-center gap-2 overflow-hidden text-gray-700">
<Icon className="h-4 w-4 text-gray-500" color="#6b7280" />
<div className="flex items-center gap-2 overflow-hidden text-brand-secondary">
<Icon
className="h-4 w-4 text-brand-secondary"
color="#6b7280"
/>
<p className="block flex-1 truncate">{item.name}</p>
</div>
</Command.Item>
@@ -528,8 +534,8 @@ export const CommandPalette: React.FC = () => {
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<Squares2X2Icon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<Squares2X2Icon className="h-4 w-4 text-brand-secondary" />
Change state...
</div>
</Command.Item>
@@ -541,8 +547,8 @@ export const CommandPalette: React.FC = () => {
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<ChartBarIcon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<ChartBarIcon className="h-4 w-4 text-brand-secondary" />
Change priority...
</div>
</Command.Item>
@@ -554,8 +560,8 @@ export const CommandPalette: React.FC = () => {
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<UsersIcon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<UsersIcon className="h-4 w-4 text-brand-secondary" />
Assign to...
</div>
</Command.Item>
@@ -566,15 +572,15 @@ export const CommandPalette: React.FC = () => {
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<div className="flex items-center gap-2 text-brand-secondary">
{issueDetails?.assignees.includes(user.id) ? (
<>
<UserMinusIcon className="h-4 w-4 text-gray-500" />
<UserMinusIcon className="h-4 w-4 text-brand-secondary" />
Un-assign from me
</>
) : (
<>
<UserPlusIcon className="h-4 w-4 text-gray-500" />
<UserPlusIcon className="h-4 w-4 text-brand-secondary" />
Assign to me
</>
)}
@@ -582,8 +588,8 @@ export const CommandPalette: React.FC = () => {
</Command.Item>
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
<div className="flex items-center gap-2 text-gray-700">
<TrashIcon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<TrashIcon className="h-4 w-4 text-brand-secondary" />
Delete issue
</div>
</Command.Item>
@@ -594,16 +600,19 @@ export const CommandPalette: React.FC = () => {
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<LinkIcon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<LinkIcon className="h-4 w-4 text-brand-secondary" />
Copy issue URL to clipboard
</div>
</Command.Item>
</>
)}
<Command.Group heading="Issue">
<Command.Item onSelect={createNewIssue} className="focus:bg-gray-200">
<div className="flex items-center gap-2 text-gray-700">
<Command.Item
onSelect={createNewIssue}
className="focus:bg-brand-surface-2"
>
<div className="flex items-center gap-2 text-brand-secondary">
<LayerDiagonalIcon className="h-4 w-4" color="#6b7280" />
Create new issue
</div>
@@ -617,7 +626,7 @@ export const CommandPalette: React.FC = () => {
onSelect={createNewProject}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<div className="flex items-center gap-2 text-brand-secondary">
<AssignmentClipboardIcon className="h-4 w-4" color="#6b7280" />
Create new project
</div>
@@ -633,7 +642,7 @@ export const CommandPalette: React.FC = () => {
onSelect={createNewCycle}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<div className="flex items-center gap-2 text-brand-secondary">
<ContrastIcon className="h-4 w-4" color="#6b7280" />
Create new cycle
</div>
@@ -646,7 +655,7 @@ export const CommandPalette: React.FC = () => {
onSelect={createNewModule}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<div className="flex items-center gap-2 text-brand-secondary">
<PeopleGroupIcon className="h-4 w-4" color="#6b7280" />
Create new module
</div>
@@ -656,7 +665,7 @@ export const CommandPalette: React.FC = () => {
<Command.Group heading="View">
<Command.Item onSelect={createNewView} className="focus:outline-none">
<div className="flex items-center gap-2 text-gray-700">
<div className="flex items-center gap-2 text-brand-secondary">
<ViewListIcon className="h-4 w-4" color="#6b7280" />
Create new view
</div>
@@ -666,7 +675,7 @@ export const CommandPalette: React.FC = () => {
<Command.Group heading="Page">
<Command.Item onSelect={createNewPage} className="focus:outline-none">
<div className="flex items-center gap-2 text-gray-700">
<div className="flex items-center gap-2 text-brand-secondary">
<DocumentTextIcon className="h-4 w-4" color="#6b7280" />
Create new page
</div>
@@ -685,7 +694,7 @@ export const CommandPalette: React.FC = () => {
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4" color="#6b7280" />
Search settings...
</div>
@@ -696,11 +705,24 @@ export const CommandPalette: React.FC = () => {
onSelect={createNewWorkspace}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<FolderPlusIcon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<FolderPlusIcon className="h-4 w-4 text-brand-secondary" />
Create new workspace
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change interface theme...");
setSearchTerm("");
setPages([...pages, "change-interface-theme"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Change interface theme...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Help">
<Command.Item
@@ -713,8 +735,8 @@ export const CommandPalette: React.FC = () => {
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<RocketLaunchIcon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<RocketLaunchIcon className="h-4 w-4 text-brand-secondary" />
Open keyboard shortcuts
</div>
</Command.Item>
@@ -725,8 +747,8 @@ export const CommandPalette: React.FC = () => {
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<DocumentIcon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<DocumentIcon className="h-4 w-4 text-brand-secondary" />
Open Plane documentation
</div>
</Command.Item>
@@ -737,7 +759,7 @@ export const CommandPalette: React.FC = () => {
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<div className="flex items-center gap-2 text-brand-secondary">
<DiscordIcon className="h-4 w-4" color="#6b7280" />
Join our Discord
</div>
@@ -752,7 +774,7 @@ export const CommandPalette: React.FC = () => {
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<div className="flex items-center gap-2 text-brand-secondary">
<GithubIcon className="h-4 w-4" color="#6b7280" />
Report a bug
</div>
@@ -764,8 +786,8 @@ export const CommandPalette: React.FC = () => {
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<ChatBubbleOvalLeftEllipsisIcon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<ChatBubbleOvalLeftEllipsisIcon className="h-4 w-4 text-brand-secondary" />
Chat with us
</div>
</Command.Item>
@@ -779,8 +801,8 @@ export const CommandPalette: React.FC = () => {
onSelect={() => goToSettings()}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<SettingIcon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
General
</div>
</Command.Item>
@@ -788,8 +810,8 @@ export const CommandPalette: React.FC = () => {
onSelect={() => goToSettings("members")}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<SettingIcon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Members
</div>
</Command.Item>
@@ -797,17 +819,17 @@ export const CommandPalette: React.FC = () => {
onSelect={() => goToSettings("billing")}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<SettingIcon className="h-4 w-4 text-gray-500" />
Billings and Plans
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Billing and Plans
</div>
</Command.Item>
<Command.Item
onSelect={() => goToSettings("integrations")}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<SettingIcon className="h-4 w-4 text-gray-500" />
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Integrations
</div>
</Command.Item>
@@ -815,9 +837,9 @@ export const CommandPalette: React.FC = () => {
onSelect={() => goToSettings("import-export")}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-gray-700">
<SettingIcon className="h-4 w-4 text-gray-500" />
Import/Export
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Import/ Export
</div>
</Command.Item>
</>
@@ -842,6 +864,9 @@ export const CommandPalette: React.FC = () => {
setIsPaletteOpen={setIsPaletteOpen}
/>
)}
{page === "change-interface-theme" && (
<ChangeInterfaceTheme setIsPaletteOpen={setIsPaletteOpen} />
)}
</Command.List>
</Command>
</Dialog.Panel>

View File

@@ -3,3 +3,4 @@ export * from "./shortcuts-modal";
export * from "./change-issue-state";
export * from "./change-issue-priority";
export * from "./change-issue-assignee";
export * from "./change-interface-theme";

View File

@@ -4,7 +4,7 @@ import { Dialog, Transition } from "@headlessui/react";
// icons
import { XMarkIcon } from "@heroicons/react/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { MacCommandIcon } from "components/icons";
import { CommandIcon } from "components/icons";
// ui
import { Input } from "components/ui";
@@ -71,7 +71,7 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
@@ -85,29 +85,29 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white p-5">
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-brand-surface-2 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-brand-surface-2 p-5">
<div className="sm:flex sm:items-start">
<div className="flex w-full flex-col gap-y-4 text-center sm:text-left">
<Dialog.Title
as="h3"
className="flex justify-between text-lg font-medium leading-6 text-gray-900"
className="flex justify-between text-lg font-medium leading-6 text-brand-base"
>
<span>Keyboard Shortcuts</span>
<span>
<button type="button" onClick={() => setIsOpen(false)}>
<XMarkIcon
className="h-6 w-6 text-gray-400 hover:text-gray-500"
className="h-6 w-6 text-gray-400 hover:text-brand-secondary"
aria-hidden="true"
/>
</button>
</span>
</Dialog.Title>
<div>
<div className="flex w-full items-center justify-start gap-1 rounded border-[0.6px] border-gray-200 bg-gray-100 px-3 py-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-gray-500" />
<div className="flex w-full items-center justify-start gap-1 rounded border-[0.6px] border-brand-base bg-brand-surface-1 px-3 py-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-brand-secondary" />
<Input
className="w-full border-none bg-transparent py-1 px-2 text-xs text-gray-500 focus:outline-none"
className="w-full border-none bg-transparent py-1 px-2 text-xs text-brand-secondary focus:outline-none"
id="search"
name="search"
type="text"
@@ -123,17 +123,23 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<div key={shortcut.keys} className="flex w-full flex-col">
<div className="flex flex-col gap-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">{shortcut.description}</p>
<p className="text-sm text-brand-secondary">
{shortcut.description}
</p>
<div className="flex items-center gap-x-2.5">
{shortcut.keys.split(",").map((key, index) => (
<span key={index} className="flex items-center gap-1">
{key === "Ctrl" ? (
<span className="flex h-full items-center rounded-sm border border-gray-200 bg-gray-100 p-2">
<MacCommandIcon />
<span className="flex h-full items-center rounded-sm border border-brand-base bg-brand-surface-1 p-1.5">
<CommandIcon className="h-4 w-4 fill-current text-brand-secondary" />
</span>
) : key === "Ctrl" ? (
<kbd className="rounded-sm border border-brand-base bg-brand-surface-1 p-1.5 text-sm font-medium text-brand-secondary">
<CommandIcon className="h-4 w-4 fill-current text-brand-secondary" />
</kbd>
) : (
<kbd className="rounded-sm border border-gray-200 bg-gray-100 px-2 py-1 text-sm font-medium text-gray-800">
{key === "Ctrl" ? <MacCommandIcon /> : key}
<kbd className="rounded-sm border border-brand-base bg-brand-surface-1 px-2 py-1 text-sm font-medium text-brand-secondary">
{key}
</kbd>
)}
</span>
@@ -145,7 +151,7 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
))
) : (
<div className="flex flex-col gap-y-3">
<p className="text-sm text-gray-500">
<p className="text-sm text-brand-secondary">
No shortcuts found for{" "}
<span className="font-semibold italic">
{`"`}
@@ -162,17 +168,21 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<div className="flex flex-col gap-y-3">
{shortcuts.map(({ keys, description }, index) => (
<div key={index} className="flex items-center justify-between">
<p className="text-sm text-gray-500">{description}</p>
<p className="text-sm text-brand-secondary">{description}</p>
<div className="flex items-center gap-x-2.5">
{keys.split(",").map((key, index) => (
<span key={index} className="flex items-center gap-1">
{key === "Ctrl" ? (
<span className="flex h-full items-center rounded-sm border border-gray-200 bg-gray-100 p-2">
<MacCommandIcon />
<span className="flex h-full items-center rounded-sm border border-brand-base bg-brand-surface-1 p-1.5 text-brand-secondary">
<CommandIcon className="h-4 w-4 fill-current text-brand-secondary" />
</span>
) : key === "Ctrl" ? (
<kbd className="rounded-sm border border-brand-base bg-brand-surface-1 p-1.5 text-sm font-medium text-brand-secondary">
<CommandIcon className="h-4 w-4 fill-current text-brand-secondary" />
</kbd>
) : (
<kbd className="rounded-sm border border-gray-200 bg-gray-100 px-2 py-1 text-sm font-medium text-gray-800">
{key === "Ctrl" ? <MacCommandIcon /> : key}
<kbd className="rounded-sm border border-brand-base bg-brand-surface-1 px-2 py-1 text-sm font-medium text-brand-secondary">
{key}
</kbd>
)}
</span>

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