Compare commits

...

84 Commits

Author SHA1 Message Date
sriram veeraghanta 1e27e37b51 Merge pull request #3666 from makeplane/preview
release: v0.15.2-dev
2024-02-14 19:41:55 +05:30
Bavisetti Narayan d901277a20 chore: parent can be added to issue (#3665) 2024-02-14 18:16:27 +05:30
rahulramesha 489555f788 fix: horizontal scroll in gantt chart while dragging (#3664)
* fix horizontal scroll in gantt chart while dragging

* add aline indicator for quick add

* add border color for line above quick add in gantt to make it look better in dark mode
2024-02-14 18:15:13 +05:30
Anmol Singh Bhatia b827a1af27 fix: issue details page parent issue redirection bug (#3662) 2024-02-14 18:13:29 +05:30
Ramesh Kumar Chandra 1d2e331cec fix: mobile responsive sidebar close on item tap (#3661) 2024-02-14 14:55:33 +05:30
Anmol Singh Bhatia e51e4761b9 fix: cycle favorite mutation (#3659)
* fix: cycle favorite mutation

* fix: issue details sidebar overflow fix
2024-02-14 14:17:46 +05:30
Anmol Singh Bhatia 9299478539 chore: module & cycle empty state improvement (#3658) 2024-02-14 13:03:37 +05:30
guru_sainath fb4cffdd1c fix: infinity loader in the issue description in issue peek overview and issue detail page (#3656) 2024-02-14 12:46:52 +05:30
Anmol Singh Bhatia 83bf28bb83 chore: loading state improvement (#3657) 2024-02-14 12:40:03 +05:30
guru_sainath 25a2816a76 chore: empty state loaders and filter cancel state update for modules and cycles (#3653) 2024-02-13 20:58:44 +05:30
rahulramesha 571b89632c fix: issue spillover on switching workspace (#3652)
* fix global issues spillover to other workspace

* fix the same for profile issues as well
2024-02-13 20:50:42 +05:30
sriram veeraghanta 5b5698ad97 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-13 19:13:07 +05:30
Anmol Singh Bhatia b1989bae1b feat: loading states update (#3639)
* dev: implement layout skeleton loader and helper function

* chore: implemented layout loader

* chore: settings loader added

* chore: cycle, module, view, pages, notification and projects loader added

* chore: kanban loader improvement

* chore: loader utils updated
2024-02-13 19:12:10 +05:30
rahulramesha 83139989c2 fix: sort order in the same list for Kanban (#3651)
* fixing kanban dnd by stooping the modification of the original array by spreading to change the array reference

* fix sort order in the same list

* minor change in condition
2024-02-13 19:11:20 +05:30
Anmol Singh Bhatia 1bf06821bb fix: add existing issue modal fix (#3649) 2024-02-13 18:57:56 +05:30
Anmol Singh Bhatia eea3b4fa54 chore: new empty state (#3640)
* chore: empty state asset updated

* chore: empty state config updated

* chore: cycle and module issues layout empty state added

* chore: workspace and project settings empty state added
2024-02-13 16:35:20 +05:30
rahulramesha f64284f6a0 fixing kanban dnd by stooping the modification of the original array by spreading to change the array reference (#3646) 2024-02-13 16:33:19 +05:30
Anmol Singh Bhatia 06496ff0f0 chore: app sidebar state (#3644)
* chore: app sidebar state fix

* chore: app sidebar state fix
2024-02-13 16:33:01 +05:30
Aaryan Khandelwal 247937d93a fix: scroll sync (#3645) 2024-02-13 14:33:24 +05:30
AbId KhAn e0a18578f5 fix local development setup issue due to docker compose local file makeplane/plane#3641 (#3642) 2024-02-13 12:39:58 +05:30
sriram veeraghanta e93bbec4cd fix: pages tabs responsive fixes (#3635) 2024-02-12 22:14:11 +05:30
sriram veeraghanta 7df2e9cf11 Merge pull request #3632 from makeplane/preview
release: v0.15.1-dev
2024-02-12 20:59:56 +05:30
rahulramesha d90aaba842 chore: change border of placeholder for spreadsheet (#3631)
* change border to place holder for spreadsheet

* add completed cylces exception fix

* remove window idle callback for making issues visible
2024-02-12 20:25:37 +05:30
sriram veeraghanta a303e52039 fix: package version upgrade 2024-02-12 20:23:39 +05:30
sriram veeraghanta 73d12cf1bb fix: package version upgrade 2024-02-12 20:23:12 +05:30
sriram veeraghanta 7651640e29 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-12 19:12:26 +05:30
Ramesh Kumar Chandra 41a9dc3603 fix: pages table of content drop down close on click and app sidebar expansion by default on big screens (#3629) 2024-02-12 19:11:56 +05:30
Prateek Shourya afda3ee6ca fix: priority sort order in display filters. (#3627) 2024-02-12 19:10:41 +05:30
Aaryan Khandelwal fc8edab59b fix: sidebar height, authorization (#3626) 2024-02-12 19:10:07 +05:30
sriram veeraghanta 9cf5348019 fix: merge conflicts resolved 2024-02-12 17:09:22 +05:30
Lakhan Baheti 042ed04a03 fix: project-pages responsiveness (#3624)
* fix: pages responsiveness

* fix: build errors
2024-02-12 17:01:58 +05:30
Ramesh Kumar Chandra e29edfc02b style: issue detail responsiveness (#3625) 2024-02-12 17:01:24 +05:30
Ramesh Kumar Chandra eb0af8de59 fix: inbox issues icon in project header render in big screen (#3622)
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-02-12 15:58:22 +05:30
Bavisetti Narayan 0fb43c6fc5 chore: Removing 'description_html' from Issue List (#3623)
* chore: removed issue description from issue list

* fix: issue description handling on peekoverview

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-02-12 15:26:02 +05:30
Aaryan Khandelwal 963d26ccda refactor: Gantt chart layout (#3585)
* chore: gantt sidebar and main content scroll sync

* chore: add arrow navigation position logic

* refactor: scroll position update logic

* refactor: gantt chart components

* refactor: gantt sidebar

* fix: vertical scroll issue

* fix: move to the hidden block button flickering

* refactor: gantt sidebar components

* chore: move timeline header outside

* fix gantt scroll issue

* fix: sticky position issues

* fix: infinite timeline scroll logic

* chore: removed unnecessary import statements

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2024-02-12 15:08:17 +05:30
sriram veeraghanta 3eb819c4ae fix: merge conflicts resolved 2024-02-12 13:14:00 +05:30
dependabot[bot] 4b67a41b41 chore(deps): bump django from 4.2.7 to 4.2.10 in /apiserver/requirements (#3606)
Bumps [django](https://github.com/django/django) from 4.2.7 to 4.2.10.
- [Commits](https://github.com/django/django/compare/4.2.7...4.2.10)

---
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>
2024-02-12 12:44:16 +05:30
Aaryan Khandelwal 0a36850d03 chore: show project tour modal before project creation (#3611) 2024-02-12 12:43:45 +05:30
dependabot[bot] 5bce2014c5 chore(deps): bump cryptography in /apiserver/requirements (#3619)
Bumps [cryptography](https://github.com/pyca/cryptography) from 41.0.6 to 42.0.0.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/41.0.6...42.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-12 12:42:52 +05:30
Ramesh Kumar Chandra 3f7f91e120 fix: breadcrumbs responsiveness fix, modules list mobile responsive layout component added, inbox button fix for responsiveness, board view card responsiveness in cycles and modules (#3620) 2024-02-12 12:34:15 +05:30
Prateek Shourya 1927fdd437 feat: checkbox component (#3603)
* feat: custom checkbox component.

* improvement: checkbox component implementation in email notification settings.

* improvement: add loader in email notification settings page.
2024-02-09 16:37:39 +05:30
sriram veeraghanta b86c6c906a fix: merge conflicts resolved 2024-02-09 16:30:05 +05:30
sriram veeraghanta 99975d0ba0 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-09 16:22:41 +05:30
Lakhan Baheti 4f72ebded9 chore: added sign-up/in, onboarding, dashboard, all-issues related events (#3595)
* chore: added event constants

* chore: added workspace events

* chore: added workspace group for events

* chore: member invitation event added

* chore: added project pages related events.

* fix: member integer role to string

* chore: added sign-up & sign-in events

* chore: added global-view related events

* chore: added notification related events

* chore: project, cycle property change added

* chore: cycle favourite, and change-properties added

* chore: module davorite, and sidebar property changes added

* fix: build errors

* chore: all events defined in constants
2024-02-09 16:22:08 +05:30
Lakhan Baheti be5d1eb9f9 fix: notification popover responsiveness (#3602)
* fix: notification popover responsiveness

* fix: build errors

* fix: typo
2024-02-09 16:17:39 +05:30
rahulramesha 8d730e6680 fix: spreadsheet date validation and sorting (#3607)
* fix validation for start and end date in spreadsheet layout

* revamp logic for sorting in all fields
2024-02-09 16:14:08 +05:30
sriram veeraghanta 41a3cb708c Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-09 15:59:44 +05:30
Bavisetti Narayan 27037a2177 feat: completed cycle snapshot (#3600)
* fix: transfer cycle old distribtion captured

* chore: active cycle snapshot

* chore: migration file changed

* chore: distribution payload changed

* chore: labels and assignee structure change

* chore: migration changes

* chore: cycle snapshot progress payload updated

* chore: cycle snapshot progress type added

* chore: snapshot progress stats updated in cycle sidebar

* chore: empty string validation

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-02-09 15:53:54 +05:30
rahulramesha e2affc3fa6 chore: virtualization ish behaviour for issue layouts (#3538)
* Virtualization like core changes with intersection observer

* Virtualization like changes for spreadsheet

* Virtualization like changes for list

* Virtualization like changes for kanban

* add logic to render all the issues at once

* revert back the changes for list to follow the old pattern of grouping

* fix column shadow in spreadsheet for rendering rows

* fix constant draggable height while dragging and rendering blocks in kanban

* fix height glitch while rendered rows adjust to default height

* remove loading animation for issue layouts

* reduce requestIdleCallback timer to 300ms

* remove logic for index tarcking to force render as the same effect seems to be achieved by removing requestIdleCallback

* Fix Kanban droppable height

* fix spreadsheet sub issue loading

* force change in reference to re render the render if visible component when the order of list changes

* add comments and minor changes
2024-02-09 15:53:15 +05:30
Ramesh Kumar Chandra 3a14f19c99 style: responsive analytics (#3604) 2024-02-09 15:52:25 +05:30
sriram veeraghanta eb4c3a4db5 fix: merge conflicts resolved 2024-02-08 18:50:04 +05:30
Aaryan Khandelwal 2e129682b7 fix: incorrect dashboard tab (#3597)
* fix: incorrect dashboard tab

* chore: added comments for the helper functions

* style: updated tabs list UI

* chore: default widget filters changed

* fix: build errors

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-02-08 17:57:22 +05:30
Anmol Singh Bhatia 4a145f7a06 fix: notification card snooze (#3598)
* fix: resolved event propagation issue with notification card snooze button

* fix: resolved event propagation issue with notification card snooze button
2024-02-08 17:55:57 +05:30
Anmol Singh Bhatia e69fcd410c chore: custom menu dropdown improvement (#3599)
* conflict: merge conflict resolved

* chore: breadcrumbs component improvement
2024-02-08 17:53:36 +05:30
Ramesh Kumar Chandra 55afef204d style: responsive profile (#3596)
* style: responsive profile

* style: profile header drop down, sidebar auto show hide depending on the screen width

* fix: item tap on white space in the drop down menu in profile header

* fix: profile layout breaking on big screens in page visit
2024-02-08 17:49:26 +05:30
sriram veeraghanta f9e187d8b9 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-08 13:30:39 +05:30
Aaryan Khandelwal 9545dc77d6 fix: cycle and module reordering in the gantt chart (#3570)
* fix: cycle and module reordering in the gantt chart

* chore: hide duration from sidebar if no dates are assigned

* chore: updated date helper functions to accept undefined params

* chore: update cycle sidebar condition
2024-02-08 13:30:16 +05:30
sriram veeraghanta 1fc987c6c9 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-08 13:14:25 +05:30
guru_sainath c1c0297b6d fix: handled issue create modal submission on clicking enter key (#3593) 2024-02-08 13:08:19 +05:30
Manish Gupta 1a7b5d7222 build fix (#3594) 2024-02-08 12:26:55 +05:30
rahulramesha fb3dd77b66 feat: Keyboard navigation spreadsheet layout for issues (#3564)
* enable keyboard navigation for spreadsheet layout

* move the logic to table level instead of cell level

* fix perf issue that made it unusable

* fix scroll issue with navigation

* fix build errors
2024-02-08 11:49:00 +05:30
Manish Gupta a43dfc097d feat: muti-arch build (#3569)
* arm build, supporting centos & alpine
2024-02-08 11:47:52 +05:30
Ramesh Kumar Chandra 346c6f5414 fix: module header hide on bigger screens (#3590)
* fix: module header hide on bigger screens

* fix: Add Inbox back on mobile

---------

Co-authored-by: Maximilian Engel <maximilian.engel@swg.de>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-02-08 00:16:00 +05:30
Anmol Singh Bhatia 729b6ac79e fix: Handled the draft issue from issue create modal and optimised the draft issue store (#3588)
Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-02-07 20:45:05 +05:30
Bavisetti Narayan 0a35fcfbc0 chore: email template logo changed (#3575)
* chore: email template logo changed

* fix: icons image extensions

* fix: state icons

---------

Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com>
2024-02-07 20:20:54 +05:30
Anmol Singh Bhatia 75b4c6e7d6 chore: posthog code refactor (#3586) 2024-02-07 17:29:39 +05:30
João Lucas de Oliveira Lopes a1d6c40627 fix: show window closing alert only when page is not saved (#3577)
* fix: show window closing alert only when page is not saved

* chore: Refactor useReloadConfirmations hook

- Removed the `message` parameter, as it was not being used and not
  supported in modern browsers
- Changed the `isActive` flag to a temporary flag and added a TODO comment to remove it later.
- Implemented the `handleRouteChangeStart` function to handle route change events and prompt the user with a confirmation dialog before leaving the page.
- Updated the dependencies of the `handleBeforeUnload` and `handleRouteChangeStart` callbacks.
- Added event listeners for `beforeunload` and `routeChangeStart` events in the `useEffect` hook.
- Cleaned up the event listeners in the cleanup function of the `useEffect` hook.

fix: Fix reload confirmations in PageDetailsPage

- Removed the TODO comment regarding fixing reload confirmations with MobX, as it has been resolved.
- Passed the `pageStore?.isSubmitting === "submitting"` flag to the `useReloadConfirmations` hook instead of an undefined message.

This commit refactors the `useReloadConfirmations` hook to improve its functionality and fixes the usage in the `PageDetailsPage` component.

---------

Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com>
2024-02-07 17:10:44 +05:30
Anmol Singh Bhatia 4a2e648f6d chore: workspace dashboard refactor (#3584) 2024-02-07 16:21:20 +05:30
Bavisetti Narayan 76db394ab1 chore: mention notification and webhook faliure (#3573)
* fix: mention rstrip error

* chore: webhook deactivation email

* chore: changed template

* chore: current site for external api's

* chore: mention in template displayed

* chore: mention tag fix

* chore: comment user name displayed
2024-02-07 15:14:30 +05:30
Bavisetti Narayan 4563b50fad chore: module issue count (#3566) 2024-02-07 15:13:35 +05:30
Anmol Singh Bhatia 065226f8b2 fix: draft issue peek overview (#3582)
* chore: project, view and shortcut modal alignment consistency

* chore: issue highlight list layout improvement

* fix: draft issue peek overview fix

* fix: draft issue layout inline editing
2024-02-07 15:06:07 +05:30
Anmol Singh Bhatia 0a99a1a091 fix: dashboard header z index and workspace active cycles fix (#3581)
* fix: dashboard header z index fix

* chore: workspace active cycles upgrade page improvement
2024-02-07 13:44:45 +05:30
Ramesh Kumar Chandra 4b2a9c8335 style: responsive breadcrumbs and headers for dashboard, projects, project issues, cycles, cycle issues, module issues (#3580) 2024-02-07 13:44:03 +05:30
Nikhil 751b15a7a7 dev: update the response for conflicting errors (#3568) 2024-02-06 17:00:42 +05:30
Bavisetti Narayan ac22769220 chore: email trigger for new assignee (#3572) 2024-02-06 16:47:14 +05:30
Manish Gupta 46ae0f98dc app_release value handled (#3571) 2024-02-06 15:35:05 +05:30
Anmol Singh Bhatia 30aaec9097 chore: module date validation (#3565) 2024-02-05 20:46:01 +05:30
Anmol Singh Bhatia 4041c5bc5b chore: cycle and module sidebar improvement (#3562) 2024-02-05 19:13:21 +05:30
Bavisetti Narayan 403595a897 chore: removed email notification for new users (#3561) 2024-02-05 19:13:02 +05:30
Aaryan Khandelwal 0ee93dfd8c chore: added None filter option to the dashboard widgets (#3556)
* chore: added tab change animation

* chore: widgets filtering logic updated

* refactor: issues list widget

* fix: tab navigation transition

* fix: extra top spacing on opening the peek overview
2024-02-05 19:12:33 +05:30
Anmol Singh Bhatia ee0e3e2e25 chore: active cycle issue transfer validation (#3560)
* fix: completed cycle list layout validation

* fix: completed cycle kanban layout validation

* fix: completed cycle spreadsheet layout validation

* fix: date dropdown disabled fix

* chore: quick action validation added for list, kanban and spreadsheet layout

* fix: calendar layout validation added
2024-02-05 14:47:40 +05:30
Lakhan Baheti 0165abab3e chore: posthog events improved (#3554)
* chore: events naming convention changed

* chore: track element added for project related events

* chore: track element added for cycle related events

* chore: track element added for module related events

* chore: issue related events updated

* refactor: event tracker store

* refactor: event-tracker store

* fix: posthog changes

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-02-05 13:19:07 +05:30
Anmol Singh Bhatia 7d07afd59c chore: cycle and module sidebar analytics improvement (#3559)
* chore: cycle and module store update action updated

* chore: cycle and module issue store actions updated

* chore: cycle and module retrieve endpoints updated

* fix: app sidebar z index and priority icon fix

* chore: cycle and module sidebar and stats updated
2024-02-05 12:34:53 +05:30
sriram veeraghanta c6e3f1b932 Merge pull request #3535 from makeplane/preview
release: 0.15-dev
2024-02-01 15:01:49 +05:30
593 changed files with 12492 additions and 5710 deletions
+90 -62
View File
@@ -2,11 +2,6 @@ name: Branch Build
on:
workflow_dispatch:
inputs:
branch_name:
description: "Branch Name"
required: true
default: "preview"
push:
branches:
- master
@@ -16,49 +11,71 @@ on:
types: [released, prereleased]
env:
TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }}
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
jobs:
branch_build_setup:
name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
runs-on: ubuntu-latest
outputs:
gh_branch_name: ${{ env.TARGET_BRANCH }}
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
steps:
- id: set_env_variables
name: Set Environment Variables
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
else
echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
fi
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
branch_build_push_frontend:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Frontend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
else
TAG=${{ env.FRONTEND_TAG }}
fi
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4.1.1
@@ -67,7 +84,7 @@ jobs:
with:
context: .
file: ./web/Dockerfile.web
platforms: linux/amd64
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.FRONTEND_TAG }}
push: true
env:
@@ -80,33 +97,36 @@ jobs:
needs: [branch_build_setup]
env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Space Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
else
TAG=${{ env.SPACE_TAG }}
fi
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4.1.1
@@ -115,7 +135,7 @@ jobs:
with:
context: .
file: ./space/Dockerfile.space
platforms: linux/amd64
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.SPACE_TAG }}
push: true
env:
@@ -128,33 +148,36 @@ jobs:
needs: [branch_build_setup]
env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Backend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
else
TAG=${{ env.BACKEND_TAG }}
fi
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4.1.1
@@ -163,7 +186,7 @@ jobs:
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/amd64
platforms: ${{ env.BUILDX_PLATFORMS }}
push: true
tags: ${{ env.BACKEND_TAG }}
env:
@@ -171,38 +194,42 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Proxy Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
else
TAG=${{ env.PROXY_TAG }}
fi
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4.1.1
@@ -211,10 +238,11 @@ jobs:
with:
context: ./nginx
file: ./nginx/Dockerfile
platforms: linux/amd64
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.PROXY_TAG }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.15.0"
"version": "0.15.1"
}
+8
View File
@@ -1,6 +1,8 @@
# Python imports
import zoneinfo
import json
from urllib.parse import urlparse
# Django imports
from django.conf import settings
@@ -51,6 +53,11 @@ class WebhookMixin:
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
url = request.build_absolute_uri()
parsed_url = urlparse(url)
# Extract the scheme and netloc
scheme = parsed_url.scheme
netloc = parsed_url.netloc
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
@@ -59,6 +66,7 @@ class WebhookMixin:
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
current_site=f"{scheme}://{netloc}",
)
return response
+2 -2
View File
@@ -262,7 +262,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(
{
"error": "Cycle with the same external id and external source already exists",
"cycle": str(cycle.id),
"id": str(cycle.id),
},
status=status.HTTP_409_CONFLICT,
)
@@ -325,7 +325,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(
{
"error": "Cycle with the same external id and external source already exists",
"cycle_id": str(cycle.id),
"id": str(cycle.id),
},
status=status.HTTP_409_CONFLICT,
)
+56 -5
View File
@@ -239,7 +239,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(
{
"error": "Issue with the same external id and external source already exists",
"issue_id": str(issue.id),
"id": str(issue.id),
},
status=status.HTTP_409_CONFLICT,
)
@@ -286,14 +286,16 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
and Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source", issue.external_source),
external_source=request.data.get(
"external_source", issue.external_source
),
external_id=request.data.get("external_id"),
).exists()
):
return Response(
{
"error": "Issue with the same external id and external source already exists",
"issue_id": str(issue.id),
"id": str(issue.id),
},
status=status.HTTP_409_CONFLICT,
)
@@ -362,6 +364,30 @@ class LabelAPIEndpoint(BaseAPIView):
try:
serializer = LabelSerializer(data=request.data)
if serializer.is_valid():
if (
request.data.get("external_id")
and request.data.get("external_source")
and Label.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
label = Label.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "Label with the same external id and external source already exists",
"id": str(label.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save(project_id=project_id)
return Response(
serializer.data, status=status.HTTP_201_CREATED
@@ -370,11 +396,17 @@ class LabelAPIEndpoint(BaseAPIView):
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
except IntegrityError:
label = Label.objects.filter(
workspace__slug=slug,
project_id=project_id,
name=request.data.get("name"),
).first()
return Response(
{
"error": "Label with the same name already exists in the project"
"error": "Label with the same name already exists in the project",
"id": str(label.id),
},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_409_CONFLICT,
)
def get(self, request, slug, project_id, pk=None):
@@ -401,6 +433,25 @@ class LabelAPIEndpoint(BaseAPIView):
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(label, data=request.data, partial=True)
if serializer.is_valid():
if (
str(request.data.get("external_id"))
and (label.external_id != str(request.data.get("external_id")))
and Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", label.external_source
),
external_id=request.data.get("external_id"),
).exists()
):
return Response(
{
"error": "Label with the same external id and external source already exists",
"id": str(label.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+2 -2
View File
@@ -151,7 +151,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(
{
"error": "Module with the same external id and external source already exists",
"module_id": str(module.id),
"id": str(module.id),
},
status=status.HTTP_409_CONFLICT,
)
@@ -185,7 +185,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(
{
"error": "Module with the same external id and external source already exists",
"module_id": str(module.id),
"id": str(module.id),
},
status=status.HTTP_409_CONFLICT,
)
+2 -2
View File
@@ -57,7 +57,7 @@ class StateAPIEndpoint(BaseAPIView):
return Response(
{
"error": "State with the same external id and external source already exists",
"state_id": str(state.id),
"id": str(state.id),
},
status=status.HTTP_409_CONFLICT,
)
@@ -128,7 +128,7 @@ class StateAPIEndpoint(BaseAPIView):
return Response(
{
"error": "State with the same external id and external source already exists",
"state_id": str(state.id),
"id": str(state.id),
},
status=status.HTTP_409_CONFLICT,
)
@@ -68,6 +68,7 @@ from .issue import (
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
IssueDetailSerializer,
)
from .module import (
+7 -1
View File
@@ -586,7 +586,6 @@ class IssueSerializer(DynamicBaseSerializer):
"id",
"name",
"state_id",
"description_html",
"sort_order",
"completed_at",
"estimate_point",
@@ -618,6 +617,13 @@ class IssueSerializer(DynamicBaseSerializer):
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + ['description_html']
class IssueLiteSerializer(DynamicBaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
+6 -6
View File
@@ -401,8 +401,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
event_name="Sign up",
medium="Magic link",
first_time=True,
)
key, token, current_attempt = generate_magic_token(email=email)
@@ -438,8 +438,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
event_name="Sign in",
medium="Magic link",
first_time=False,
)
@@ -468,8 +468,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
event_name="Sign in",
medium="Email",
first_time=False,
)
+4 -4
View File
@@ -274,8 +274,8 @@ class SignInEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
event_name="Sign in",
medium="Email",
first_time=False,
)
@@ -349,8 +349,8 @@ class MagicSignInEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
event_name="Sign in",
medium="Magic link",
first_time=False,
)
+1
View File
@@ -64,6 +64,7 @@ class WebhookMixin:
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
current_site=request.META.get("HTTP_ORIGIN"),
)
return response
+235 -13
View File
@@ -20,6 +20,7 @@ from django.core import serializers
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework.response import Response
@@ -242,13 +243,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.values("display_name", "assignee_id", "avatar")
.annotate(
total_issues=Count(
"assignee_id",
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -258,7 +259,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -281,13 +282,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id",
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -297,7 +298,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -312,6 +313,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"labels": label_distribution,
"completion_chart": {},
}
if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"][
"completion_chart"
@@ -419,13 +421,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
total_issues=Count(
"assignee_id",
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -435,7 +437,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -459,13 +461,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id",
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -475,7 +477,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -840,10 +842,230 @@ class TransferCycleIssueEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
new_cycle = Cycle.objects.get(
new_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
).first()
old_cycle = (
Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
)
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
cycle_id=cycle_id,
)
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None,
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": str(item["label_id"]) if item["label_id"] else None,
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
current_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.first().total_issues,
"completed_issues": old_cycle.first().completed_issues,
"cancelled_issues": old_cycle.first().cancelled_issues,
"started_issues": old_cycle.first().started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues,
"total_estimates": old_cycle.first().total_estimates,
"completed_estimates": old_cycle.first().completed_estimates,
"started_estimates": old_cycle.first().started_estimates,
"distribution":{
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
}
current_cycle.save(update_fields=["progress_snapshot"])
if (
new_cycle.end_date is not None
and new_cycle.end_date < timezone.now().date()
+34
View File
@@ -145,6 +145,23 @@ def dashboard_assigned_issues(self, request, slug):
)
).order_by("priority_order")
if issue_type == "pending":
pending_issues_count = assigned_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
).count()
pending_issues = assigned_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
)[:5]
return Response(
{
"issues": IssueSerializer(
pending_issues, many=True, expand=self.expand
).data,
"count": pending_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "completed":
completed_issues_count = assigned_issues.filter(
state__group__in=["completed"]
@@ -257,6 +274,23 @@ def dashboard_created_issues(self, request, slug):
)
).order_by("priority_order")
if issue_type == "pending":
pending_issues_count = created_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
).count()
pending_issues = created_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
)[:5]
return Response(
{
"issues": IssueSerializer(
pending_issues, many=True, expand=self.expand
).data,
"count": pending_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "completed":
completed_issues_count = created_issues.filter(
state__group__in=["completed"]
+15 -14
View File
@@ -50,6 +50,7 @@ from plane.app.serializers import (
CommentReactionSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssueDetailSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
@@ -267,7 +268,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
def retrieve(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first()
return Response(
IssueSerializer(
IssueDetailSerializer(
issue, fields=self.fields, expand=self.expand
).data,
status=status.HTTP_200_OK,
@@ -1668,15 +1669,9 @@ class IssueDraftViewSet(BaseViewSet):
def get_queryset(self):
return (
Issue.objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
Issue.objects.filter(
project_id=self.kwargs.get("project_id")
)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.select_related("workspace", "project", "state", "parent")
@@ -1710,7 +1705,7 @@ class IssueDraftViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
).distinct()
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
@@ -1832,7 +1827,10 @@ class IssueDraftViewSet(BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first()
)
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk):
@@ -1868,10 +1866,13 @@ class IssueDraftViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True
issue = self.get_queryset().filter(pk=pk).first()
return Response(
IssueSerializer(
issue, fields=self.fields, expand=self.expand
).data,
status=status.HTTP_200_OK,
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
+7 -7
View File
@@ -197,7 +197,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
total_issues=Count(
"assignee_id",
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
@@ -206,7 +206,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
completed_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -216,7 +216,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -239,7 +239,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id",
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
@@ -248,7 +248,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
completed_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -258,7 +258,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -334,7 +334,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
def get_queryset(self):
return (
Issue.objects.filter(
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id")
+2 -2
View File
@@ -296,7 +296,7 @@ class OauthEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
event_name="Sign in",
medium=medium.upper(),
first_time=False,
)
@@ -427,7 +427,7 @@ class OauthEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
event_name="Sign up",
medium=medium.upper(),
first_time=True,
)
+1 -6
View File
@@ -247,12 +247,7 @@ class IssueSearchEndpoint(BaseAPIView):
if parent == "true" and issue_id:
issue = 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.issue_objects.filter(
parent__isnull=False
).values_list("parent_id", flat=True)
)
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id))
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
+144 -110
View File
@@ -1,5 +1,6 @@
import json
from datetime import datetime
from bs4 import BeautifulSoup
# Third party imports
from celery import shared_task
@@ -9,7 +10,6 @@ from django.utils import timezone
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Module imports
from plane.db.models import EmailNotificationLog, User, Issue
@@ -40,7 +40,7 @@ def stack_email_notification():
processed_notifications = []
# Loop through all the issues to create the emails
for receiver_id in receivers:
# Notifcation triggered for the receiver
# Notification triggered for the receiver
receiver_notifications = [
notification
for notification in email_notifications
@@ -124,119 +124,153 @@ def create_payload(notification_data):
return data
def process_mention(mention_component):
soup = BeautifulSoup(mention_component, 'html.parser')
mentions = soup.find_all('mention-component')
for mention in mentions:
user_id = mention['id']
user = User.objects.get(pk=user_id)
user_name = user.display_name
highlighted_name = f"@{user_name}"
mention.replace_with(highlighted_name)
return str(soup)
def process_html_content(content):
processed_content_list = []
for html_content in content:
processed_content = process_mention(html_content)
processed_content_list.append(processed_content)
return processed_content_list
@shared_task
def send_email_notification(
issue_id, notification_data, receiver_id, email_notification_ids
):
ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data)
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
receiver = User.objects.get(pk=receiver_id)
issue = Issue.objects.get(pk=issue_id)
template_data = []
total_changes = 0
comments = []
actors_involved = []
for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes)
comment = changes.pop("comment", False)
actors_involved.append(actor_id)
if comment:
comments.append(
{
"actor_comments": comment,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
# Parse the input string into a datetime object
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
if changes:
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(formatted_time),
}
)
summary = "Updates were made to the issue by"
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
context = {
"data": template_data,
"summary": summary,
"actors_involved": len(set(actors_involved)),
"issue": {
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"name": issue.name,
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
},
"receiver": {
"email": receiver.email,
},
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
"workspace":str(issue.project.workspace.slug),
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
)
text_content = strip_tags(html_content)
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
EmailNotificationLog.objects.filter(
pk__in=email_notification_ids
).update(sent_at=timezone.now())
return
except Exception as e:
print(e)
receiver = User.objects.get(pk=receiver_id)
issue = Issue.objects.get(pk=issue_id)
template_data = []
total_changes = 0
comments = []
actors_involved = []
for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes)
comment = changes.pop("comment", False)
mention = changes.pop("mention", False)
actors_involved.append(actor_id)
if comment:
comments.append(
{
"actor_comments": comment,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
if mention:
mention["new_value"] = process_html_content(mention.get("new_value"))
mention["old_value"] = process_html_content(mention.get("old_value"))
comments.append(
{
"actor_comments": mention,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
# Parse the input string into a datetime object
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
if changes:
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(formatted_time),
}
)
summary = "Updates were made to the issue by"
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
context = {
"data": template_data,
"summary": summary,
"actors_involved": len(set(actors_involved)),
"issue": {
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"name": issue.name,
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
},
"receiver": {
"email": receiver.email,
},
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
"workspace":str(issue.project.workspace.slug),
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
)
text_content = strip_tags(html_content)
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
EmailNotificationLog.objects.filter(
pk__in=email_notification_ids
).update(sent_at=timezone.now())
return
except Exception as e:
print(e)
return
except Issue.DoesNotExist:
return
@@ -353,13 +353,18 @@ def track_assignees(
issue_activities,
epoch,
):
requested_assignees = set(
[str(asg) for asg in requested_data.get("assignee_ids", [])]
requested_assignees = (
set([str(asg) for asg in requested_data.get("assignee_ids", [])])
if requested_data is not None
else set()
)
current_assignees = set(
[str(asg) for asg in current_instance.get("assignee_ids", [])]
current_assignees = (
set([str(asg) for asg in current_instance.get("assignee_ids", [])])
if current_instance is not None
else set()
)
added_assignees = requested_assignees - current_assignees
dropped_assginees = current_assignees - requested_assignees
@@ -547,6 +552,20 @@ def create_issue_activity(
epoch=epoch,
)
)
requested_data = (
json.loads(requested_data) if requested_data is not None else None
)
if requested_data.get("assignee_ids") is not None:
track_assignees(
requested_data,
current_instance,
issue_id,
project_id,
workspace_id,
actor_id,
issue_activities,
epoch,
)
def update_issue_activity(
+4 -1
View File
@@ -515,7 +515,7 @@ def notifications(
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
receiver_id=mention_id,
entity_identifier=issue_id,
entity_name="issue",
data={
@@ -552,6 +552,7 @@ def notifications(
"old_value": str(
issue_activity.get("old_value")
),
"activity_time": issue_activity.get("created_at"),
},
},
)
@@ -639,6 +640,7 @@ def notifications(
"old_value": str(
last_activity.old_value
),
"activity_time": issue_activity.get("created_at"),
},
},
)
@@ -695,6 +697,7 @@ def notifications(
"old_value"
)
),
"activity_time": issue_activity.get("created_at"),
},
},
)
+75 -4
View File
@@ -7,6 +7,9 @@ import hmac
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
# Third party imports
from celery import shared_task
@@ -22,10 +25,10 @@ from plane.db.models import (
ModuleIssue,
CycleIssue,
IssueComment,
User,
)
from plane.api.serializers import (
ProjectSerializer,
IssueSerializer,
CycleSerializer,
ModuleSerializer,
CycleIssueSerializer,
@@ -34,6 +37,9 @@ from plane.api.serializers import (
IssueExpandSerializer,
)
# Module imports
from plane.license.utils.instance_value import get_email_configuration
SERIALIZER_MAPPER = {
"project": ProjectSerializer,
"issue": IssueExpandSerializer,
@@ -72,7 +78,7 @@ def get_model_data(event, event_id, many=False):
max_retries=5,
retry_jitter=True,
)
def webhook_task(self, webhook, slug, event, event_data, action):
def webhook_task(self, webhook, slug, event, event_data, action, current_site):
try:
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
@@ -151,7 +157,18 @@ def webhook_task(self, webhook, slug, event, event_data, action):
response_body=str(e),
retry_count=str(self.request.retries),
)
# Retry logic
if self.request.retries >= self.max_retries:
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
if webhook:
# send email for the deactivation of the webhook
send_webhook_deactivation_email(
webhook_id=webhook.id,
receiver_id=webhook.created_by_id,
reason=str(e),
current_site=current_site,
)
return
raise requests.RequestException()
except Exception as e:
@@ -162,7 +179,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
@shared_task()
def send_webhook(event, payload, kw, action, slug, bulk):
def send_webhook(event, payload, kw, action, slug, bulk, current_site):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
@@ -216,6 +233,7 @@ def send_webhook(event, payload, kw, action, slug, bulk):
event=event,
event_data=data,
action=action,
current_site=current_site,
)
except Exception as e:
@@ -223,3 +241,56 @@ def send_webhook(event, payload, kw, action, slug, bulk):
print(e)
capture_exception(e)
return
@shared_task
def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason):
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
receiver = User.objects.get(pk=receiver_id)
webhook = Webhook.objects.get(pk=webhook_id)
subject="Webhook Deactivated"
message=f"Webhook {webhook.url} has been deactivated due to failed requests."
# Send the mail
context = {
"email": receiver.email,
"message": message,
"webhook_url":f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
}
html_content = render_to_string(
"emails/notifications/webhook-deactivate.html", context
)
text_content = strip_tags(html_content)
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
except Exception as e:
print(e)
return
@@ -0,0 +1,33 @@
# Generated by Django 4.2.7 on 2024-02-08 09:57
from django.db import migrations
def widgets_filter_change(apps, schema_editor):
Widget = apps.get_model("db", "Widget")
widgets_to_update = []
# Define the filter dictionaries for each widget key
filters_mapping = {
"assigned_issues": {"duration": "none", "tab": "pending"},
"created_issues": {"duration": "none", "tab": "pending"},
"issues_by_state_groups": {"duration": "none"},
"issues_by_priority": {"duration": "none"},
}
# Iterate over widgets and update filters if applicable
for widget in Widget.objects.all():
if widget.key in filters_mapping:
widget.filters = filters_mapping[widget.key]
widgets_to_update.append(widget)
# Bulk update the widgets
Widget.objects.bulk_update(widgets_to_update, ["filters"], batch_size=10)
class Migration(migrations.Migration):
dependencies = [
('db', '0058_alter_moduleissue_issue_and_more'),
]
operations = [
migrations.RunPython(widgets_filter_change)
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-02-08 09:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0059_auto_20240208_0957'),
]
operations = [
migrations.AddField(
model_name='cycle',
name='progress_snapshot',
field=models.JSONField(default=dict),
),
]
+1
View File
@@ -68,6 +68,7 @@ class Cycle(ProjectBaseModel):
sort_order = models.FloatField(default=65535)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
progress_snapshot = models.JSONField(default=dict)
class Meta:
verbose_name = "Cycle"
+5
View File
@@ -172,4 +172,9 @@ def create_user_notification(sender, instance, created, **kwargs):
from plane.db.models import UserNotificationPreference
UserNotificationPreference.objects.create(
user=instance,
property_change=False,
state_change=False,
comment=False,
mention=False,
issue_completed=False,
)
-2
View File
@@ -282,10 +282,8 @@ if REDIS_SSL:
redis_url = os.environ.get("REDIS_URL")
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
CELERY_BROKER_URL = broker_url
CELERY_RESULT_BACKEND = broker_url
else:
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
CELERY_IMPORTS = (
"plane.bgtasks.issue_automation_task",
+2 -2
View File
@@ -1,6 +1,6 @@
# base requirements
Django==4.2.7
Django==4.2.10
psycopg==3.1.12
djangorestframework==3.14.0
redis==4.6.0
@@ -30,7 +30,7 @@ openpyxl==3.1.2
beautifulsoup4==4.12.2
dj-database-url==2.1.0
posthog==3.0.2
cryptography==41.0.6
cryptography==42.0.0
lxml==4.9.3
boto3==1.28.40
@@ -66,7 +66,7 @@
style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px"
>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.png"
width="130"
height="40"
border="0"
@@ -108,25 +108,33 @@
margin-bottom: 15px;
"
/>
{% if actors_involved == 1 %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
{{summary}}
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{{ data.0.actor_detail.first_name}}
{{data.0.actor_detail.last_name}}
</span>.
</p>
{% else %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
{{summary}}
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{{ data.0.actor_detail.first_name}}
{{data.0.actor_detail.last_name }}
</span>and others.
</p>
{% endif %}
{% if actors_involved == 1 %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
{{summary}}
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{% if data|length > 0 %}
{{ data.0.actor_detail.first_name}}
{{data.0.actor_detail.last_name}}
{% else %}
{{ comments.0.actor_detail.first_name}}
{{comments.0.actor_detail.last_name}}
{% endif %}
</span>.
</p>
{% else %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
{{summary}}
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{% if data|length > 0 %}
{{ data.0.actor_detail.first_name}}
{{data.0.actor_detail.last_name}}
{% else %}
{{ comments.0.actor_detail.first_name}}
{{comments.0.actor_detail.last_name}}
{% endif %}
</span>and others.
</p>
{% endif %}
<!-- {% if actors_involved == 1 %}
{% if data|length > 0 and comments|length == 0 %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
@@ -272,7 +280,7 @@
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/due-date.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/due-date.png"
width="12"
height="12"
border="0"
@@ -333,7 +341,7 @@
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/duplicate.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/duplicate.png"
width="12"
height="12"
border="0"
@@ -428,7 +436,7 @@
<tr>
<td valign="top" style="white-space: nowrap; padding: 0px;">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/assignee.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/assignee.png"
width="12"
height="12"
border="0"
@@ -524,7 +532,7 @@
<tr>
<td valign="top" style="white-space: nowrap; padding: 0px;">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/labels.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/labels.png"
width="12"
height="12"
border="0"
@@ -621,7 +629,7 @@
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/state.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/state.png"
width="12"
height="12"
border="0"
@@ -639,15 +647,17 @@
State:
</p>
</td>
<td >
{% if update.changes.state.old_value.0 == 'Backlog' or update.changes.state.old_value.0 == 'In Progress' or update.changes.state.old_value.0 == 'Done' or update.changes.state.old_value.0 == 'Cancelled' %}
<td>
<img
src="{% if update.changes.state.old_value.0 == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% endif %}{% if update.changes.state.old_value.0 == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% endif %}{% if update.changes.state.old_value.0 == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% endif %}{% if update.changes.state.old_value.0 == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}"
src="{% if update.changes.state.old_value.0 == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.png{% endif %}{% if update.changes.state.old_value.0 == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.png{% endif %}{% if update.changes.state.old_value.0 == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.png{% endif %}{% if update.changes.state.old_value.0 == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.png{% endif %}"
width="12"
height="12"
border="0"
style="display: block; margin-left: 5px;"
/>
</td>
{% endif %}
<td>
<p
style="
@@ -661,22 +671,24 @@
</td>
<td style="padding-left: 10px; padding-right: 10px;">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.png"
width="16"
height="16"
border="0"
style="display: block;"
/>
</td>
<td >
{% if update.changes.state.new_value|last == 'Backlog' or update.changes.state.new_value|last == 'In Progress' or update.changes.state.new_value|last == 'Done' or update.changes.state.new_value|last == 'Cancelled' %}
<td>
<img
src="{% if update.changes.state.new_value|last == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% elif update.changes.state.new_value|last == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% elif update.changes.state.new_value|last == 'Todo' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/todo.webp{% elif update.changes.state.new_value|last == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% elif update.changes.state.new_value|last == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}"
src="{% if update.changes.state.new_value|last == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.png{% elif update.changes.state.new_value|last == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.png{% elif update.changes.state.new_value|last == 'Todo' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/todo.png{% elif update.changes.state.new_value|last == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.png{% elif update.changes.state.new_value|last == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.png{% endif %}"
width="12"
height="12"
border="0"
style="display: block;"
/>
</td>
{% endif %}
<td>
<p
style="
@@ -699,7 +711,7 @@
<tr>
<td valign="top">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/link.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/link.png"
width="12"
height="12"
border="0"
@@ -760,7 +772,7 @@
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/priority.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/priority.png"
width="12"
height="12"
border="0"
@@ -800,7 +812,7 @@
</td>
<td style="padding-left: 10px; padding-right: 10px;">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.png"
width="16"
height="16"
border="0"
@@ -838,7 +850,7 @@
<tr style="overflow-wrap: break-word;">
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/blocking.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/blocking.png"
width="12"
height="12"
border="0"
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,5 +1,6 @@
#!/bin/bash
# Check if the user has sudo access
if command -v curl &> /dev/null; then
sudo curl -sSL \
-o /usr/local/bin/plane-app \
@@ -11,6 +12,6 @@ else
fi
sudo chmod +x /usr/local/bin/plane-app
sudo sed -i 's/export BRANCH=${BRANCH:-master}/export BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
sudo sed -i 's/export DEPLOY_BRANCH=${BRANCH:-master}/export DEPLOY_BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
sudo plane-app --help
plane-app --help
+123 -80
View File
@@ -17,7 +17,7 @@ Project management tool from the future
EOF
}
function update_env_files() {
function update_env_file() {
config_file=$1
key=$2
value=$3
@@ -25,14 +25,16 @@ function update_env_files() {
# Check if the config file exists
if [ ! -f "$config_file" ]; then
echo "Config file not found. Creating a new one..." >&2
touch "$config_file"
sudo touch "$config_file"
fi
# Check if the key already exists in the config file
if grep -q "^$key=" "$config_file"; then
awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
if sudo grep "^$key=" "$config_file"; then
sudo awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" | sudo tee "$config_file.tmp" > /dev/null
sudo mv "$config_file.tmp" "$config_file" &> /dev/null
else
echo "$key=$value" >> "$config_file"
# sudo echo "$key=$value" >> "$config_file"
echo -e "$key=$value" | sudo tee -a "$config_file" > /dev/null
fi
}
function read_env_file() {
@@ -42,12 +44,12 @@ function read_env_file() {
# Check if the config file exists
if [ ! -f "$config_file" ]; then
echo "Config file not found. Creating a new one..." >&2
touch "$config_file"
sudo touch "$config_file"
fi
# Check if the key already exists in the config file
if grep -q "^$key=" "$config_file"; then
value=$(awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
if sudo grep -q "^$key=" "$config_file"; then
value=$(sudo awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
echo "$value"
else
echo ""
@@ -55,19 +57,19 @@ function read_env_file() {
}
function update_config() {
config_file="$PLANE_INSTALL_DIR/config.env"
update_env_files "$config_file" "$1" "$2"
update_env_file $config_file $1 $2
}
function read_config() {
config_file="$PLANE_INSTALL_DIR/config.env"
read_env_file "$config_file" "$1"
read_env_file $config_file $1
}
function update_env() {
config_file="$PLANE_INSTALL_DIR/.env"
update_env_files "$config_file" "$1" "$2"
update_env_file $config_file $1 $2
}
function read_env() {
config_file="$PLANE_INSTALL_DIR/.env"
read_env_file "$config_file" "$1"
read_env_file $config_file $1
}
function show_message() {
print_header
@@ -87,14 +89,14 @@ function prepare_environment() {
show_message "Prepare Environment..." >&2
show_message "- Updating OS with required tools ✋" >&2
sudo apt-get update -y &> /dev/null
sudo apt-get upgrade -y &> /dev/null
sudo "$PACKAGE_MANAGER" update -y
sudo "$PACKAGE_MANAGER" upgrade -y
required_tools=("curl" "awk" "wget" "nano" "dialog" "git")
local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap")
for tool in "${required_tools[@]}"; do
if ! command -v $tool &> /dev/null; then
sudo apt install -y $tool &> /dev/null
sudo "$PACKAGE_MANAGER" install -y $tool
fi
done
@@ -103,11 +105,30 @@ function prepare_environment() {
# Install Docker if not installed
if ! command -v docker &> /dev/null; then
show_message "- Installing Docker ✋" >&2
sudo curl -o- https://get.docker.com | bash -
# curl -o- https://get.docker.com | bash -
if [ "$EUID" -ne 0 ]; then
dockerd-rootless-setuptool.sh install &> /dev/null
if [ "$PACKAGE_MANAGER" == "yum" ]; then
sudo $PACKAGE_MANAGER install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo &> /dev/null
elif [ "$PACKAGE_MANAGER" == "apt-get" ]; then
# Add Docker's official GPG key:
sudo $PACKAGE_MANAGER update
sudo $PACKAGE_MANAGER install ca-certificates curl &> /dev/null
sudo install -m 0755 -d /etc/apt/keyrings &> /dev/null
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc &> /dev/null
sudo chmod a+r /etc/apt/keyrings/docker.asc &> /dev/null
# Add the repository to Apt sources:
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo $PACKAGE_MANAGER update
fi
sudo $PACKAGE_MANAGER install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
show_message "- Docker Installed ✅" "replace_last_line" >&2
else
show_message "- Docker is already installed ✅" >&2
@@ -127,17 +148,17 @@ function prepare_environment() {
function download_plane() {
# Download Docker Compose File from github url
show_message "Downloading Plane Setup Files ✋" >&2
curl -H 'Cache-Control: no-cache, no-store' \
sudo curl -H 'Cache-Control: no-cache, no-store' \
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s)
curl -H 'Cache-Control: no-cache, no-store' \
sudo curl -H 'Cache-Control: no-cache, no-store' \
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s)
# if .env does not exists rename variables-upgrade.env to .env
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
sudo mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
fi
show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2
@@ -186,7 +207,7 @@ function build_local_image() {
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch -q > /dev/null
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $DEPLOY_BRANCH --single-branch -q > /dev/null
sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml
@@ -199,25 +220,26 @@ function check_for_docker_images() {
show_message "" >&2
# show_message "Building Plane Images" >&2
update_env "DOCKERHUB_USER" "makeplane"
update_env "PULL_POLICY" "always"
CURR_DIR=$(pwd)
if [ "$BRANCH" == "master" ]; then
if [ "$DEPLOY_BRANCH" == "master" ]; then
update_env "APP_RELEASE" "latest"
export APP_RELEASE=latest
else
update_env "APP_RELEASE" "$BRANCH"
export APP_RELEASE=$BRANCH
update_env "APP_RELEASE" "$DEPLOY_BRANCH"
export APP_RELEASE=$DEPLOY_BRANCH
fi
if [ $CPU_ARCH == "amd64" ] || [ $CPU_ARCH == "x86_64" ]; then
if [ $USE_GLOBAL_IMAGES == 1 ]; then
# show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2
export DOCKERHUB_USER=makeplane
update_env "DOCKERHUB_USER" "$DOCKERHUB_USER"
update_env "PULL_POLICY" "always"
echo "Building Plane Images for $CPU_ARCH is not required. Skipping..."
else
export DOCKERHUB_USER=myplane
show_message "Building Plane Images for $CPU_ARCH " >&2
update_env "DOCKERHUB_USER" "myplane"
update_env "DOCKERHUB_USER" "$DOCKERHUB_USER"
update_env "PULL_POLICY" "never"
build_local_image
@@ -233,7 +255,7 @@ function check_for_docker_images() {
sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
sudo docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
show_message "Plane Images Downloaded ✅" "replace_last_line" >&2
}
function configure_plane() {
@@ -453,9 +475,11 @@ function install() {
show_message ""
if [ "$(uname)" == "Linux" ]; then
OS="linux"
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
# check the OS
if [ "$OS_NAME" == "ubuntu" ]; then
OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
print_header
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
OS_SUPPORTED=true
show_message "******** Installing Plane ********"
show_message ""
@@ -488,7 +512,8 @@ function install() {
fi
else
PROGRESS_MSG="❌❌❌ Unsupported OS Detected ❌❌❌"
OS_SUPPORTED=false
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
show_message ""
exit 1
fi
@@ -499,12 +524,17 @@ function install() {
fi
}
function upgrade() {
print_header
if [ "$(uname)" == "Linux" ]; then
OS="linux"
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
# check the OS
if [ "$OS_NAME" == "ubuntu" ]; then
OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
OS_SUPPORTED=true
show_message "******** Upgrading Plane ********"
show_message ""
prepare_environment
@@ -528,53 +558,49 @@ function upgrade() {
exit 1
fi
else
PROGRESS_MSG="Unsupported OS Detected"
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
show_message ""
exit 1
fi
else
PROGRESS_MSG="Unsupported OS Detected : $(uname)"
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
show_message ""
exit 1
fi
}
function uninstall() {
print_header
if [ "$(uname)" == "Linux" ]; then
OS="linux"
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
# check the OS
if [ "$OS_NAME" == "ubuntu" ]; then
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
OS_SUPPORTED=true
show_message "******** Uninstalling Plane ********"
show_message ""
stop_server
# CHECK IF PLANE SERVICE EXISTS
# if [ -f "/etc/systemd/system/plane.service" ]; then
# sudo systemctl stop plane.service &> /dev/null
# sudo systemctl disable plane.service &> /dev/null
# sudo rm /etc/systemd/system/plane.service &> /dev/null
# sudo systemctl daemon-reload &> /dev/null
# fi
# show_message "- Plane Service removed ✅"
if ! [ -x "$(command -v docker)" ]; then
echo "DOCKER_NOT_INSTALLED" &> /dev/null
else
# Ask of user input to confirm uninstall docker ?
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --defaultno --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
if [ $? -eq 0 ]; then
show_message "- Uninstalling Docker ✋"
sudo apt-get purge -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
sudo apt-get autoremove -y --purge docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
sudo docker images -q | xargs -r sudo docker rmi -f &> /dev/null
sudo "$PACKAGE_MANAGER" remove -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
sudo "$PACKAGE_MANAGER" autoremove -y docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
show_message "- Docker Uninstalled ✅" "replace_last_line" >&2
fi
fi
rm $PLANE_INSTALL_DIR/.env &> /dev/null
rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
rm $PLANE_INSTALL_DIR/config.env &> /dev/null
rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
sudo rm $PLANE_INSTALL_DIR/.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
show_message "- Configuration Cleaned ✅"
@@ -593,12 +619,12 @@ function uninstall() {
show_message ""
show_message ""
else
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
show_message ""
exit 1
fi
else
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
show_message ""
exit 1
fi
@@ -608,15 +634,15 @@ function start_server() {
env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Starting Plane Server ✋"
docker compose -f $docker_compose_file --env-file=$env_file up -d
show_message "Starting Plane Server ($APP_RELEASE) ✋"
sudo docker compose -f $docker_compose_file --env-file=$env_file up -d
# Wait for containers to be running
echo "Waiting for containers to start..."
while ! docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
sleep 1
done
show_message "Plane Server Started ✅" "replace_last_line" >&2
show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi
@@ -626,11 +652,11 @@ function stop_server() {
env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Stopping Plane Server ✋"
docker compose -f $docker_compose_file --env-file=$env_file down
show_message "Plane Server Stopped ✅" "replace_last_line" >&2
show_message "Stopping Plane Server ($APP_RELEASE) ✋"
sudo docker compose -f $docker_compose_file --env-file=$env_file down
show_message "Plane Server Stopped ($APP_RELEASE) ✅" "replace_last_line" >&2
else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
show_message "Plane Server not installed [Skipping] ✅" "replace_last_line" >&2
fi
}
function restart_server() {
@@ -638,9 +664,9 @@ function restart_server() {
env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Restarting Plane Server ✋"
docker compose -f $docker_compose_file --env-file=$env_file restart
show_message "Plane Server Restarted ✅" "replace_last_line" >&2
show_message "Restarting Plane Server ($APP_RELEASE) ✋"
sudo docker compose -f $docker_compose_file --env-file=$env_file restart
show_message "Plane Server Restarted ($APP_RELEASE) ✅" "replace_last_line" >&2
else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi
@@ -666,28 +692,45 @@ function show_help() {
}
function update_installer() {
show_message "Updating Plane Installer ✋" >&2
curl -H 'Cache-Control: no-cache, no-store' \
sudo curl -H 'Cache-Control: no-cache, no-store' \
-s -o /usr/local/bin/plane-app \
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/1-click/install.sh?token=$(date +%s)
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s)
chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
}
export BRANCH=${BRANCH:-master}
export APP_RELEASE=$BRANCH
export DEPLOY_BRANCH=${BRANCH:-master}
export APP_RELEASE=$DEPLOY_BRANCH
export DOCKERHUB_USER=makeplane
export PULL_POLICY=always
if [ "$DEPLOY_BRANCH" == "master" ]; then
export APP_RELEASE=latest
fi
PLANE_INSTALL_DIR=/opt/plane
DATA_DIR=$PLANE_INSTALL_DIR/data
LOG_DIR=$PLANE_INSTALL_DIR/log
OS_SUPPORTED=false
CPU_ARCH=$(uname -m)
PROGRESS_MSG=""
USE_GLOBAL_IMAGES=1
USE_GLOBAL_IMAGES=0
PACKAGE_MANAGER=""
mkdir -p $PLANE_INSTALL_DIR/{data,log}
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then
USE_GLOBAL_IMAGES=1
fi
sudo mkdir -p $PLANE_INSTALL_DIR/{data,log}
if command -v apt-get &> /dev/null; then
PACKAGE_MANAGER="apt-get"
elif command -v yum &> /dev/null; then
PACKAGE_MANAGER="yum"
elif command -v apk &> /dev/null; then
PACKAGE_MANAGER="apk"
fi
if [ "$1" == "start" ]; then
start_server
@@ -704,7 +747,7 @@ elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then
upgrade
elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then
uninstall
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ] ; then
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ]; then
update_installer
elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_help
-4
View File
@@ -38,10 +38,6 @@ x-app-env : &app-env
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
# OPENAI SETTINGS - Deprecated can be configured through admin panel
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-""}
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"}
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
+11 -6
View File
@@ -20,8 +20,8 @@ function buildLocalImage() {
DO_BUILD="2"
else
printf "\n" >&2
printf "${YELLOW}You are on ${ARCH} cpu architecture. ${NC}\n" >&2
printf "${YELLOW}Since the prebuilt ${ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2
printf "${YELLOW}You are on ${CPU_ARCH} cpu architecture. ${NC}\n" >&2
printf "${YELLOW}Since the prebuilt ${CPU_ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2
printf "${YELLOW}This might take ${YELLOW}5-30 min based on your system's hardware configuration. \n ${NC} \n" >&2
printf "\n" >&2
printf "${GREEN}Select an option to proceed: ${NC}\n" >&2
@@ -49,7 +49,7 @@ function buildLocalImage() {
cd $PLANE_TEMP_CODE_DIR
if [ "$BRANCH" == "master" ];
then
APP_RELEASE=latest
export APP_RELEASE=latest
fi
docker compose -f build.yml build --no-cache >&2
@@ -149,7 +149,7 @@ function upgrade() {
function askForAction() {
echo
echo "Select a Action you want to perform:"
echo " 1) Install (${ARCH})"
echo " 1) Install (${CPU_ARCH})"
echo " 2) Start"
echo " 3) Stop"
echo " 4) Restart"
@@ -193,8 +193,8 @@ function askForAction() {
}
# CPU ARCHITECHTURE BASED SETTINGS
ARCH=$(uname -m)
if [ $ARCH == "amd64" ] || [ $ARCH == "x86_64" ];
CPU_ARCH=$(uname -m)
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]];
then
USE_GLOBAL_IMAGES=1
DOCKERHUB_USER=makeplane
@@ -205,6 +205,11 @@ else
PULL_POLICY=never
fi
if [ "$BRANCH" == "master" ];
then
export APP_RELEASE=latest
fi
# REMOVE SPECIAL CHARACTERS FROM BRANCH NAME
if [ "$BRANCH" != "master" ];
then
+15 -20
View File
@@ -8,13 +8,13 @@ NGINX_PORT=80
WEB_URL=http://localhost
DEBUG=0
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
SENTRY_DSN=""
SENTRY_ENVIRONMENT="production"
GOOGLE_CLIENT_ID=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
GOOGLE_CLIENT_ID=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
DOCKERIZED=1 # deprecated
CORS_ALLOWED_ORIGINS="http://localhost"
CORS_ALLOWED_ORIGINS=http://localhost
#DB SETTINGS
PGHOST=plane-db
@@ -31,19 +31,14 @@ REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:6379/
# EMAIL SETTINGS
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_HOST=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_FROM=Team Plane <team@mailer.plane.so>
EMAIL_USE_TLS=1
EMAIL_USE_SSL=0
# OPENAI SETTINGS
OPENAI_API_BASE=https://api.openai.com/v1 # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# LOGIN/SIGNUP SETTINGS
ENABLE_SIGNUP=1
ENABLE_EMAIL_PASSWORD=1
@@ -52,13 +47,13 @@ SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
# DATA STORE SETTINGS
USE_MINIO=1
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_REGION=
AWS_ACCESS_KEY_ID=access-key
AWS_SECRET_ACCESS_KEY=secret-key
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads
MINIO_ROOT_USER="access-key"
MINIO_ROOT_PASSWORD="secret-key"
MINIO_ROOT_USER=access-key
MINIO_ROOT_PASSWORD=secret-key
BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
+1 -1
View File
@@ -137,7 +137,7 @@ services:
dockerfile: Dockerfile.dev
args:
DOCKER_BUILDKIT: 1
restart: no
restart: "no"
networks:
- dev_env
volumes:
+2 -2
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.15.0",
"version": "0.15.1",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -34,4 +34,4 @@
"@types/react": "18.2.42"
},
"packageManager": "yarn@1.22.19"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor-core",
"version": "0.15.0",
"version": "0.15.1",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/document-editor",
"version": "0.15.0",
"version": "0.15.1",
"description": "Package that powers Plane's Pages Editor",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
@@ -6,10 +6,16 @@ import { scrollSummary } from "src/utils/editor-summary-utils";
interface ContentBrowserProps {
editor: Editor;
markings: IMarking[];
setSidePeekVisible?: (sidePeekState: boolean) => void;
}
export const ContentBrowser = (props: ContentBrowserProps) => {
const { editor, markings } = props;
const { editor, markings, setSidePeekVisible } = props;
const handleOnClick = (marking: IMarking) => {
scrollSummary(editor, marking);
if (setSidePeekVisible) setSidePeekVisible(false);
}
return (
<div className="flex h-full flex-col overflow-hidden">
@@ -18,11 +24,11 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
{markings.length !== 0 ? (
markings.map((marking) =>
marking.level === 1 ? (
<HeadingComp onClick={() => scrollSummary(editor, marking)} heading={marking.text} />
<HeadingComp onClick={() => handleOnClick(marking)} heading={marking.text} />
) : marking.level === 2 ? (
<SubheadingComp onClick={() => scrollSummary(editor, marking)} subHeading={marking.text} />
<SubheadingComp onClick={() => handleOnClick(marking)} subHeading={marking.text} />
) : (
<HeadingThreeComp heading={marking.text} onClick={() => scrollSummary(editor, marking)} />
<HeadingThreeComp heading={marking.text} onClick={() => handleOnClick(marking)} />
)
)
) : (
@@ -42,8 +42,8 @@ export const EditorHeader = (props: IEditorHeader) => {
} = props;
return (
<div className="flex items-center border-b border-custom-border-200 px-5 py-2">
<div className="w-56 flex-shrink-0 lg:w-72">
<div className="flex items-center border-b border-custom-border-200 md:px-5 px-3 py-2">
<div className="md:w-56 flex-shrink-0 lg:w-72 w-fit">
<SummaryPopover
editor={editor}
markings={markings}
@@ -52,7 +52,7 @@ export const EditorHeader = (props: IEditorHeader) => {
/>
</div>
<div className="flex-shrink-0">
<div className="flex-shrink-0 hidden md:flex">
{!readonly && uploadFile && (
<FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />
)}
@@ -152,7 +152,7 @@ export const PageRenderer = (props: IPageRenderer) => {
);
return (
<div className="w-full pb-64 pl-7 pt-5 page-renderer">
<div className="w-full pb-64 md:pl-7 pl-3 pt-5 page-renderer">
{!readonly ? (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
@@ -33,23 +33,36 @@ export const SummaryPopover: React.FC<Props> = (props) => {
<button
type="button"
ref={setReferenceElement}
className={`grid h-7 w-7 place-items-center rounded ${
sidePeekVisible ? "bg-custom-primary-100/20 text-custom-primary-100" : "text-custom-text-300"
}`}
className={`grid h-7 w-7 place-items-center rounded ${sidePeekVisible ? "bg-custom-primary-100/20 text-custom-primary-100" : "text-custom-text-300"
}`}
onClick={() => setSidePeekVisible(!sidePeekVisible)}
>
<List className="h-4 w-4" />
</button>
{!sidePeekVisible && (
<div
className="z-10 hidden max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg group-hover/summary-popover:block"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<ContentBrowser editor={editor} markings={markings} />
</div>
)}
<div className="md:hidden block">
{sidePeekVisible && (
<div
className="z-10 max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<ContentBrowser setSidePeekVisible={setSidePeekVisible} editor={editor} markings={markings} />
</div>
)}
</div>
<div className="hidden md:block">
{!sidePeekVisible && (
<div
className="z-10 hidden max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg group-hover/summary-popover:block"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<ContentBrowser editor={editor} markings={markings} />
</div>
)}
</div>
</div>
);
};
@@ -10,6 +10,7 @@ import { DocumentDetails } from "src/types/editor-types";
import { PageRenderer } from "src/ui/components/page-renderer";
import { getMenuOptions } from "src/utils/menu-options";
import { useRouter } from "next/router";
import { FixedMenu } from "src";
interface IDocumentEditor {
// document info
@@ -149,11 +150,14 @@ const DocumentEditor = ({
documentDetails={documentDetails}
isSubmitting={isSubmitting}
/>
<div className="flex-shrink-0 md:hidden border-b border-custom-border-200 pl-3 py-2">
{uploadFile && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
</div>
<div className="flex h-full w-full overflow-y-auto frame-renderer">
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72">
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72 hidden md:block">
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
</div>
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer
onActionCompleteHandler={onActionCompleteHandler}
hideDragHandle={hideDragHandleOnMouseLeave}
@@ -77,7 +77,7 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
}
return (
<div className="flex items-center divide-x divide-custom-border-200">
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
<div className="flex items-center gap-0.5 pr-2">
{basicMarkItems.map((item) => (
<button
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor-extensions",
"version": "0.15.0",
"version": "0.15.1",
"description": "Package that powers Plane's Editor with extensions",
"private": true,
"main": "./dist/index.mjs",
@@ -1,6 +1,6 @@
{
"name": "@plane/lite-text-editor",
"version": "0.15.0",
"version": "0.15.1",
"description": "Package that powers Plane's Comment Editor",
"private": true,
"main": "./dist/index.mjs",
@@ -1,6 +1,6 @@
{
"name": "@plane/rich-text-editor",
"version": "0.15.0",
"version": "0.15.1",
"description": "Rich Text Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "eslint-config-custom",
"private": true,
"version": "0.15.0",
"version": "0.15.1",
"main": "index.js",
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.15.0",
"version": "0.15.1",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tsconfig",
"version": "0.15.0",
"version": "0.15.1",
"private": true,
"files": [
"base.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.15.0",
"version": "0.15.1",
"private": true,
"main": "./src/index.d.ts"
}
+18
View File
@@ -31,6 +31,7 @@ export interface ICycle {
issue: string;
name: string;
owned_by: string;
progress_snapshot: TProgressSnapshot;
project: string;
project_detail: IProjectLite;
status: TCycleGroups;
@@ -49,6 +50,23 @@ export interface ICycle {
workspace_detail: IWorkspaceLite;
}
export type TProgressSnapshot = {
backlog_issues: number;
cancelled_issues: number;
completed_estimates: number | null;
completed_issues: number;
distribution?: {
assignees: TAssigneesDistribution[];
completion_chart: TCompletionChartDistribution;
labels: TLabelsDistribution[];
};
started_estimates: number | null;
started_issues: number;
total_estimates: number | null;
total_issues: number;
unstarted_issues: number;
};
export type TAssigneesDistribution = {
assignee_id: string | null;
avatar: string | null;
+6 -5
View File
@@ -13,9 +13,10 @@ export type TWidgetKeys =
| "recent_projects"
| "recent_collaborators";
export type TIssuesListTypes = "upcoming" | "overdue" | "completed";
export type TIssuesListTypes = "pending" | "upcoming" | "overdue" | "completed";
export type TDurationFilterOptions =
| "none"
| "today"
| "this_week"
| "this_month"
@@ -23,21 +24,21 @@ export type TDurationFilterOptions =
// widget filters
export type TAssignedIssuesWidgetFilters = {
target_date?: TDurationFilterOptions;
duration?: TDurationFilterOptions;
tab?: TIssuesListTypes;
};
export type TCreatedIssuesWidgetFilters = {
target_date?: TDurationFilterOptions;
duration?: TDurationFilterOptions;
tab?: TIssuesListTypes;
};
export type TIssuesByStateGroupsWidgetFilters = {
target_date?: TDurationFilterOptions;
duration?: TDurationFilterOptions;
};
export type TIssuesByPriorityWidgetFilters = {
target_date?: TDurationFilterOptions;
duration?: TDurationFilterOptions;
};
export type TWidgetFiltersFormData =
+9
View File
@@ -221,3 +221,12 @@ export interface IGroupByColumn {
export interface IIssueMap {
[key: string]: TIssue;
}
export interface IIssueListRow {
id: string;
groupId: string;
type: "HEADER" | "NO_ISSUES" | "QUICK_ADD" | "ISSUE";
name?: string;
icon?: ReactElement | undefined;
payload?: Partial<TIssue>;
}
+8 -2
View File
@@ -1,5 +1,11 @@
import { EUserProjectRoles } from "constants/project";
import type { IUser, IUserLite, IWorkspace, IWorkspaceLite, TStateGroups } from ".";
import type {
IUser,
IUserLite,
IWorkspace,
IWorkspaceLite,
TStateGroups,
} from ".";
export interface IProject {
archive_in: number;
@@ -117,7 +123,7 @@ export type TProjectIssuesSearchParams = {
parent?: boolean;
issue_relation?: boolean;
cycle?: boolean;
module?: string[];
module?: string;
sub_issue?: boolean;
issue_id?: string;
workspace_search: boolean;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.15.0",
"version": "0.15.1",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
+55 -17
View File
@@ -1,35 +1,73 @@
import * as React from "react";
// icons
import { ChevronRight } from "lucide-react";
type BreadcrumbsProps = {
children: any;
children: React.ReactNode;
onBack?: () => void;
};
const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
<div className="flex items-center space-x-2">
{React.Children.map(children, (child, index) => (
<div key={index} className="flex flex-wrap items-center gap-2.5">
{child}
{index !== React.Children.count(children) - 1 && (
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
)}
</div>
))}
</div>
);
const Breadcrumbs = ({ children, onBack }: BreadcrumbsProps) => {
const [isSmallScreen, setIsSmallScreen] = React.useState(false);
React.useEffect(() => {
const handleResize = () => {
setIsSmallScreen(window.innerWidth <= 640); // Adjust this value as per your requirement
};
window.addEventListener("resize", handleResize);
handleResize(); // Call it initially to set the correct state
return () => window.removeEventListener("resize", handleResize);
}, []);
const childrenArray = React.Children.toArray(children);
return (
<div className="flex items-center space-x-2 overflow-hidden">
{!isSmallScreen && (
<>
{childrenArray.map((child, index) => (
<React.Fragment key={index}>
{index > 0 && !isSmallScreen && (
<div className="flex items-center gap-2.5">
<ChevronRight
className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400"
aria-hidden="true"
/>
</div>
)}
<div className={`flex items-center gap-2.5 ${isSmallScreen && index > 0 ? 'hidden sm:flex' : 'flex'}`}>
{child}
</div>
</React.Fragment>
))}
</>
)}
{isSmallScreen && childrenArray.length > 1 && (
<>
<div className="flex items-center gap-2.5">
{onBack && <span onClick={onBack} className="text-custom-text-200">...</span>}
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
</div>
<div className="flex items-center gap-2.5">{childrenArray[childrenArray.length - 1]}</div>
</>
)}
{isSmallScreen && childrenArray.length === 1 && childrenArray}
</div>
);
};
type Props = {
type?: "text" | "component";
component?: React.ReactNode;
link?: JSX.Element;
};
const BreadcrumbItem: React.FC<Props> = (props) => {
const { type = "text", component, link } = props;
return <>{type != "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
return <>{type !== "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
};
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
export { Breadcrumbs, BreadcrumbItem };
export { Breadcrumbs, BreadcrumbItem };
+33 -13
View File
@@ -15,6 +15,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
const {
buttonClassName = "",
customButtonClassName = "",
customButtonTabIndex = 0,
placement,
children,
className = "",
@@ -29,6 +30,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
verticalEllipsis = false,
portalElement,
menuButtonOnClick,
onMenuClose,
tabIndex,
closeOnSelect,
} = props;
@@ -47,18 +49,28 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
const closeDropdown = () => {
isOpen && onMenuClose && onMenuClose();
setIsOpen(false);
};
const selectActiveItem = () => {
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
`[data-headlessui-state="active"] button`
);
activeItem?.click();
};
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem);
const handleOnClick = () => {
if (closeOnSelect) closeDropdown();
};
useOutsideClickDetector(dropdownRef, closeDropdown);
let menuItems = (
<Menu.Items
className="fixed z-10"
onClick={() => {
if (closeOnSelect) closeDropdown();
}}
static
>
<Menu.Items className="fixed z-10" static>
<div
className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
@@ -89,7 +101,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("relative w-min text-left", className)}
onKeyDown={handleKeyDown}
onKeyDownCapture={handleKeyDown}
onClick={handleOnClick}
>
{({ open }) => (
<>
@@ -98,11 +111,13 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button
ref={setReferenceElement}
type="button"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
className={customButtonClassName}
tabIndex={customButtonTabIndex}
>
{customButton}
</button>
@@ -114,7 +129,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button
ref={setReferenceElement}
type="button"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
@@ -122,6 +138,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
tabIndex={customButtonTabIndex}
>
<MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
</button>
@@ -138,10 +155,12 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={() => {
onClick={(e) => {
e.stopPropagation();
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
tabIndex={customButtonTabIndex}
>
{label}
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
@@ -159,6 +178,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
const { children, onClick, className = "" } = props;
return (
<Menu.Item as="div">
{({ active, close }) => (
+2
View File
@@ -3,6 +3,7 @@ import { Placement } from "@blueprintjs/popover2";
export interface IDropdownProps {
customButtonClassName?: string;
customButtonTabIndex?: number;
buttonClassName?: string;
className?: string;
customButton?: JSX.Element;
@@ -23,6 +24,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps {
noBorder?: boolean;
verticalEllipsis?: boolean;
menuButtonOnClick?: (...args: any) => void;
onMenuClose?: () => void;
closeOnSelect?: boolean;
portalElement?: Element | null;
}
+67
View File
@@ -0,0 +1,67 @@
import * as React from "react";
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
intermediate?: boolean;
className?: string;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref) => {
const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props;
return (
<div className={`relative w-full flex gap-2 ${className}`}>
<input
id={id}
ref={ref}
type="checkbox"
name={name}
checked={checked}
className={`
appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50
${
disabled
? "border-custom-border-200 bg-custom-background-80 cursor-not-allowed"
: `cursor-pointer ${
checked || intermediate
? "border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200"
: "border-custom-border-300 hover:border-custom-border-400 bg-white"
}`
}
`}
disabled={disabled}
{...rest}
/>
<svg
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
} ${checked ? "block" : "hidden"}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<svg
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
} ${intermediate && !checked ? "block" : "hidden"}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5.75 4H2.25" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
);
});
Checkbox.displayName = "form-checkbox-field";
export { Checkbox };
+1
View File
@@ -1,3 +1,4 @@
export * from "./input";
export * from "./textarea";
export * from "./input-color-picker";
export * from "./checkbox";
@@ -1,16 +1,23 @@
import { useCallback } from "react";
type TUseDropdownKeyDown = {
(onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent<HTMLElement>) => void;
(
onOpen: () => void,
onClose: () => void,
isOpen: boolean,
selectActiveItem?: () => void
): (event: React.KeyboardEvent<HTMLElement>) => void;
};
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => {
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen, selectActiveItem?) => {
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") {
event.stopPropagation();
if (!isOpen) {
event.stopPropagation();
onOpen();
} else {
selectActiveItem && selectActiveItem();
}
} else if (event.key === "Escape" && isOpen) {
event.stopPropagation();
-1
View File
@@ -47,7 +47,6 @@ export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
>
<Icon
size={size}
viewBox="0 0 23.5 24"
className={cn(
{
"text-white": priority === "urgent",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.15.0",
"version": "0.15.1",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form";
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// icons
import { Eye, EyeOff } from "lucide-react";
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED } from "constants/event-tracker";
type Props = {
email: string;
@@ -34,6 +36,8 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
// states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
// form info
@@ -63,21 +67,34 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
title: "Success!",
message: "Password created successfully.",
});
captureEvent(PASSWORD_CREATE_SELECTED, {
state: "SUCCESS",
first_time: false,
});
await handleSignInRedirection();
})
.catch((err) =>
.catch((err) => {
captureEvent(PASSWORD_CREATE_SELECTED, {
state: "FAILED",
first_time: false,
});
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
});
});
};
const handleGoToWorkspace = async () => {
setIsGoingToWorkspace(true);
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
await handleSignInRedirection().finally(() => {
captureEvent(PASSWORD_CREATE_SKIPPED, {
state: "SUCCESS",
first_time: false,
});
setIsGoingToWorkspace(false);
});
};
return (
@@ -7,7 +7,7 @@ import { Eye, EyeOff, XCircle } from "lucide-react";
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import { useApplication } from "hooks/store";
import { useApplication, useEventTracker } from "hooks/store";
// components
import { ESignInSteps, ForgotPasswordPopover } from "components/account";
// ui
@@ -16,6 +16,8 @@ import { Button, Input } from "@plane/ui";
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IPasswordSignInData } from "@plane/types";
// constants
import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker";
type Props = {
email: string;
@@ -46,6 +48,7 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
const {
config: { envConfig },
} = useApplication();
const { captureEvent } = useEventTracker();
// derived values
const isSmtpConfigured = envConfig?.is_smtp_configured;
// form info
@@ -72,7 +75,13 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
await authService
.passwordSignIn(payload)
.then(async () => await onSubmit())
.then(async () => {
captureEvent(SIGN_IN_WITH_PASSWORD, {
state: "SUCCESS",
first_time: false,
});
await onSubmit();
})
.catch((err) =>
setToastAlert({
type: "error",
@@ -182,9 +191,10 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
</div>
)}
/>
<div className="w-full text-right mt-2 pb-3">
<div className="mt-2 w-full pb-3 text-right">
{isSmtpConfigured ? (
<Link
onClick={() => captureEvent(FORGOT_PASSWORD)}
href={`/accounts/forgot-password?email=${email}`}
className="text-xs font-medium text-custom-primary-100"
>
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import Link from "next/link";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication } from "hooks/store";
import { useApplication, useEventTracker } from "hooks/store";
import useSignInRedirection from "hooks/use-sign-in-redirection";
// components
import { LatestFeatureBlock } from "components/common";
@@ -13,6 +13,8 @@ import {
OAuthOptions,
SignInOptionalSetPasswordForm,
} from "components/account";
// constants
import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker";
export enum ESignInSteps {
EMAIL = "EMAIL",
@@ -32,6 +34,7 @@ export const SignInRoot = observer(() => {
const {
config: { envConfig },
} = useApplication();
const { captureEvent } = useEventTracker();
// derived values
const isSmtpConfigured = envConfig?.is_smtp_configured;
@@ -110,7 +113,11 @@ export const SignInRoot = observer(() => {
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" />
<p className="text-xs text-onboarding-text-300 text-center mt-6">
Don{"'"}t have an account?{" "}
<Link href="/accounts/sign-up" className="text-custom-primary-100 font-medium underline">
<Link
href="/accounts/sign-up"
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
className="text-custom-primary-100 font-medium underline"
>
Sign up
</Link>
</p>
@@ -7,12 +7,15 @@ import { UserService } from "services/user.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
// constants
import { CODE_VERIFIED } from "constants/event-tracker";
type Props = {
email: string;
@@ -41,6 +44,8 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
// toast alert
const { setToastAlert } = useToast();
// store hooks
const { captureEvent } = useEventTracker();
// timer
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
// form info
@@ -69,17 +74,22 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
await authService
.magicSignIn(payload)
.then(async () => {
captureEvent(CODE_VERIFIED, {
state: "SUCCESS",
});
const currentUser = await userService.currentUser();
await onSubmit(currentUser.is_password_autoset);
})
.catch((err) =>
.catch((err) => {
captureEvent(CODE_VERIFIED, {
state: "FAILED",
});
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
});
});
};
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
@@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form";
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// constants
import { ESignUpSteps } from "components/account";
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker";
// icons
import { Eye, EyeOff } from "lucide-react";
@@ -37,6 +39,8 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
// states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
// form info
@@ -66,21 +70,34 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
title: "Success!",
message: "Password created successfully.",
});
captureEvent(SETUP_PASSWORD, {
state: "SUCCESS",
first_time: true,
});
await handleSignInRedirection();
})
.catch((err) =>
.catch((err) => {
captureEvent(SETUP_PASSWORD, {
state: "FAILED",
first_time: true,
});
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
});
});
};
const handleGoToWorkspace = async () => {
setIsGoingToWorkspace(true);
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
await handleSignInRedirection().finally(() => {
captureEvent(PASSWORD_CREATE_SKIPPED, {
state: "SUCCESS",
first_time: true,
});
setIsGoingToWorkspace(false);
});
};
return (
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication } from "hooks/store";
import { useApplication, useEventTracker } from "hooks/store";
import useSignInRedirection from "hooks/use-sign-in-redirection";
// components
import {
@@ -12,6 +12,8 @@ import {
SignUpUniqueCodeForm,
} from "components/account";
import Link from "next/link";
// constants
import { NAVIGATE_TO_SIGNIN } from "constants/event-tracker";
export enum ESignUpSteps {
EMAIL = "EMAIL",
@@ -32,6 +34,7 @@ export const SignUpRoot = observer(() => {
const {
config: { envConfig },
} = useApplication();
const { captureEvent } = useEventTracker();
// step 1 submit handler- email verification
const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE);
@@ -86,7 +89,11 @@ export const SignUpRoot = observer(() => {
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_up" />
<p className="text-xs text-onboarding-text-300 text-center mt-6">
Already using Plane?{" "}
<Link href="/" className="text-custom-primary-100 font-medium underline">
<Link
href="/"
onClick={() => captureEvent(NAVIGATE_TO_SIGNIN, {})}
className="text-custom-primary-100 font-medium underline"
>
Sign in
</Link>
</p>
@@ -8,12 +8,15 @@ import { UserService } from "services/user.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
// constants
import { CODE_VERIFIED } from "constants/event-tracker";
type Props = {
email: string;
@@ -39,6 +42,8 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
const { email, handleEmailClear, onSubmit } = props;
// states
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
// timer
@@ -69,17 +74,22 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
await authService
.magicSignIn(payload)
.then(async () => {
captureEvent(CODE_VERIFIED, {
state: "SUCCESS",
});
const currentUser = await userService.currentUser();
await onSubmit(currentUser.is_password_autoset);
})
.catch((err) =>
.catch((err) => {
captureEvent(CODE_VERIFIED, {
state: "FAILED",
});
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
});
});
};
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
@@ -96,7 +106,6 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
title: "Success!",
message: "A new unique code has been sent to your email.",
});
reset({
email: formData.email,
token: "",
@@ -10,6 +10,8 @@ import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSi
import { IAnalyticsParams } from "@plane/types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
import { cn } from "helpers/common.helper";
import { useApplication } from "hooks/store";
type Props = {
additionalParams?: Partial<IAnalyticsParams>;
@@ -46,11 +48,13 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
const { theme: themeStore } = useApplication();
const isProjectLevel = projectId ? true : false;
return (
<div className={`flex flex-col-reverse overflow-hidden ${fullScreen ? "md:grid md:h-full md:grid-cols-4" : ""}`}>
<div className="col-span-3 flex h-full flex-col overflow-hidden">
<div className={cn("relative w-full h-full flex overflow-hidden", isProjectLevel ? "flex-col-reverse" : "")}>
<div className="w-full flex h-full flex-col overflow-hidden">
<CustomAnalyticsSelectBar
control={control}
setValue={setValue}
@@ -61,16 +65,22 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
<CustomAnalyticsMainContent
analytics={analytics}
error={analyticsError}
fullScreen={fullScreen}
params={params}
fullScreen={fullScreen}
/>
</div>
<CustomAnalyticsSidebar
analytics={analytics}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
<div
className={cn(
"border-l border-custom-border-200 transition-all",
!isProjectLevel
? "absolute right-0 top-0 bottom-0 md:relative flex-shrink-0 h-full max-w-[250px] sm:max-w-full"
: ""
)}
style={themeStore.workspaceAnalyticsSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
>
<CustomAnalyticsSidebar analytics={analytics} params={params} isProjectLevel={isProjectLevel} />
</div>
</div>
);
});
@@ -22,9 +22,8 @@ export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
return (
<div
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-3" : "grid-cols-2"} ${
fullScreen ? "md:py-5 lg:grid-cols-4" : ""
}`}
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-1 sm:grid-cols-3" : "grid-cols-2"} ${fullScreen ? "md:py-5 lg:grid-cols-4" : ""
}`}
>
{!isProjectLevel && (
<div>
@@ -17,9 +17,9 @@ export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((pro
const { getProjectById } = useProject();
return (
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
<div className="relative flex flex-col gap-4 h-full">
<h4 className="font-medium">Selected Projects</h4>
<div className="mt-4 h-full space-y-6 overflow-y-auto">
<div className="relative space-y-6 overflow-hidden overflow-y-auto">
{projectIds.map((projectId) => {
const project = getProjectById(projectId);
@@ -26,7 +26,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
<>
{projectId ? (
cycleDetails ? (
<div className="hidden h-full overflow-y-auto md:block">
<div className="h-full overflow-y-auto">
<h4 className="break-words font-medium">Analytics for {cycleDetails.name}</h4>
<div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs">
@@ -52,7 +52,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
</div>
</div>
) : moduleDetails ? (
<div className="hidden h-full overflow-y-auto md:block">
<div className="h-full overflow-y-auto">
<h4 className="break-words font-medium">Analytics for {moduleDetails.name}</h4>
<div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs">
@@ -78,7 +78,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
</div>
</div>
) : (
<div className="hidden h-full overflow-y-auto md:flex md:flex-col">
<div className="h-full overflow-y-auto">
<div className="flex items-center gap-1">
{projectDetails?.emoji ? (
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div>
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
@@ -19,18 +19,18 @@ import { renderFormattedDate } from "helpers/date-time.helper";
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
import { cn } from "helpers/common.helper";
type Props = {
analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
};
const analyticsService = new AnalyticsService();
export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const { analytics, params, fullScreen, isProjectLevel = false } = props;
const { analytics, params, isProjectLevel = false } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@@ -138,18 +138,14 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
return (
<div
className={`flex items-center justify-between space-y-2 px-5 py-2.5 ${
fullScreen
? "overflow-hidden border-l border-custom-border-200 md:h-full md:flex-col md:items-start md:space-y-4 md:border-l md:border-custom-border-200 md:py-5"
: ""
}`}
<div className={cn("relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100", !isProjectLevel ? "flex-col" : "")}
>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
<LayersIcon height={14} width={14} />
{analytics ? analytics.total : "..."} Issues
{analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
</div>
{isProjectLevel && (
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
@@ -158,36 +154,36 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
(cycleId
? cycleDetails?.created_at
: moduleId
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
)}
</div>
)}
</div>
<div className="h-full w-full overflow-hidden">
{fullScreen ? (
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
)}
<CustomAnalyticsSidebarHeader />
</>
) : null}
<div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}>
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
)}
<CustomAnalyticsSidebarHeader />
</>
</div>
<div className="flex flex-wrap items-center gap-2 justify-self-end">
<div className="flex flex-wrap items-center gap-2 justify-end">
<Button
variant="neutral-primary"
prependIcon={<RefreshCw className="h-3.5 w-3.5" />}
prependIcon={<RefreshCw className="h-3 md:h-3.5 w-3 md:w-3.5" />}
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Refresh</div>
</Button>
<Button variant="primary" prependIcon={<Download className="h-3.5 w-3.5" />} onClick={exportAnalytics}>
Export as CSV
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Export as CSV</div>
</Button>
</div>
</div>
@@ -20,16 +20,15 @@ export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props
return (
<Tab.Group as={React.Fragment}>
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 p-5 pt-0">
<Tab.List as="div" className="flex space-x-2 border-b border-custom-border-200 px-0 md:px-5 py-0 md:py-3">
{ANALYTICS_TABS.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
selected ? "bg-custom-background-80" : ""
`rounded-0 w-full md:w-max md:rounded-3xl border-b md:border border-custom-border-200 focus:outline-none px-0 md:px-4 py-2 text-xs hover:bg-custom-background-80 ${selected ? "border-custom-primary-100 text-custom-primary-100 md:bg-custom-background-80 md:text-custom-text-200 md:border-custom-border-200" : "border-transparent"
}`
}
onClick={() => {}}
onClick={() => { }}
>
{tab.title}
</Tab>
@@ -38,14 +38,12 @@ export const ProjectAnalyticsModal: React.FC<Props> = observer((props) => {
>
<Dialog.Panel className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
<div
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
fullScreen ? "w-full p-2" : "w-1/2"
}`}
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${fullScreen ? "w-full p-2" : "w-full sm:w-full md:w-1/2"
}`}
>
<div
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
fullScreen ? "rounded-lg border" : "border-l"
}`}
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${fullScreen ? "rounded-lg border" : "border-l"
}`}
>
<ProjectAnalyticsModalHeader
fullScreen={fullScreen}
@@ -1,7 +1,7 @@
import { Command } from "cmdk";
import { ContrastIcon, FileText } from "lucide-react";
// hooks
import { useApplication } from "hooks/store";
import { useApplication, useEventTracker } from "hooks/store";
// ui
import { DiceIcon, PhotoFilterIcon } from "@plane/ui";
@@ -14,8 +14,8 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
const {
commandPalette: { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal },
eventTracker: { setTrackElement },
} = useApplication();
const { setTrackElement } = useEventTracker();
return (
<>
@@ -23,7 +23,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE");
setTrackElement("Command palette");
toggleCreateCycleModal(true);
}}
className="focus:outline-none"
@@ -39,6 +39,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreateModuleModal(true);
}}
className="focus:outline-none"
@@ -54,6 +55,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreateViewModal(true);
}}
className="focus:outline-none"
@@ -69,6 +71,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreatePageModal(true);
}}
className="focus:outline-none"
@@ -6,7 +6,7 @@ import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
import { FolderPlus, Search, Settings } from "lucide-react";
// hooks
import { useApplication, useProject } from "hooks/store";
import { useApplication, useEventTracker, useProject } from "hooks/store";
// services
import { WorkspaceService } from "services/workspace.service";
import { IssueService } from "services/issue";
@@ -64,8 +64,8 @@ export const CommandModal: React.FC = observer(() => {
toggleCreateIssueModal,
toggleCreateProjectModal,
},
eventTracker: { setTrackElement },
} = useApplication();
const { setTrackElement } = useEventTracker();
// router
const router = useRouter();
@@ -278,7 +278,7 @@ export const CommandModal: React.FC = observer(() => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE");
setTrackElement("Command Palette");
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
@@ -296,7 +296,7 @@ export const CommandModal: React.FC = observer(() => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE");
setTrackElement("Command palette");
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication, useIssues, useUser } from "hooks/store";
import { useApplication, useEventTracker, useIssues, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CommandModal, ShortcutsModal } from "components/command-palette";
@@ -32,8 +32,8 @@ export const CommandPalette: FC = observer(() => {
const {
commandPalette,
theme: { toggleSidebar },
eventTracker: { setTrackElement },
} = useApplication();
const { setTrackElement } = useEventTracker();
const { currentUser } = useUser();
const {
issues: { removeIssue },
@@ -118,11 +118,10 @@ export const CommandPalette: FC = observer(() => {
toggleSidebar();
}
} else if (!isAnyModalOpen) {
setTrackElement("Shortcut key");
if (keyPressed === "c") {
setTrackElement("SHORTCUT_KEY");
toggleCreateIssueModal(true);
} else if (keyPressed === "p") {
setTrackElement("SHORTCUT_KEY");
toggleCreateProjectModal(true);
} else if (keyPressed === "h") {
toggleShortcutModal(true);
@@ -164,6 +163,8 @@ export const CommandPalette: FC = observer(() => {
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
if (!currentUser) return null;
return (
@@ -218,6 +219,7 @@ export const CommandPalette: FC = observer(() => {
onClose={() => toggleCreateIssueModal(false)}
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
storeType={createIssueStoreType}
isDraft={isDraftIssue}
/>
{workspaceSlug && projectId && issueId && issueDetails && (
@@ -47,7 +47,7 @@ export const ShortcutsModal: FC<Props> = (props) => {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="h-full w-full">
<div className="grid h-full w-full place-items-center p-5">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-custom-background-100 p-5 shadow-custom-shadow-md transition-all sm:w-[28rem]">
<Dialog.Title as="h3" className="flex justify-between">
<span className="text-lg font-medium">Keyboard shortcuts</span>
+1 -1
View File
@@ -11,7 +11,7 @@ export const BreadcrumbLink: React.FC<Props> = (props) => {
const { href, label, icon } = props;
return (
<Tooltip tooltipContent={label} position="bottom">
<li className="flex items-center space-x-2">
<li className="flex items-center space-x-2" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5">
{href ? (
<Link
@@ -78,7 +78,6 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
useEffect(() => {
if (!isOpen || !workspaceSlug || !projectId) return;
if (issues.length <= 0) setIsSearching(true);
projectService
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
@@ -88,16 +87,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
})
.then((res) => setIssues(res))
.finally(() => setIsSearching(false));
}, [issues, debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
useEffect(() => {
setSearchTerm("");
setIssues([]);
setSelectedIssues([]);
setIsSearching(false);
setIsSubmitting(false);
setIsWorkspaceLevel(false);
}, [isOpen]);
}, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
return (
<>
@@ -0,0 +1,82 @@
import { cn } from "helpers/common.helper";
import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react";
type Props = {
defaultHeight?: string;
verticalOffset?: number;
horizonatlOffset?: number;
root?: MutableRefObject<HTMLElement | null>;
children: ReactNode;
as?: keyof JSX.IntrinsicElements;
classNames?: string;
alwaysRender?: boolean;
placeholderChildren?: ReactNode;
pauseHeightUpdateWhileRendering?: boolean;
changingReference?: any;
};
const RenderIfVisible: React.FC<Props> = (props) => {
const {
defaultHeight = "300px",
root,
verticalOffset = 50,
horizonatlOffset = 0,
as = "div",
children,
classNames = "",
alwaysRender = false, //render the children even if it is not visble in root
placeholderChildren = null, //placeholder children
pauseHeightUpdateWhileRendering = false, //while this is true the height of the blocks are maintained
changingReference, //This is to force render when this reference is changed
} = props;
const [shouldVisible, setShouldVisible] = useState<boolean>(alwaysRender);
const placeholderHeight = useRef<string>(defaultHeight);
const intersectionRef = useRef<HTMLElement | null>(null);
const isVisible = alwaysRender || shouldVisible;
// Set visibility with intersection observer
useEffect(() => {
if (intersectionRef.current) {
const observer = new IntersectionObserver(
(entries) => {
//DO no remove comments for future
// if (typeof window !== undefined && window.requestIdleCallback) {
// window.requestIdleCallback(() => setShouldVisible(entries[0].isIntersecting), {
// timeout: 300,
// });
// } else {
// setShouldVisible(entries[0].isIntersecting);
// }
setShouldVisible(entries[0].isIntersecting);
},
{
root: root?.current,
rootMargin: `${verticalOffset}% ${horizonatlOffset}% ${verticalOffset}% ${horizonatlOffset}%`,
}
);
observer.observe(intersectionRef.current);
return () => {
if (intersectionRef.current) {
observer.unobserve(intersectionRef.current);
}
};
}
}, [root?.current, intersectionRef, children, changingReference]);
//Set height after render
useEffect(() => {
if (intersectionRef.current && isVisible) {
placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`;
}
}, [isVisible, intersectionRef, alwaysRender, pauseHeightUpdateWhileRendering]);
const child = isVisible ? <>{children}</> : placeholderChildren;
const style =
isVisible && !pauseHeightUpdateWhileRendering ? {} : { height: placeholderHeight.current, width: "100%" };
const className = isVisible ? classNames : cn(classNames, "bg-custom-background-80");
return React.createElement(as, { ref: intersectionRef, style, className }, child);
};
export default RenderIfVisible;
+12 -7
View File
@@ -86,12 +86,15 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
{
id: "pending",
color: "#3F76FF",
data: chartData.map((item, index) => ({
index,
x: item.currentDate,
y: item.pending,
color: "#3F76FF",
})),
data:
chartData.length > 0
? chartData.map((item, index) => ({
index,
x: item.currentDate,
y: item.pending,
color: "#3F76FF",
}))
: [],
enableArea: true,
},
{
@@ -121,7 +124,9 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
enableArea
colors={(datum) => datum.color ?? "#3F76FF"}
customYAxisTickValues={[0, totalIssues]}
gridXValues={chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : ""))}
gridXValues={
chartData.length > 0 ? chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : "")) : undefined
}
enableSlices="x"
sliceTooltip={(datum) => (
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
@@ -3,12 +3,20 @@ import { Menu } from "lucide-react";
import { useApplication } from "hooks/store";
import { observer } from "mobx-react";
export const SidebarHamburgerToggle: FC = observer (() => {
const { theme: themStore } = useApplication();
type Props = {
onClick?: () => void;
}
export const SidebarHamburgerToggle: FC<Props> = observer((props) => {
const { onClick } = props
const { theme: themeStore } = useApplication();
return (
<div
className="w-7 h-7 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
onClick={() => themStore.toggleSidebar()}
className="w-7 h-7 flex-shrink-0 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
onClick={() => {
if (onClick) onClick()
else themeStore.toggleMobileSidebar()
}}
>
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
</div>
@@ -125,7 +125,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab>
</Tab.List>
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
<Tab.Panel as="div" className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
<Tab.Panel as="div" className="flex min-h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
{distribution?.assignees.length > 0 ? (
distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
@@ -34,7 +34,8 @@ import { ICycle, TCycleGroups } from "@plane/types";
// constants
import { EIssuesStoreType } from "constants/issue";
import { CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
import { CYCLE_EMPTY_STATE_DETAILS, CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
import { CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
interface IActiveCycleDetails {
workspaceSlug: string;
@@ -150,7 +151,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
color: group.color,
}));
const daysLeft = findHowManyDaysLeft(activeCycle.end_date ?? new Date());
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
return (
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
@@ -0,0 +1,168 @@
import { useCallback, useState } from "react";
import router from "next/router";
//components
import { CustomMenu } from "@plane/ui";
// icons
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
// hooks
import { useIssues, useCycle, useProjectState, useLabel, useMember } from "hooks/store";
// constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue";
import { ProjectAnalyticsModal } from "components/analytics";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues";
export const CycleMobileHeader = () => {
const [analyticsModal, setAnalyticsModal] = useState(false);
const { getCycleById } = useCycle();
const layouts = [
{ key: "list", title: "List", icon: List },
{ key: "kanban", title: "Kanban", icon: Kanban },
{ key: "calendar", title: "Calendar", icon: Calendar },
];
const { workspaceSlug, projectId, cycleId } = router.query as {
workspaceSlug: string;
projectId: string;
cycleId: string;
};
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
const {
project: { projectMemberIds },
} = useMember();
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId);
},
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
return (
<>
<ProjectAnalyticsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
cycleDetails={cycleDetails ?? undefined}
/>
<div className="flex justify-evenly py-2 border-b border-custom-border-200">
<CustomMenu
maxHeight={"md"}
className="flex flex-grow justify-center text-custom-text-200 text-sm"
placement="bottom-start"
customButton={<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>}
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
closeOnSelect
>
{layouts.map((layout, index) => (
<CustomMenu.MenuItem
onClick={() => {
handleLayoutChange(ISSUE_LAYOUTS[index].key);
}}
className="flex items-center gap-2"
>
<layout.icon className="w-3 h-3" />
<div className="text-custom-text-300">{layout.title}</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
<FiltersDropdown
title="Filters"
placement="bottom-end"
menuButton={
<span className="flex items-center text-custom-text-200 text-sm">
Filters
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
</span>
}
>
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
/>
</FiltersDropdown>
</div>
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
<FiltersDropdown
title="Display"
placement="bottom-end"
menuButton={
<span className="flex items-center text-custom-text-200 text-sm">
Display
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
</span>
}
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
/>
</FiltersDropdown>
</div>
<span
onClick={() => setAnalyticsModal(true)}
className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200"
>
Analytics
</span>
</div>
</>
);
};
@@ -38,7 +38,7 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
{peekCycle && (
<div
ref={ref}
className="flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300"
className="flex h-full w-full max-w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 fixed md:relative right-0 z-[9]"
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
+39 -22
View File
@@ -1,8 +1,9 @@
import { FC, MouseEvent, useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { observer } from "mobx-react";
// hooks
import { useApplication, useCycle, useUser } from "hooks/store";
import { useEventTracker, useCycle, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
@@ -16,6 +17,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
// constants
import { CYCLE_STATUS } from "constants/cycle";
import { EUserWorkspaceRoles } from "constants/workspace";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
//.types
import { TCycleGroups } from "@plane/types";
@@ -25,7 +27,7 @@ export interface ICyclesBoardCard {
cycleId: string;
}
export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
@@ -33,9 +35,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
// router
const router = useRouter();
// store
const {
eventTracker: { setTrackElement },
} = useApplication();
const { setTrackElement, captureEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
@@ -92,39 +92,56 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
.then(() => {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "Grid layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
});
};
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
.then(() => {
captureEvent(CYCLE_UNFAVORITED, {
cycle_id: cycleId,
element: "Grid layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
});
};
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page grid layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page grid layout");
setDeleteModal(true);
setTrackElement("CYCLE_PAGE_BOARD_LAYOUT");
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
@@ -138,7 +155,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
});
};
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date ?? new Date());
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
return (
<div>
@@ -159,7 +176,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
/>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
<div className="flex h-44 w-full min-w-[250px] flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 truncate">
<span className="flex-shrink-0">
@@ -237,7 +254,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
) : (
<span className="text-xs text-custom-text-400">No due date</span>
)}
<div className="z-10 flex items-center gap-1.5">
<div className="z-[5] flex items-center gap-1.5">
{isEditingAllowed &&
(cycleDetails.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
@@ -279,4 +296,4 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
</Link>
</div>
);
};
});
+1 -1
View File
@@ -7,7 +7,7 @@ import { useUser } from "hooks/store";
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// constants
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle";
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
export interface ICyclesBoard {
cycleIds: string[];
+120 -101
View File
@@ -1,8 +1,9 @@
import { FC, MouseEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react";
// hooks
import { useApplication, useCycle, useUser } from "hooks/store";
import { useEventTracker, useCycle, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
@@ -18,6 +19,7 @@ import { CYCLE_STATUS } from "constants/cycle";
import { EUserWorkspaceRoles } from "constants/workspace";
// types
import { TCycleGroups } from "@plane/types";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
type TCyclesListItem = {
cycleId: string;
@@ -29,7 +31,7 @@ type TCyclesListItem = {
projectId: string;
};
export const CyclesListItem: FC<TCyclesListItem> = (props) => {
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
@@ -37,9 +39,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
// router
const router = useRouter();
// store hooks
const {
eventTracker: { setTrackElement },
} = useApplication();
const { setTrackElement, captureEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
@@ -65,39 +65,56 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
.then(() => {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
});
};
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
.then(() => {
captureEvent(CYCLE_UNFAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
});
};
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setDeleteModal(true);
setTrackElement("CYCLE_PAGE_LIST_LAYOUT");
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
@@ -141,7 +158,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date ?? new Date());
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
return (
<>
@@ -160,10 +177,10 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
projectId={projectId}
/>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
<div className="group flex h-16 w-full items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90">
<div className="flex w-full items-center gap-3 truncate">
<div className="flex items-center gap-4 truncate">
<span className="flex-shrink-0">
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row">
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden">
<div className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}>
{isCompleted ? (
progress === 100 ? (
@@ -177,98 +194,100 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)}
</CircularProgressIndicator>
</span>
</div>
<div className="flex items-center gap-2.5">
<span className="flex-shrink-0">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
</span>
<div className="relative flex items-center gap-2.5 overflow-hidden">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
<Tooltip tooltipContent={cycleDetails.name} position="top">
<span className="truncate text-base font-medium">{cycleDetails.name}</span>
<span className="line-clamp-1 inline-block overflow-hidden truncate text-base font-medium">
{cycleDetails.name}
</span>
</Tooltip>
</div>
</div>
<button onClick={openCycleOverview} className="z-10 hidden flex-shrink-0 group-hover:flex">
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</div>
<div className="flex w-full items-center justify-end gap-2.5 md:w-auto md:flex-shrink-0 ">
<div className="flex items-center justify-center">
{currentCycle && (
<span
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
: `${currentCycle.label}`}
</span>
)}
<button onClick={openCycleOverview} className="flex-shrink-0 z-[5] invisible group-hover:visible">
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</div>
{renderDate && (
<span className="flex w-40 items-center justify-center gap-2 text-xs text-custom-text-300">
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
</span>
)}
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
<div className="flex w-16 cursor-default items-center justify-center gap-1">
{cycleDetails.assignees.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
{currentCycle && (
<div
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
: `${currentCycle.label}`}
</div>
</Tooltip>
{isEditingAllowed &&
(cycleDetails.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
))}
)}
</div>
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 overflow-hidden md:w-auto md:flex-shrink-0 md:justify-end ">
<div className="text-xs text-custom-text-300">
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
</div>
<CustomMenu ellipsis>
{!isCompleted && isEditingAllowed && (
<div className="relative flex flex-shrink-0 items-center gap-3">
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignees.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{isEditingAllowed && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
{cycleDetails.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
<CustomMenu ellipsis>
{!isCompleted && isEditingAllowed && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</Link>
</>
);
};
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// ui
import { Loader } from "@plane/ui";
// constants
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle";
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
export interface ICyclesList {
cycleIds: string[];
+20 -42
View File
@@ -5,7 +5,7 @@ import { useCycle } from "hooks/store";
// components
import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles";
// ui components
import { Loader } from "@plane/ui";
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
// types
import { TCycleLayout, TCycleView } from "@plane/types";
@@ -25,6 +25,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
currentProjectDraftCycleIds,
currentProjectUpcomingCycleIds,
currentProjectCycleIds,
loader,
} = useCycle();
const cyclesList =
@@ -36,55 +37,32 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
? currentProjectUpcomingCycleIds
: currentProjectCycleIds;
if (loader || !cyclesList)
return (
<>
{layout === "list" && <CycleModuleListLayout />}
{layout === "board" && <CycleModuleBoardLayout />}
{layout === "gantt" && <GanttLayoutLoader />}
</>
);
return (
<>
{layout === "list" && (
<>
{cyclesList ? (
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
) : (
<Loader className="space-y-4 p-8">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</>
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
)}
{layout === "board" && (
<>
{cyclesList ? (
<CyclesBoard
cycleIds={cyclesList}
filter={filter}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
) : (
<Loader className="grid grid-cols-1 gap-9 p-8 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
<Loader.Item height="200px" />
<Loader.Item height="200px" />
</Loader>
)}
</>
<CyclesBoard
cycleIds={cyclesList}
filter={filter}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
)}
{layout === "gantt" && (
<>
{cyclesList ? (
<CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />
) : (
<Loader className="space-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</>
)}
{layout === "gantt" && <CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />}
</>
);
});
+10 -8
View File
@@ -4,12 +4,14 @@ import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
import { AlertTriangle } from "lucide-react";
// hooks
import { useApplication, useCycle } from "hooks/store";
import { useEventTracker, useCycle } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { Button } from "@plane/ui";
// types
import { ICycle } from "@plane/types";
// constants
import { CYCLE_DELETED } from "constants/event-tracker";
interface ICycleDelete {
cycle: ICycle;
@@ -27,9 +29,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
const router = useRouter();
const { cycleId, peekCycle } = router.query;
// store hooks
const {
eventTracker: { postHogEventTracker },
} = useApplication();
const { captureCycleEvent } = useEventTracker();
const { deleteCycle } = useCycle();
// toast alert
const { setToastAlert } = useToast();
@@ -46,13 +46,15 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
title: "Success!",
message: "Cycle deleted successfully.",
});
postHogEventTracker("CYCLE_DELETE", {
state: "SUCCESS",
captureCycleEvent({
eventName: CYCLE_DELETED,
payload: { ...cycle, state: "SUCCESS" },
});
})
.catch(() => {
postHogEventTracker("CYCLE_DELETE", {
state: "FAILED",
captureCycleEvent({
eventName: CYCLE_DELETED,
payload: { ...cycle, state: "FAILED" },
});
});
+3 -3
View File
@@ -10,7 +10,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { ICycle } from "@plane/types";
type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleFormSubmit: (values: Partial<ICycle>, dirtyFields: any) => Promise<void>;
handleClose: () => void;
status: boolean;
projectId: string;
@@ -29,7 +29,7 @@ export const CycleForm: React.FC<Props> = (props) => {
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props;
// form data
const {
formState: { errors, isSubmitting },
formState: { errors, isSubmitting, dirtyFields },
handleSubmit,
control,
watch,
@@ -61,7 +61,7 @@ export const CycleForm: React.FC<Props> = (props) => {
maxDate?.setDate(maxDate.getDate() - 1);
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
<form onSubmit={handleSubmit((formData)=>handleFormSubmit(formData,dirtyFields))}>
<div className="space-y-5">
<div className="flex items-center gap-x-3">
{!status && (
+39 -16
View File
@@ -1,16 +1,30 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react";
// hooks
import { useApplication, useCycle } from "hooks/store";
// ui
import { Tooltip, ContrastIcon } from "@plane/ui";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
// types
import { ICycle } from "@plane/types";
export const CycleGanttBlock = ({ data }: { data: ICycle }) => {
type Props = {
cycleId: string;
};
export const CycleGanttBlock: React.FC<Props> = observer((props) => {
const { cycleId } = props;
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// store hooks
const {
router: { workspaceSlug },
} = useApplication();
const { getCycleById } = useCycle();
// derived values
const cycleDetails = getCycleById(cycleId);
const cycleStatus = cycleDetails?.status.toLocaleLowerCase();
const cycleStatus = data.status.toLocaleLowerCase();
return (
<div
className="relative flex h-full w-full items-center rounded"
@@ -26,36 +40,45 @@ export const CycleGanttBlock = ({ data }: { data: ICycle }) => {
? "rgb(var(--color-text-200))"
: "",
}}
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)}
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)}
>
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{data?.name}</h5>
<h5>{cycleDetails?.name}</h5>
<div>
{renderFormattedDate(data?.start_date ?? "")} to {renderFormattedDate(data?.end_date ?? "")}
{renderFormattedDate(cycleDetails?.start_date ?? "")} to{" "}
{renderFormattedDate(cycleDetails?.end_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="relative w-full truncate px-2.5 py-1 text-sm text-custom-text-100">{data?.name}</div>
<div className="relative w-full truncate px-2.5 py-1 text-sm text-custom-text-100">{cycleDetails?.name}</div>
</Tooltip>
</div>
);
};
});
export const CycleGanttSidebarBlock = ({ data }: { data: ICycle }) => {
export const CycleGanttSidebarBlock: React.FC<Props> = observer((props) => {
const { cycleId } = props;
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// store hooks
const {
router: { workspaceSlug },
} = useApplication();
const { getCycleById } = useCycle();
// derived values
const cycleDetails = getCycleById(cycleId);
const cycleStatus = data.status.toLocaleLowerCase();
const cycleStatus = cycleDetails?.status.toLocaleLowerCase();
return (
<div
className="relative flex h-full w-full items-center gap-2"
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)}
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)}
>
<ContrastIcon
className="h-5 w-5 flex-shrink-0"
@@ -71,7 +94,7 @@ export const CycleGanttSidebarBlock = ({ data }: { data: ICycle }) => {
: ""
}`}
/>
<h6 className="flex-grow truncate text-sm font-medium">{data?.name}</h6>
<h6 className="flex-grow truncate text-sm font-medium">{cycleDetails?.name}</h6>
</div>
);
};
});

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