mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of github.com:makeplane/plane into dev/external-pings
This commit is contained in:
commit
37fb3cd4e2
4
.github/ISSUE_TEMPLATE/--bug-report.yaml
vendored
4
.github/ISSUE_TEMPLATE/--bug-report.yaml
vendored
@ -2,7 +2,7 @@ name: Bug report
|
|||||||
description: Create a bug report to help us improve Plane
|
description: Create a bug report to help us improve Plane
|
||||||
title: "[bug]: "
|
title: "[bug]: "
|
||||||
labels: [🐛bug]
|
labels: [🐛bug]
|
||||||
assignees: [srinivaspendem, pushya-plane]
|
assignees: [srinivaspendem, pushya22]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
@ -45,7 +45,7 @@ body:
|
|||||||
- Deploy preview
|
- Deploy preview
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
type: dropdown
|
- type: dropdown
|
||||||
id: browser
|
id: browser
|
||||||
attributes:
|
attributes:
|
||||||
label: Browser
|
label: Browser
|
||||||
|
@ -2,7 +2,7 @@ name: Feature request
|
|||||||
description: Suggest a feature to improve Plane
|
description: Suggest a feature to improve Plane
|
||||||
title: "[feature]: "
|
title: "[feature]: "
|
||||||
labels: [✨feature]
|
labels: [✨feature]
|
||||||
assignees: [srinivaspendem, pushya-plane]
|
assignees: [srinivaspendem, pushya22]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
|
61
.github/workflows/build-branch.yml
vendored
61
.github/workflows/build-branch.yml
vendored
@ -23,6 +23,10 @@ jobs:
|
|||||||
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
|
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
|
||||||
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
||||||
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
|
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
|
||||||
|
build_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }}
|
||||||
|
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
|
||||||
|
build_backend: ${{ steps.changed_files.outputs.backend_any_changed }}
|
||||||
|
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: set_env_variables
|
- id: set_env_variables
|
||||||
@ -41,7 +45,36 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
|
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- id: checkout_files
|
||||||
|
name: Checkout Files
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed_files
|
||||||
|
uses: tj-actions/changed-files@v42
|
||||||
|
with:
|
||||||
|
files_yaml: |
|
||||||
|
frontend:
|
||||||
|
- web/**
|
||||||
|
- packages/**
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'turbo.json'
|
||||||
|
space:
|
||||||
|
- space/**
|
||||||
|
- packages/**
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'turbo.json'
|
||||||
|
backend:
|
||||||
|
- apiserver/**
|
||||||
|
proxy:
|
||||||
|
- nginx/**
|
||||||
|
|
||||||
branch_build_push_frontend:
|
branch_build_push_frontend:
|
||||||
|
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -55,9 +88,9 @@ jobs:
|
|||||||
- name: Set Frontend Docker Tag
|
- name: Set Frontend Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest
|
||||||
else
|
else
|
||||||
TAG=${{ env.FRONTEND_TAG }}
|
TAG=${{ env.FRONTEND_TAG }}
|
||||||
fi
|
fi
|
||||||
@ -77,7 +110,7 @@ jobs:
|
|||||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build and Push Frontend to Docker Container Registry
|
- name: Build and Push Frontend to Docker Container Registry
|
||||||
uses: docker/build-push-action@v5.1.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
@ -93,6 +126,7 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
branch_build_push_space:
|
branch_build_push_space:
|
||||||
|
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -106,9 +140,9 @@ jobs:
|
|||||||
- name: Set Space Docker Tag
|
- name: Set Space Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest
|
||||||
else
|
else
|
||||||
TAG=${{ env.SPACE_TAG }}
|
TAG=${{ env.SPACE_TAG }}
|
||||||
fi
|
fi
|
||||||
@ -128,7 +162,7 @@ jobs:
|
|||||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build and Push Space to Docker Hub
|
- name: Build and Push Space to Docker Hub
|
||||||
uses: docker/build-push-action@v5.1.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
@ -144,6 +178,7 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
branch_build_push_backend:
|
branch_build_push_backend:
|
||||||
|
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -157,9 +192,9 @@ jobs:
|
|||||||
- name: Set Backend Docker Tag
|
- name: Set Backend Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest
|
||||||
else
|
else
|
||||||
TAG=${{ env.BACKEND_TAG }}
|
TAG=${{ env.BACKEND_TAG }}
|
||||||
fi
|
fi
|
||||||
@ -179,7 +214,7 @@ jobs:
|
|||||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build and Push Backend to Docker Hub
|
- name: Build and Push Backend to Docker Hub
|
||||||
uses: docker/build-push-action@v5.1.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
@ -194,8 +229,8 @@ jobs:
|
|||||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
|
||||||
branch_build_push_proxy:
|
branch_build_push_proxy:
|
||||||
|
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -209,9 +244,9 @@ jobs:
|
|||||||
- name: Set Proxy Docker Tag
|
- name: Set Proxy Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest
|
||||||
else
|
else
|
||||||
TAG=${{ env.PROXY_TAG }}
|
TAG=${{ env.PROXY_TAG }}
|
||||||
fi
|
fi
|
||||||
@ -231,7 +266,7 @@ jobs:
|
|||||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build and Push Plane-Proxy to Docker Hub
|
- name: Build and Push Plane-Proxy to Docker Hub
|
||||||
uses: docker/build-push-action@v5.1.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
|
25
.github/workflows/create-sync-pr.yml
vendored
25
.github/workflows/create-sync-pr.yml
vendored
@ -2,7 +2,7 @@ name: Create Sync Action
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- preview
|
- preview
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.1.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@ -31,14 +31,25 @@ jobs:
|
|||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install gh -y
|
sudo apt install gh -y
|
||||||
|
|
||||||
- name: Push Changes to Target Repo
|
- name: Push Changes to Target Repo A
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}"
|
TARGET_REPO="${{ secrets.TARGET_REPO_A }}"
|
||||||
TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}"
|
TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}"
|
||||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||||
|
|
||||||
git checkout $SOURCE_BRANCH
|
git checkout $SOURCE_BRANCH
|
||||||
git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||||
git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH
|
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
|
||||||
|
|
||||||
|
- name: Push Changes to Target Repo B
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TARGET_REPO="${{ secrets.TARGET_REPO_B }}"
|
||||||
|
TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}"
|
||||||
|
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||||
|
|
||||||
|
git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||||
|
git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "plane-api",
|
"name": "plane-api",
|
||||||
"version": "0.15.1"
|
"version": "0.16.0"
|
||||||
}
|
}
|
||||||
|
@ -45,7 +45,10 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
return (
|
return (
|
||||||
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("owned_by")
|
.select_related("owned_by")
|
||||||
@ -390,7 +393,10 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
)
|
)
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.filter(cycle_id=self.kwargs.get("cycle_id"))
|
.filter(cycle_id=self.kwargs.get("cycle_id"))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
|
@ -352,7 +352,10 @@ class LabelAPIEndpoint(BaseAPIView):
|
|||||||
return (
|
return (
|
||||||
Label.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
Label.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("parent")
|
.select_related("parent")
|
||||||
@ -481,7 +484,10 @@ class IssueLinkAPIEndpoint(BaseAPIView):
|
|||||||
IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.order_by(self.kwargs.get("order_by", "-created_at"))
|
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
@ -607,11 +613,11 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
)
|
)
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
.select_related("project")
|
project__project_projectmember__member=self.request.user,
|
||||||
.select_related("workspace")
|
project__project_projectmember__is_active=True,
|
||||||
.select_related("issue")
|
)
|
||||||
.select_related("actor")
|
.select_related("workspace", "project", "issue", "actor")
|
||||||
.annotate(
|
.annotate(
|
||||||
is_member=Exists(
|
is_member=Exists(
|
||||||
ProjectMember.objects.filter(
|
ProjectMember.objects.filter(
|
||||||
@ -784,6 +790,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
|
|||||||
.filter(
|
.filter(
|
||||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
.select_related("actor", "workspace", "issue", "project")
|
.select_related("actor", "workspace", "issue", "project")
|
||||||
).order_by(request.GET.get("order_by", "created_at"))
|
).order_by(request.GET.get("order_by", "created_at"))
|
||||||
|
@ -273,7 +273,10 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(module_id=self.kwargs.get("module_id"))
|
.filter(module_id=self.kwargs.get("module_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("module")
|
.select_related("module")
|
||||||
|
@ -24,7 +24,10 @@ class StateAPIEndpoint(BaseAPIView):
|
|||||||
return (
|
return (
|
||||||
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.filter(~Q(name="Triage"))
|
.filter(~Q(name="Triage"))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
|
@ -259,23 +259,15 @@ urlpatterns = [
|
|||||||
name="project-issue-archive",
|
name="project-issue-archive",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/archive/",
|
||||||
IssueArchiveViewSet.as_view(
|
IssueArchiveViewSet.as_view(
|
||||||
{
|
{
|
||||||
"get": "retrieve",
|
"get": "retrieve",
|
||||||
"delete": "destroy",
|
"post": "archive",
|
||||||
|
"delete": "unarchive",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
name="project-issue-archive",
|
name="project-issue-archive-unarchive",
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/unarchive/<uuid:pk>/",
|
|
||||||
IssueArchiveViewSet.as_view(
|
|
||||||
{
|
|
||||||
"post": "unarchive",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="project-issue-archive",
|
|
||||||
),
|
),
|
||||||
## End Issue Archives
|
## End Issue Archives
|
||||||
## Issue Relation
|
## Issue Relation
|
||||||
|
@ -66,15 +66,15 @@ class ConfigurationEndpoint(BaseAPIView):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "SLACK_CLIENT_ID",
|
"key": "SLACK_CLIENT_ID",
|
||||||
"default": os.environ.get("SLACK_CLIENT_ID", "1"),
|
"default": os.environ.get("SLACK_CLIENT_ID", None),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "POSTHOG_API_KEY",
|
"key": "POSTHOG_API_KEY",
|
||||||
"default": os.environ.get("POSTHOG_API_KEY", "1"),
|
"default": os.environ.get("POSTHOG_API_KEY", None),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "POSTHOG_HOST",
|
"key": "POSTHOG_HOST",
|
||||||
"default": os.environ.get("POSTHOG_HOST", "1"),
|
"default": os.environ.get("POSTHOG_HOST", None),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "UNSPLASH_ACCESS_KEY",
|
"key": "UNSPLASH_ACCESS_KEY",
|
||||||
@ -181,11 +181,11 @@ class MobileConfigurationEndpoint(BaseAPIView):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "POSTHOG_API_KEY",
|
"key": "POSTHOG_API_KEY",
|
||||||
"default": os.environ.get("POSTHOG_API_KEY", "1"),
|
"default": os.environ.get("POSTHOG_API_KEY", None),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "POSTHOG_HOST",
|
"key": "POSTHOG_HOST",
|
||||||
"default": os.environ.get("POSTHOG_HOST", "1"),
|
"default": os.environ.get("POSTHOG_HOST", None),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "UNSPLASH_ACCESS_KEY",
|
"key": "UNSPLASH_ACCESS_KEY",
|
||||||
|
@ -85,7 +85,10 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project", "workspace", "owned_by")
|
.select_related("project", "workspace", "owned_by")
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
@ -689,7 +692,10 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.filter(cycle_id=self.kwargs.get("cycle_id"))
|
.filter(cycle_id=self.kwargs.get("cycle_id"))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
|
@ -33,6 +33,7 @@ from plane.app.serializers import (
|
|||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
InboxSerializer,
|
InboxSerializer,
|
||||||
InboxIssueSerializer,
|
InboxIssueSerializer,
|
||||||
|
IssueDetailSerializer,
|
||||||
)
|
)
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
@ -426,11 +427,10 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if issue is None:
|
if issue is None:
|
||||||
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
|
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
serializer = IssueSerializer(issue)
|
serializer = IssueDetailSerializer(issue)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
||||||
|
@ -36,7 +36,10 @@ class SlackProjectSyncViewSet(BaseViewSet):
|
|||||||
workspace__slug=self.kwargs.get("slug"),
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
project_id=self.kwargs.get("project_id"),
|
project_id=self.kwargs.get("project_id"),
|
||||||
)
|
)
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def create(self, request, slug, project_id, workspace_integration_id):
|
def create(self, request, slug, project_id, workspace_integration_id):
|
||||||
|
@ -773,7 +773,10 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
|
|||||||
def get(self, request, slug):
|
def get(self, request, slug):
|
||||||
issues = (
|
issues = (
|
||||||
Issue.issue_objects.filter(workspace__slug=slug)
|
Issue.issue_objects.filter(workspace__slug=slug)
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
)
|
)
|
||||||
serializer = IssueSerializer(issues, many=True)
|
serializer = IssueSerializer(issues, many=True)
|
||||||
@ -796,6 +799,7 @@ class IssueActivityEndpoint(BaseAPIView):
|
|||||||
.filter(
|
.filter(
|
||||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
@ -805,6 +809,7 @@ class IssueActivityEndpoint(BaseAPIView):
|
|||||||
IssueComment.objects.filter(issue_id=issue_id)
|
IssueComment.objects.filter(issue_id=issue_id)
|
||||||
.filter(
|
.filter(
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
@ -856,7 +861,10 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("issue")
|
.select_related("issue")
|
||||||
@ -1018,7 +1026,10 @@ class LabelViewSet(BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("parent")
|
.select_related("parent")
|
||||||
@ -1231,7 +1242,10 @@ class IssueLinkViewSet(BaseViewSet):
|
|||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
@ -1633,6 +1647,36 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||||||
serializer = IssueDetailSerializer(issue, expand=self.expand)
|
serializer = IssueDetailSerializer(issue, expand=self.expand)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def archive(self, request, slug, project_id, pk=None):
|
||||||
|
issue = Issue.issue_objects.get(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
pk=pk,
|
||||||
|
)
|
||||||
|
if issue.state.group not in ["completed", "cancelled"]:
|
||||||
|
return Response(
|
||||||
|
{"error": "Can only archive completed or cancelled state group issue"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
issue_activity.delay(
|
||||||
|
type="issue.activity.updated",
|
||||||
|
requested_data=json.dumps({"archived_at": str(timezone.now().date()), "automation": False}),
|
||||||
|
actor_id=str(request.user.id),
|
||||||
|
issue_id=str(issue.id),
|
||||||
|
project_id=str(project_id),
|
||||||
|
current_instance=json.dumps(
|
||||||
|
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||||
|
),
|
||||||
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
|
)
|
||||||
|
issue.archived_at = timezone.now().date()
|
||||||
|
issue.save()
|
||||||
|
|
||||||
|
return Response({"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
def unarchive(self, request, slug, project_id, pk=None):
|
def unarchive(self, request, slug, project_id, pk=None):
|
||||||
issue = Issue.objects.get(
|
issue = Issue.objects.get(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
@ -1656,7 +1700,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||||||
issue.archived_at = None
|
issue.archived_at = None
|
||||||
issue.save()
|
issue.save()
|
||||||
|
|
||||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
class IssueSubscriberViewSet(BaseViewSet):
|
class IssueSubscriberViewSet(BaseViewSet):
|
||||||
@ -1692,7 +1736,10 @@ class IssueSubscriberViewSet(BaseViewSet):
|
|||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
@ -1776,7 +1823,10 @@ class IssueReactionViewSet(BaseViewSet):
|
|||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
@ -1845,7 +1895,10 @@ class CommentReactionViewSet(BaseViewSet):
|
|||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(comment_id=self.kwargs.get("comment_id"))
|
.filter(comment_id=self.kwargs.get("comment_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
@ -1915,7 +1968,10 @@ class IssueRelationViewSet(BaseViewSet):
|
|||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("issue")
|
.select_related("issue")
|
||||||
@ -2310,17 +2366,10 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = IssueSerializer(issue, data=request.data, partial=True)
|
serializer = IssueCreateSerializer(issue, data=request.data, partial=True)
|
||||||
|
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
if request.data.get(
|
serializer.save()
|
||||||
"is_draft"
|
|
||||||
) is not None and not request.data.get("is_draft"):
|
|
||||||
serializer.save(
|
|
||||||
created_at=timezone.now(), updated_at=timezone.now()
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
serializer.save()
|
|
||||||
issue_activity.delay(
|
issue_activity.delay(
|
||||||
type="issue_draft.activity.updated",
|
type="issue_draft.activity.updated",
|
||||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||||
|
@ -673,7 +673,10 @@ class ModuleLinkViewSet(BaseViewSet):
|
|||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(module_id=self.kwargs.get("module_id"))
|
.filter(module_id=self.kwargs.get("module_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
@ -60,7 +60,10 @@ class PageViewSet(BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.filter(parent__isnull=True)
|
.filter(parent__isnull=True)
|
||||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
|
@ -77,6 +77,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
sort_order = ProjectMember.objects.filter(
|
||||||
|
member=self.request.user,
|
||||||
|
project_id=OuterRef("pk"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
is_active=True,
|
||||||
|
).values("sort_order")
|
||||||
return self.filter_queryset(
|
return self.filter_queryset(
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
@ -147,6 +153,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.annotate(sort_order=Subquery(sort_order))
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"project_projectmember",
|
"project_projectmember",
|
||||||
@ -166,16 +173,8 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
for field in request.GET.get("fields", "").split(",")
|
for field in request.GET.get("fields", "").split(",")
|
||||||
if field
|
if field
|
||||||
]
|
]
|
||||||
|
|
||||||
sort_order_query = ProjectMember.objects.filter(
|
|
||||||
member=request.user,
|
|
||||||
project_id=OuterRef("pk"),
|
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
|
||||||
is_active=True,
|
|
||||||
).values("sort_order")
|
|
||||||
projects = (
|
projects = (
|
||||||
self.get_queryset()
|
self.get_queryset()
|
||||||
.annotate(sort_order=Subquery(sort_order_query))
|
|
||||||
.order_by("sort_order", "name")
|
.order_by("sort_order", "name")
|
||||||
)
|
)
|
||||||
if request.GET.get("per_page", False) and request.GET.get(
|
if request.GET.get("per_page", False) and request.GET.get(
|
||||||
@ -204,7 +203,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
# Add the user as Administrator to the project
|
# Add the user as Administrator to the project
|
||||||
project_member = ProjectMember.objects.create(
|
_ = ProjectMember.objects.create(
|
||||||
project_id=serializer.data["id"],
|
project_id=serializer.data["id"],
|
||||||
member=request.user,
|
member=request.user,
|
||||||
role=20,
|
role=20,
|
||||||
|
@ -48,8 +48,8 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
return (
|
return (
|
||||||
Project.objects.filter(
|
Project.objects.filter(
|
||||||
q,
|
q,
|
||||||
Q(project_projectmember__member=self.request.user)
|
project_projectmember__member=self.request.user,
|
||||||
| Q(network=2),
|
project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
.distinct()
|
.distinct()
|
||||||
@ -71,6 +71,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
issues = Issue.issue_objects.filter(
|
issues = Issue.issue_objects.filter(
|
||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -95,6 +96,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
cycles = Cycle.objects.filter(
|
cycles = Cycle.objects.filter(
|
||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -118,6 +120,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
modules = Module.objects.filter(
|
modules = Module.objects.filter(
|
||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -141,6 +144,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
pages = Page.objects.filter(
|
pages = Page.objects.filter(
|
||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -164,6 +168,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
issue_views = IssueView.objects.filter(
|
issue_views = IssueView.objects.filter(
|
||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -236,6 +241,7 @@ class IssueSearchEndpoint(BaseAPIView):
|
|||||||
issues = Issue.issue_objects.filter(
|
issues = Issue.issue_objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if workspace_search == "false":
|
if workspace_search == "false":
|
||||||
|
@ -31,7 +31,10 @@ class StateViewSet(BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.filter(~Q(name="Triage"))
|
.filter(~Q(name="Triage"))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
|
@ -86,6 +86,10 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
|||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("workspace", "project", "state", "parent")
|
.select_related("workspace", "project", "state", "parent")
|
||||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||||
@ -163,7 +167,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
|||||||
issue_queryset = (
|
issue_queryset = (
|
||||||
self.get_queryset()
|
self.get_queryset()
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
|
||||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -284,7 +287,10 @@ class IssueViewViewSet(BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.annotate(is_favorite=Exists(subquery))
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
@ -1086,6 +1086,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
assignees__in=[user_id],
|
assignees__in=[user_id],
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.annotate(state_group=F("state__group"))
|
.annotate(state_group=F("state__group"))
|
||||||
@ -1101,6 +1102,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
assignees__in=[user_id],
|
assignees__in=[user_id],
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.values("priority")
|
.values("priority")
|
||||||
@ -1123,6 +1125,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
Issue.issue_objects.filter(
|
Issue.issue_objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
created_by_id=user_id,
|
created_by_id=user_id,
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
@ -1134,6 +1137,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
assignees__in=[user_id],
|
assignees__in=[user_id],
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.count()
|
.count()
|
||||||
@ -1145,6 +1149,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
assignees__in=[user_id],
|
assignees__in=[user_id],
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.count()
|
.count()
|
||||||
@ -1156,6 +1161,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
assignees__in=[user_id],
|
assignees__in=[user_id],
|
||||||
state__group="completed",
|
state__group="completed",
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.count()
|
.count()
|
||||||
@ -1166,6 +1172,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
subscriber_id=user_id,
|
subscriber_id=user_id,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.count()
|
.count()
|
||||||
@ -1215,6 +1222,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
|||||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
actor=user_id,
|
actor=user_id,
|
||||||
).select_related("actor", "workspace", "issue", "project")
|
).select_related("actor", "workspace", "issue", "project")
|
||||||
|
|
||||||
@ -1355,6 +1363,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
|||||||
| Q(issue_subscribers__subscriber_id=user_id),
|
| Q(issue_subscribers__subscriber_id=user_id),
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.select_related("workspace", "project", "state", "parent")
|
.select_related("workspace", "project", "state", "parent")
|
||||||
@ -1486,6 +1495,7 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
|
|||||||
labels = Label.objects.filter(
|
labels = Label.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
serializer = LabelSerializer(labels, many=True).data
|
serializer = LabelSerializer(labels, many=True).data
|
||||||
return Response(serializer, status=status.HTTP_200_OK)
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
@ -1500,6 +1510,7 @@ class WorkspaceStatesEndpoint(BaseAPIView):
|
|||||||
states = State.objects.filter(
|
states = State.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
serializer = StateSerializer(states, many=True).data
|
serializer = StateSerializer(states, many=True).data
|
||||||
return Response(serializer, status=status.HTTP_200_OK)
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
|
@ -292,6 +292,7 @@ def issue_export_task(
|
|||||||
workspace__id=workspace_id,
|
workspace__id=workspace_id,
|
||||||
project_id__in=project_ids,
|
project_id__in=project_ids,
|
||||||
project__project_projectmember__member=exporter_instance.initiated_by_id,
|
project__project_projectmember__member=exporter_instance.initiated_by_id,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.select_related(
|
.select_related(
|
||||||
"project", "workspace", "state", "parent", "created_by"
|
"project", "workspace", "state", "parent", "created_by"
|
||||||
|
@ -483,17 +483,23 @@ def track_archive_at(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
if requested_data.get("automation"):
|
||||||
|
comment = "Plane has archived the issue"
|
||||||
|
new_value = "archive"
|
||||||
|
else:
|
||||||
|
comment = "Actor has archived the issue"
|
||||||
|
new_value = "manual_archive"
|
||||||
issue_activities.append(
|
issue_activities.append(
|
||||||
IssueActivity(
|
IssueActivity(
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
comment="Plane has archived the issue",
|
comment=comment,
|
||||||
verb="updated",
|
verb="updated",
|
||||||
actor_id=actor_id,
|
actor_id=actor_id,
|
||||||
field="archived_at",
|
field="archived_at",
|
||||||
old_value=None,
|
old_value=None,
|
||||||
new_value="archive",
|
new_value=new_value,
|
||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -79,7 +79,7 @@ def archive_old_issues():
|
|||||||
issue_activity.delay(
|
issue_activity.delay(
|
||||||
type="issue.activity.updated",
|
type="issue.activity.updated",
|
||||||
requested_data=json.dumps(
|
requested_data=json.dumps(
|
||||||
{"archived_at": str(archive_at)}
|
{"archived_at": str(archive_at), "automation": True}
|
||||||
),
|
),
|
||||||
actor_id=str(project.created_by_id),
|
actor_id=str(project.created_by_id),
|
||||||
issue_id=issue.id,
|
issue_id=issue.id,
|
||||||
|
@ -9,11 +9,11 @@ from plane.db.models import Issue
|
|||||||
|
|
||||||
|
|
||||||
def search_issues(query, queryset):
|
def search_issues(query, queryset):
|
||||||
fields = ["name", "sequence_id"]
|
fields = ["name", "sequence_id", "project__identifier"]
|
||||||
q = Q()
|
q = Q()
|
||||||
for field in fields:
|
for field in fields:
|
||||||
if field == "sequence_id" and len(query) <= 20:
|
if field == "sequence_id" and len(query) <= 20:
|
||||||
sequences = re.findall(r"[A-Za-z0-9]{1,12}-\d+", query)
|
sequences = re.findall(r"\b\d+\b", query)
|
||||||
for sequence_id in sequences:
|
for sequence_id in sequences:
|
||||||
q |= Q(**{"sequence_id": sequence_id})
|
q |= Q(**{"sequence_id": sequence_id})
|
||||||
else:
|
else:
|
||||||
|
78
deploy/1-click/README.md
Normal file
78
deploy/1-click/README.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# 1-Click Self-Hosting
|
||||||
|
|
||||||
|
In this guide, we will walk you through the process of setting up a 1-click self-hosted environment. Self-hosting allows you to have full control over your applications and data. It's a great way to ensure privacy, control, and customization.
|
||||||
|
|
||||||
|
Let's get started!
|
||||||
|
|
||||||
|
## Installing Plane
|
||||||
|
|
||||||
|
Installing Plane is a very easy and minimal step process.
|
||||||
|
|
||||||
|
### Prerequisite
|
||||||
|
|
||||||
|
- Operating System (latest): Debian / Ubuntu / Centos
|
||||||
|
- Supported CPU Architechture: AMD64 / ARM64 / x86_64 / aarch64
|
||||||
|
|
||||||
|
### Downloading Latest Stable Release
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/1-click/install.sh | sh -
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Downloading Preview Release</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
export BRANCH=preview
|
||||||
|
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/preview/deploy/1-click/install.sh | sh -
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: `Preview` builds do not support ARM64/AARCH64 CPU architecture
|
||||||
|
</details>
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
|
||||||
|
Expect this after a successful install
|
||||||
|
|
||||||
|
![Install Output](images/install.png)
|
||||||
|
|
||||||
|
Access the application on a browser via http://server-ip-address
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Control of your Plane Server Setup
|
||||||
|
|
||||||
|
Plane App is available via the command `plane-app`. Running the command `plane-app --help` helps you to manage Plane
|
||||||
|
|
||||||
|
![Plane Help](images/help.png)
|
||||||
|
|
||||||
|
<ins>Basic Operations</ins>:
|
||||||
|
1. Start Server using `plane-app start`
|
||||||
|
1. Stop Server using `plane-app stop`
|
||||||
|
1. Restart Server using `plane-app restart`
|
||||||
|
|
||||||
|
<ins>Advanced Operations</ins>:
|
||||||
|
1. Configure Plane using `plane-app --configure`. This will give you options to modify
|
||||||
|
- NGINX Port (default 80)
|
||||||
|
- Domain Name (default is the local server public IP address)
|
||||||
|
- File Upload Size (default 5MB)
|
||||||
|
- External Postgres DB Url (optional - default empty)
|
||||||
|
- External Redis URL (optional - default empty)
|
||||||
|
- AWS S3 Bucket (optional - to be configured only in case the user wants to use an S3 Bucket)
|
||||||
|
|
||||||
|
1. Upgrade Plane using `plane-app --upgrade`. This will get the latest stable version of Plane files (docker-compose.yaml, .env, and docker images)
|
||||||
|
|
||||||
|
1. Updating Plane App installer using `plane-app --update-installer` will update the `plane-app` utility.
|
||||||
|
|
||||||
|
1. Uninstall Plane using `plane-app --uninstall`. This will uninstall the Plane application from the server and all docker containers but do not remove the data stored in Postgres, Redis, and Minio.
|
||||||
|
|
||||||
|
1. Plane App can be reinstalled using `plane-app --install`.
|
||||||
|
|
||||||
|
<ins>Application Data is stored in the mentioned folders</ins>:
|
||||||
|
1. DB Data: /opt/plane/data/postgres
|
||||||
|
1. Redis Data: /opt/plane/data/redis
|
||||||
|
1. Minio Data: /opt/plane/data/minio
|
BIN
deploy/1-click/images/help.png
Normal file
BIN
deploy/1-click/images/help.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 109 KiB |
BIN
deploy/1-click/images/install.png
Normal file
BIN
deploy/1-click/images/install.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 173 KiB |
@ -1,17 +1,20 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
export GIT_REPO=makeplane/plane
|
||||||
|
|
||||||
# Check if the user has sudo access
|
# Check if the user has sudo access
|
||||||
if command -v curl &> /dev/null; then
|
if command -v curl &> /dev/null; then
|
||||||
sudo curl -sSL \
|
sudo curl -sSL \
|
||||||
-o /usr/local/bin/plane-app \
|
-o /usr/local/bin/plane-app \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
||||||
else
|
else
|
||||||
sudo wget -q \
|
sudo wget -q \
|
||||||
-O /usr/local/bin/plane-app \
|
-O /usr/local/bin/plane-app \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sudo chmod +x /usr/local/bin/plane-app
|
sudo chmod +x /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 sed -i 's@export DEPLOY_BRANCH=${BRANCH:-master}@export DEPLOY_BRANCH='${BRANCH:-master}'@' /usr/local/bin/plane-app
|
||||||
|
sudo sed -i 's@CODE_REPO=${GIT_REPO:-makeplane/plane}@CODE_REPO='$GIT_REPO'@' /usr/local/bin/plane-app
|
||||||
|
|
||||||
plane-app --help
|
plane-app -i #--help
|
||||||
|
@ -90,9 +90,9 @@ function prepare_environment() {
|
|||||||
|
|
||||||
show_message "- Updating OS with required tools ✋" >&2
|
show_message "- Updating OS with required tools ✋" >&2
|
||||||
sudo "$PACKAGE_MANAGER" update -y
|
sudo "$PACKAGE_MANAGER" update -y
|
||||||
sudo "$PACKAGE_MANAGER" upgrade -y
|
# sudo "$PACKAGE_MANAGER" upgrade -y
|
||||||
|
|
||||||
local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap")
|
local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap" "jq")
|
||||||
|
|
||||||
for tool in "${required_tools[@]}"; do
|
for tool in "${required_tools[@]}"; do
|
||||||
if ! command -v $tool &> /dev/null; then
|
if ! command -v $tool &> /dev/null; then
|
||||||
@ -150,11 +150,11 @@ function download_plane() {
|
|||||||
show_message "Downloading Plane Setup Files ✋" >&2
|
show_message "Downloading Plane Setup Files ✋" >&2
|
||||||
sudo 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 \
|
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s)
|
https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s)
|
||||||
|
|
||||||
sudo 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 \
|
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s)
|
https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s)
|
||||||
|
|
||||||
# if .env does not exists rename variables-upgrade.env to .env
|
# if .env does not exists rename variables-upgrade.env to .env
|
||||||
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
|
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
|
||||||
@ -202,7 +202,7 @@ function printUsageInstructions() {
|
|||||||
}
|
}
|
||||||
function build_local_image() {
|
function build_local_image() {
|
||||||
show_message "- Downloading Plane Source Code ✋" >&2
|
show_message "- Downloading Plane Source Code ✋" >&2
|
||||||
REPO=https://github.com/makeplane/plane.git
|
REPO=https://github.com/$CODE_REPO.git
|
||||||
CURR_DIR=$PWD
|
CURR_DIR=$PWD
|
||||||
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
|
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
|
||||||
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
|
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
|
||||||
@ -290,40 +290,40 @@ function configure_plane() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
smtp_host=$(read_env "EMAIL_HOST")
|
# smtp_host=$(read_env "EMAIL_HOST")
|
||||||
smtp_user=$(read_env "EMAIL_HOST_USER")
|
# smtp_user=$(read_env "EMAIL_HOST_USER")
|
||||||
smtp_password=$(read_env "EMAIL_HOST_PASSWORD")
|
# smtp_password=$(read_env "EMAIL_HOST_PASSWORD")
|
||||||
smtp_port=$(read_env "EMAIL_PORT")
|
# smtp_port=$(read_env "EMAIL_PORT")
|
||||||
smtp_from=$(read_env "EMAIL_FROM")
|
# smtp_from=$(read_env "EMAIL_FROM")
|
||||||
smtp_tls=$(read_env "EMAIL_USE_TLS")
|
# smtp_tls=$(read_env "EMAIL_USE_TLS")
|
||||||
smtp_ssl=$(read_env "EMAIL_USE_SSL")
|
# smtp_ssl=$(read_env "EMAIL_USE_SSL")
|
||||||
|
|
||||||
SMTP_SETTINGS=$(dialog \
|
# SMTP_SETTINGS=$(dialog \
|
||||||
--ok-label "Next" \
|
# --ok-label "Next" \
|
||||||
--cancel-label "Skip" \
|
# --cancel-label "Skip" \
|
||||||
--backtitle "Plane Configuration" \
|
# --backtitle "Plane Configuration" \
|
||||||
--title "SMTP Settings" \
|
# --title "SMTP Settings" \
|
||||||
--form "" \
|
# --form "" \
|
||||||
0 0 0 \
|
# 0 0 0 \
|
||||||
"Host:" 1 1 "$smtp_host" 1 10 80 0 \
|
# "Host:" 1 1 "$smtp_host" 1 10 80 0 \
|
||||||
"User:" 2 1 "$smtp_user" 2 10 80 0 \
|
# "User:" 2 1 "$smtp_user" 2 10 80 0 \
|
||||||
"Password:" 3 1 "$smtp_password" 3 10 80 0 \
|
# "Password:" 3 1 "$smtp_password" 3 10 80 0 \
|
||||||
"Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \
|
# "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \
|
||||||
"From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \
|
# "From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \
|
||||||
"TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \
|
# "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \
|
||||||
"SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \
|
# "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \
|
||||||
2>&1 1>&3)
|
# 2>&1 1>&3)
|
||||||
|
|
||||||
save_smtp_settings=0
|
# save_smtp_settings=0
|
||||||
if [ $? -eq 0 ]; then
|
# if [ $? -eq 0 ]; then
|
||||||
save_smtp_settings=1
|
# save_smtp_settings=1
|
||||||
smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p)
|
# smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p)
|
||||||
smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p)
|
# smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p)
|
||||||
smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p)
|
# smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p)
|
||||||
smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p)
|
# smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p)
|
||||||
smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p)
|
# smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p)
|
||||||
smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p)
|
# smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p)
|
||||||
fi
|
# fi
|
||||||
external_pgdb_url=$(dialog \
|
external_pgdb_url=$(dialog \
|
||||||
--backtitle "Plane Configuration" \
|
--backtitle "Plane Configuration" \
|
||||||
--title "Using External Postgres Database ?" \
|
--title "Using External Postgres Database ?" \
|
||||||
@ -383,15 +383,6 @@ function configure_plane() {
|
|||||||
domain_name: $domain_name
|
domain_name: $domain_name
|
||||||
upload_limit: $upload_limit
|
upload_limit: $upload_limit
|
||||||
|
|
||||||
save_smtp_settings: $save_smtp_settings
|
|
||||||
smtp_host: $smtp_host
|
|
||||||
smtp_user: $smtp_user
|
|
||||||
smtp_password: $smtp_password
|
|
||||||
smtp_port: $smtp_port
|
|
||||||
smtp_from: $smtp_from
|
|
||||||
smtp_tls: $smtp_tls
|
|
||||||
smtp_ssl: $smtp_ssl
|
|
||||||
|
|
||||||
save_aws_settings: $save_aws_settings
|
save_aws_settings: $save_aws_settings
|
||||||
aws_region: $aws_region
|
aws_region: $aws_region
|
||||||
aws_access_key: $aws_access_key
|
aws_access_key: $aws_access_key
|
||||||
@ -413,15 +404,15 @@ function configure_plane() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# check enable smpt settings value
|
# check enable smpt settings value
|
||||||
if [ $save_smtp_settings == 1 ]; then
|
# if [ $save_smtp_settings == 1 ]; then
|
||||||
update_env "EMAIL_HOST" "$smtp_host"
|
# update_env "EMAIL_HOST" "$smtp_host"
|
||||||
update_env "EMAIL_HOST_USER" "$smtp_user"
|
# update_env "EMAIL_HOST_USER" "$smtp_user"
|
||||||
update_env "EMAIL_HOST_PASSWORD" "$smtp_password"
|
# update_env "EMAIL_HOST_PASSWORD" "$smtp_password"
|
||||||
update_env "EMAIL_PORT" "$smtp_port"
|
# update_env "EMAIL_PORT" "$smtp_port"
|
||||||
update_env "EMAIL_FROM" "$smtp_from"
|
# update_env "EMAIL_FROM" "$smtp_from"
|
||||||
update_env "EMAIL_USE_TLS" "$smtp_tls"
|
# update_env "EMAIL_USE_TLS" "$smtp_tls"
|
||||||
update_env "EMAIL_USE_SSL" "$smtp_ssl"
|
# update_env "EMAIL_USE_SSL" "$smtp_ssl"
|
||||||
fi
|
# fi
|
||||||
|
|
||||||
# check enable aws settings value
|
# check enable aws settings value
|
||||||
if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then
|
if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then
|
||||||
@ -493,13 +484,24 @@ function install() {
|
|||||||
check_for_docker_images
|
check_for_docker_images
|
||||||
|
|
||||||
last_installed_on=$(read_config "INSTALLATION_DATE")
|
last_installed_on=$(read_config "INSTALLATION_DATE")
|
||||||
if [ "$last_installed_on" == "" ]; then
|
# if [ "$last_installed_on" == "" ]; then
|
||||||
configure_plane
|
# configure_plane
|
||||||
fi
|
# fi
|
||||||
printUsageInstructions
|
|
||||||
|
|
||||||
update_config "INSTALLATION_DATE" "$(date)"
|
|
||||||
|
|
||||||
|
update_env "NGINX_PORT" "80"
|
||||||
|
update_env "DOMAIN_NAME" "$MY_IP"
|
||||||
|
update_env "WEB_URL" "http://$MY_IP"
|
||||||
|
update_env "CORS_ALLOWED_ORIGINS" "http://$MY_IP"
|
||||||
|
|
||||||
|
update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')"
|
||||||
|
|
||||||
|
if command -v crontab &> /dev/null; then
|
||||||
|
sudo touch /etc/cron.daily/makeplane
|
||||||
|
sudo chmod +x /etc/cron.daily/makeplane
|
||||||
|
sudo echo "0 2 * * * root /usr/local/bin/plane-app --upgrade" > /etc/cron.daily/makeplane
|
||||||
|
sudo crontab /etc/cron.daily/makeplane
|
||||||
|
fi
|
||||||
|
|
||||||
show_message "Plane Installed Successfully ✅"
|
show_message "Plane Installed Successfully ✅"
|
||||||
show_message ""
|
show_message ""
|
||||||
else
|
else
|
||||||
@ -539,12 +541,15 @@ function upgrade() {
|
|||||||
prepare_environment
|
prepare_environment
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
|
stop_server
|
||||||
download_plane
|
download_plane
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
check_for_docker_images
|
check_for_docker_images
|
||||||
upgrade_configuration
|
upgrade_configuration
|
||||||
update_config "UPGRADE_DATE" "$(date)"
|
update_config "UPGRADE_DATE" "$(date)"
|
||||||
|
|
||||||
|
start_server
|
||||||
|
|
||||||
show_message ""
|
show_message ""
|
||||||
show_message "Plane Upgraded Successfully ✅"
|
show_message "Plane Upgraded Successfully ✅"
|
||||||
show_message ""
|
show_message ""
|
||||||
@ -601,6 +606,11 @@ function uninstall() {
|
|||||||
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.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/config.env &> /dev/null
|
||||||
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
||||||
|
|
||||||
|
if command -v crontab &> /dev/null; then
|
||||||
|
sudo crontab -r &> /dev/null
|
||||||
|
sudo rm /etc/cron.daily/makeplane &> /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
|
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
|
||||||
show_message "- Configuration Cleaned ✅"
|
show_message "- Configuration Cleaned ✅"
|
||||||
@ -642,7 +652,39 @@ function start_server() {
|
|||||||
while ! sudo 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
|
sleep 1
|
||||||
done
|
done
|
||||||
|
# wait for migrator container to exit with status 0 before starting the application
|
||||||
|
migrator_container_id=$(sudo docker container ls -aq -f "name=plane-migrator")
|
||||||
|
|
||||||
|
# if migrator container is running, wait for it to exit
|
||||||
|
if [ -n "$migrator_container_id" ]; then
|
||||||
|
while sudo docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do
|
||||||
|
show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (Migrator in progress)" "replace_last_line" >&2
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# if migrator exit status is not 0, show error message and exit
|
||||||
|
if [ -n "$migrator_container_id" ]; then
|
||||||
|
migrator_exit_code=$(sudo docker inspect --format='{{.State.ExitCode}}' $migrator_container_id)
|
||||||
|
if [ $migrator_exit_code -ne 0 ]; then
|
||||||
|
# show_message "Migrator failed with exit code $migrator_exit_code ❌" "replace_last_line" >&2
|
||||||
|
show_message "Plane Server failed to start ❌" "replace_last_line" >&2
|
||||||
|
stop_server
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_container_id=$(sudo docker container ls -q -f "name=plane-api")
|
||||||
|
while ! sudo docker logs $api_container_id 2>&1 | grep -i "Application startup complete";
|
||||||
|
do
|
||||||
|
show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (API starting)" "replace_last_line" >&2
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
|
show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
|
||||||
|
show_message "---------------------------------------------------------------" >&2
|
||||||
|
show_message "Access the Plane application at http://$MY_IP" >&2
|
||||||
|
show_message "---------------------------------------------------------------" >&2
|
||||||
|
|
||||||
else
|
else
|
||||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||||
fi
|
fi
|
||||||
@ -694,7 +736,7 @@ function update_installer() {
|
|||||||
show_message "Updating Plane Installer ✋" >&2
|
show_message "Updating Plane Installer ✋" >&2
|
||||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||||
-s -o /usr/local/bin/plane-app \
|
-s -o /usr/local/bin/plane-app \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s)
|
https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s)
|
||||||
|
|
||||||
sudo 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
|
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
|
||||||
@ -711,12 +753,14 @@ fi
|
|||||||
|
|
||||||
PLANE_INSTALL_DIR=/opt/plane
|
PLANE_INSTALL_DIR=/opt/plane
|
||||||
DATA_DIR=$PLANE_INSTALL_DIR/data
|
DATA_DIR=$PLANE_INSTALL_DIR/data
|
||||||
LOG_DIR=$PLANE_INSTALL_DIR/log
|
LOG_DIR=$PLANE_INSTALL_DIR/logs
|
||||||
|
CODE_REPO=${GIT_REPO:-makeplane/plane}
|
||||||
OS_SUPPORTED=false
|
OS_SUPPORTED=false
|
||||||
CPU_ARCH=$(uname -m)
|
CPU_ARCH=$(uname -m)
|
||||||
PROGRESS_MSG=""
|
PROGRESS_MSG=""
|
||||||
USE_GLOBAL_IMAGES=0
|
USE_GLOBAL_IMAGES=0
|
||||||
PACKAGE_MANAGER=""
|
PACKAGE_MANAGER=""
|
||||||
|
MY_IP=$(curl -s ifconfig.me)
|
||||||
|
|
||||||
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then
|
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then
|
||||||
USE_GLOBAL_IMAGES=1
|
USE_GLOBAL_IMAGES=1
|
||||||
@ -740,6 +784,9 @@ elif [ "$1" == "restart" ]; then
|
|||||||
restart_server
|
restart_server
|
||||||
elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then
|
elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then
|
||||||
install
|
install
|
||||||
|
start_server
|
||||||
|
show_message "" >&2
|
||||||
|
show_message "To view help, use plane-app --help " >&2
|
||||||
elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then
|
elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then
|
||||||
configure_plane
|
configure_plane
|
||||||
printUsageInstructions
|
printUsageInstructions
|
||||||
|
@ -56,8 +56,6 @@ x-app-env : &app-env
|
|||||||
- BUCKET_NAME=${BUCKET_NAME:-uploads}
|
- BUCKET_NAME=${BUCKET_NAME:-uploads}
|
||||||
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
@ -138,7 +136,6 @@ services:
|
|||||||
command: postgres -c 'max_connections=1000'
|
command: postgres -c 'max_connections=1000'
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
plane-redis:
|
plane-redis:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
image: redis:6.2.7-alpine
|
image: redis:6.2.7-alpine
|
||||||
|
@ -13,6 +13,23 @@ YELLOW='\033[1;33m'
|
|||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
function print_header() {
|
||||||
|
clear
|
||||||
|
|
||||||
|
cat <<"EOF"
|
||||||
|
---------------------------------------
|
||||||
|
____ _
|
||||||
|
| _ \| | __ _ _ __ ___
|
||||||
|
| |_) | |/ _` | '_ \ / _ \
|
||||||
|
| __/| | (_| | | | | __/
|
||||||
|
|_| |_|\__,_|_| |_|\___|
|
||||||
|
|
||||||
|
---------------------------------------
|
||||||
|
Project management tool from the future
|
||||||
|
---------------------------------------
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
function buildLocalImage() {
|
function buildLocalImage() {
|
||||||
if [ "$1" == "--force-build" ]; then
|
if [ "$1" == "--force-build" ]; then
|
||||||
DO_BUILD="1"
|
DO_BUILD="1"
|
||||||
@ -110,7 +127,7 @@ function download() {
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml pull
|
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@ -121,19 +138,48 @@ function download() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
function startServices() {
|
function startServices() {
|
||||||
cd $PLANE_INSTALL_DIR
|
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull
|
||||||
docker compose up -d --quiet-pull
|
|
||||||
cd $SCRIPT_DIR
|
local migrator_container_id=$(docker container ls -aq -f "name=plane-app-migrator")
|
||||||
|
if [ -n "$migrator_container_id" ]; then
|
||||||
|
local idx=0
|
||||||
|
while docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do
|
||||||
|
local message=">>> Waiting for Data Migration to finish"
|
||||||
|
local dots=$(printf '%*s' $idx | tr ' ' '.')
|
||||||
|
echo -ne "\r$message$dots"
|
||||||
|
((idx++))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
printf "\r\033[K"
|
||||||
|
|
||||||
|
# if migrator exit status is not 0, show error message and exit
|
||||||
|
if [ -n "$migrator_container_id" ]; then
|
||||||
|
local migrator_exit_code=$(docker inspect --format='{{.State.ExitCode}}' $migrator_container_id)
|
||||||
|
if [ $migrator_exit_code -ne 0 ]; then
|
||||||
|
echo "Plane Server failed to start ❌"
|
||||||
|
stopServices
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local api_container_id=$(docker container ls -q -f "name=plane-app-api")
|
||||||
|
local idx2=0
|
||||||
|
while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q ".";
|
||||||
|
do
|
||||||
|
local message=">>> Waiting for API Service to Start"
|
||||||
|
local dots=$(printf '%*s' $idx2 | tr ' ' '.')
|
||||||
|
echo -ne "\r$message$dots"
|
||||||
|
((idx2++))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
printf "\r\033[K"
|
||||||
}
|
}
|
||||||
function stopServices() {
|
function stopServices() {
|
||||||
cd $PLANE_INSTALL_DIR
|
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH down
|
||||||
docker compose down
|
|
||||||
cd $SCRIPT_DIR
|
|
||||||
}
|
}
|
||||||
function restartServices() {
|
function restartServices() {
|
||||||
cd $PLANE_INSTALL_DIR
|
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH restart
|
||||||
docker compose restart
|
|
||||||
cd $SCRIPT_DIR
|
|
||||||
}
|
}
|
||||||
function upgrade() {
|
function upgrade() {
|
||||||
echo "***** STOPPING SERVICES ****"
|
echo "***** STOPPING SERVICES ****"
|
||||||
@ -144,47 +190,137 @@ function upgrade() {
|
|||||||
download
|
download
|
||||||
|
|
||||||
echo "***** PLEASE VALIDATE AND START SERVICES ****"
|
echo "***** PLEASE VALIDATE AND START SERVICES ****"
|
||||||
|
}
|
||||||
|
function viewSpecificLogs(){
|
||||||
|
local SERVICE_NAME=$1
|
||||||
|
|
||||||
|
if docker-compose -f $DOCKER_FILE_PATH ps | grep -q "$SERVICE_NAME"; then
|
||||||
|
echo "Service '$SERVICE_NAME' is running."
|
||||||
|
else
|
||||||
|
echo "Service '$SERVICE_NAME' is not running."
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose -f $DOCKER_FILE_PATH logs -f $SERVICE_NAME
|
||||||
|
}
|
||||||
|
function viewLogs(){
|
||||||
|
|
||||||
|
ARG_SERVICE_NAME=$2
|
||||||
|
|
||||||
|
if [ -z "$ARG_SERVICE_NAME" ];
|
||||||
|
then
|
||||||
|
echo
|
||||||
|
echo "Select a Service you want to view the logs for:"
|
||||||
|
echo " 1) Web"
|
||||||
|
echo " 2) Space"
|
||||||
|
echo " 3) API"
|
||||||
|
echo " 4) Worker"
|
||||||
|
echo " 5) Beat-Worker"
|
||||||
|
echo " 6) Migrator"
|
||||||
|
echo " 7) Proxy"
|
||||||
|
echo " 8) Redis"
|
||||||
|
echo " 9) Postgres"
|
||||||
|
echo " 10) Minio"
|
||||||
|
echo " 0) Back to Main Menu"
|
||||||
|
echo
|
||||||
|
read -p "Service: " DOCKER_SERVICE_NAME
|
||||||
|
|
||||||
|
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 10 )); do
|
||||||
|
echo "Invalid selection. Please enter a number between 1 and 11."
|
||||||
|
read -p "Service: " DOCKER_SERVICE_NAME
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$DOCKER_SERVICE_NAME" ];
|
||||||
|
then
|
||||||
|
echo "INVALID SERVICE NAME SUPPLIED"
|
||||||
|
else
|
||||||
|
case $DOCKER_SERVICE_NAME in
|
||||||
|
1) viewSpecificLogs "web";;
|
||||||
|
2) viewSpecificLogs "space";;
|
||||||
|
3) viewSpecificLogs "api";;
|
||||||
|
4) viewSpecificLogs "worker";;
|
||||||
|
5) viewSpecificLogs "beat-worker";;
|
||||||
|
6) viewSpecificLogs "migrator";;
|
||||||
|
7) viewSpecificLogs "proxy";;
|
||||||
|
8) viewSpecificLogs "plane-redis";;
|
||||||
|
9) viewSpecificLogs "plane-db";;
|
||||||
|
10) viewSpecificLogs "plane-minio";;
|
||||||
|
0) askForAction;;
|
||||||
|
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
elif [ -n "$ARG_SERVICE_NAME" ];
|
||||||
|
then
|
||||||
|
ARG_SERVICE_NAME=$(echo "$ARG_SERVICE_NAME" | tr '[:upper:]' '[:lower:]')
|
||||||
|
case $ARG_SERVICE_NAME in
|
||||||
|
web) viewSpecificLogs "web";;
|
||||||
|
space) viewSpecificLogs "space";;
|
||||||
|
api) viewSpecificLogs "api";;
|
||||||
|
worker) viewSpecificLogs "worker";;
|
||||||
|
beat-worker) viewSpecificLogs "beat-worker";;
|
||||||
|
migrator) viewSpecificLogs "migrator";;
|
||||||
|
proxy) viewSpecificLogs "proxy";;
|
||||||
|
redis) viewSpecificLogs "plane-redis";;
|
||||||
|
postgres) viewSpecificLogs "plane-db";;
|
||||||
|
minio) viewSpecificLogs "plane-minio";;
|
||||||
|
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
echo "INVALID SERVICE NAME SUPPLIED"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
function askForAction() {
|
function askForAction() {
|
||||||
echo
|
local DEFAULT_ACTION=$1
|
||||||
echo "Select a Action you want to perform:"
|
|
||||||
echo " 1) Install (${CPU_ARCH})"
|
if [ -z "$DEFAULT_ACTION" ];
|
||||||
echo " 2) Start"
|
then
|
||||||
echo " 3) Stop"
|
echo
|
||||||
echo " 4) Restart"
|
echo "Select a Action you want to perform:"
|
||||||
echo " 5) Upgrade"
|
echo " 1) Install (${CPU_ARCH})"
|
||||||
echo " 6) Exit"
|
echo " 2) Start"
|
||||||
echo
|
echo " 3) Stop"
|
||||||
read -p "Action [2]: " ACTION
|
echo " 4) Restart"
|
||||||
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do
|
echo " 5) Upgrade"
|
||||||
echo "$ACTION: invalid selection."
|
echo " 6) View Logs"
|
||||||
|
echo " 7) Exit"
|
||||||
|
echo
|
||||||
read -p "Action [2]: " ACTION
|
read -p "Action [2]: " ACTION
|
||||||
done
|
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-7]$ ]]; do
|
||||||
echo
|
echo "$ACTION: invalid selection."
|
||||||
|
read -p "Action [2]: " ACTION
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$ACTION" ];
|
||||||
|
then
|
||||||
|
ACTION=2
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$ACTION" == "1" ]
|
if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "install" ]
|
||||||
then
|
then
|
||||||
install
|
install
|
||||||
askForAction
|
askForAction
|
||||||
elif [ "$ACTION" == "2" ] || [ "$ACTION" == "" ]
|
elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "start" ]
|
||||||
then
|
then
|
||||||
startServices
|
startServices
|
||||||
askForAction
|
askForAction
|
||||||
elif [ "$ACTION" == "3" ]
|
elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "stop" ]
|
||||||
then
|
then
|
||||||
stopServices
|
stopServices
|
||||||
askForAction
|
askForAction
|
||||||
elif [ "$ACTION" == "4" ]
|
elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "restart" ]
|
||||||
then
|
then
|
||||||
restartServices
|
restartServices
|
||||||
askForAction
|
askForAction
|
||||||
elif [ "$ACTION" == "5" ]
|
elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ]
|
||||||
then
|
then
|
||||||
upgrade
|
upgrade
|
||||||
askForAction
|
askForAction
|
||||||
elif [ "$ACTION" == "6" ]
|
elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ]
|
||||||
|
then
|
||||||
|
viewLogs $@
|
||||||
|
askForAction
|
||||||
|
elif [ "$ACTION" == "7" ]
|
||||||
then
|
then
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
@ -217,4 +353,8 @@ then
|
|||||||
fi
|
fi
|
||||||
mkdir -p $PLANE_INSTALL_DIR/archive
|
mkdir -p $PLANE_INSTALL_DIR/archive
|
||||||
|
|
||||||
askForAction
|
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
|
||||||
|
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/.env
|
||||||
|
|
||||||
|
print_header
|
||||||
|
askForAction $@
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"repository": "https://github.com/makeplane/plane.git",
|
"repository": "https://github.com/makeplane/plane.git",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/editor-core",
|
"name": "@plane/editor-core",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "Core Editor that powers Plane",
|
"description": "Core Editor that powers Plane",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/document-editor",
|
"name": "@plane/document-editor",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "Package that powers Plane's Pages Editor",
|
"description": "Package that powers Plane's Pages Editor",
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/editor-extensions",
|
"name": "@plane/editor-extensions",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "Package that powers Plane's Editor with extensions",
|
"description": "Package that powers Plane's Editor with extensions",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/lite-text-editor",
|
"name": "@plane/lite-text-editor",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "Package that powers Plane's Comment Editor",
|
"description": "Package that powers Plane's Comment Editor",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/rich-text-editor",
|
"name": "@plane/rich-text-editor",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "Rich Text Editor that powers Plane",
|
"description": "Rich Text Editor that powers Plane",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "eslint-config-custom",
|
"name": "eslint-config-custom",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tailwind-config-custom",
|
"name": "tailwind-config-custom",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "common tailwind configuration across monorepo",
|
"description": "common tailwind configuration across monorepo",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tsconfig",
|
"name": "tsconfig",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/types",
|
"name": "@plane/types",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/index.d.ts"
|
"main": "./src/index.d.ts"
|
||||||
}
|
}
|
||||||
|
24
packages/types/src/notifications.d.ts
vendored
24
packages/types/src/notifications.d.ts
vendored
@ -12,27 +12,27 @@ export interface PaginatedUserNotification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IUserNotification {
|
export interface IUserNotification {
|
||||||
id: string;
|
archived_at: string | null;
|
||||||
created_at: Date;
|
created_at: string;
|
||||||
updated_at: Date;
|
created_by: null;
|
||||||
data: Data;
|
data: Data;
|
||||||
entity_identifier: string;
|
entity_identifier: string;
|
||||||
entity_name: string;
|
entity_name: string;
|
||||||
title: string;
|
id: string;
|
||||||
message: null;
|
message: null;
|
||||||
message_html: string;
|
message_html: string;
|
||||||
message_stripped: null;
|
message_stripped: null;
|
||||||
sender: string;
|
|
||||||
read_at: Date | null;
|
|
||||||
archived_at: Date | null;
|
|
||||||
snoozed_till: Date | null;
|
|
||||||
created_by: null;
|
|
||||||
updated_by: null;
|
|
||||||
workspace: string;
|
|
||||||
project: string;
|
project: string;
|
||||||
|
read_at: Date | null;
|
||||||
|
receiver: string;
|
||||||
|
sender: string;
|
||||||
|
snoozed_till: Date | null;
|
||||||
|
title: string;
|
||||||
triggered_by: string;
|
triggered_by: string;
|
||||||
triggered_by_details: IUserLite;
|
triggered_by_details: IUserLite;
|
||||||
receiver: string;
|
updated_at: Date;
|
||||||
|
updated_by: null;
|
||||||
|
workspace: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Data {
|
export interface Data {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"name": "@plane/ui",
|
"name": "@plane/ui",
|
||||||
"description": "UI components shared across multiple apps internally",
|
"description": "UI components shared across multiple apps internally",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
@ -177,17 +177,18 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||||
const { children, onClick, className = "" } = props;
|
const { children, disabled = false, onClick, className } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu.Item as="div">
|
<Menu.Item as="div" disabled={disabled}>
|
||||||
{({ active, close }) => (
|
{({ active, close }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200",
|
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200",
|
||||||
{
|
{
|
||||||
"bg-custom-background-80": active,
|
"bg-custom-background-80": active && !disabled,
|
||||||
|
"text-custom-text-400": disabled,
|
||||||
},
|
},
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@ -195,6 +196,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
|||||||
close();
|
close();
|
||||||
onClick && onClick(e);
|
onClick && onClick(e);
|
||||||
}}
|
}}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
@ -64,6 +64,7 @@ export type ICustomSearchSelectProps = IDropdownProps &
|
|||||||
|
|
||||||
export interface ICustomMenuItemProps {
|
export interface ICustomMenuItemProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
onClick?: (args?: any) => void;
|
onClick?: (args?: any) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space",
|
"name": "space",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo run develop",
|
"dev": "turbo run develop",
|
||||||
|
@ -48,7 +48,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
|
|||||||
<div className="">
|
<div className="">
|
||||||
<h4 className="text-sm font-medium">Auto-archive closed issues</h4>
|
<h4 className="text-sm font-medium">Auto-archive closed issues</h4>
|
||||||
<p className="text-sm tracking-tight text-custom-text-200">
|
<p className="text-sm tracking-tight text-custom-text-200">
|
||||||
Plane will auto archive issues that have been completed or cancelled.
|
Plane will auto archive issues that have been completed or canceled.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -73,7 +73,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
|
|||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={currentProjectDetails?.archive_in}
|
value={currentProjectDetails?.archive_in}
|
||||||
label={`${currentProjectDetails?.archive_in} ${
|
label={`${currentProjectDetails?.archive_in} ${
|
||||||
currentProjectDetails?.archive_in === 1 ? "Month" : "Months"
|
currentProjectDetails?.archive_in === 1 ? "month" : "months"
|
||||||
}`}
|
}`}
|
||||||
onChange={(val: number) => {
|
onChange={(val: number) => {
|
||||||
handleChange({ archive_in: val });
|
handleChange({ archive_in: val });
|
||||||
@ -93,7 +93,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
|
|||||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80"
|
className="flex w-full select-none items-center rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80"
|
||||||
onClick={() => setmonthModal(true)}
|
onClick={() => setmonthModal(true)}
|
||||||
>
|
>
|
||||||
Customise Time Range
|
Customize time range
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
</CustomSelect>
|
</CustomSelect>
|
||||||
|
@ -74,7 +74,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
|
|||||||
<div className="">
|
<div className="">
|
||||||
<h4 className="text-sm font-medium">Auto-close issues</h4>
|
<h4 className="text-sm font-medium">Auto-close issues</h4>
|
||||||
<p className="text-sm tracking-tight text-custom-text-200">
|
<p className="text-sm tracking-tight text-custom-text-200">
|
||||||
Plane will automatically close issue that haven{"'"}t been completed or cancelled.
|
Plane will automatically close issue that haven{"'"}t been completed or canceled.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -100,7 +100,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
|
|||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={currentProjectDetails?.close_in}
|
value={currentProjectDetails?.close_in}
|
||||||
label={`${currentProjectDetails?.close_in} ${
|
label={`${currentProjectDetails?.close_in} ${
|
||||||
currentProjectDetails?.close_in === 1 ? "Month" : "Months"
|
currentProjectDetails?.close_in === 1 ? "month" : "months"
|
||||||
}`}
|
}`}
|
||||||
onChange={(val: number) => {
|
onChange={(val: number) => {
|
||||||
handleChange({ close_in: val });
|
handleChange({ close_in: val });
|
||||||
@ -119,7 +119,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
|
|||||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||||
onClick={() => setmonthModal(true)}
|
onClick={() => setmonthModal(true)}
|
||||||
>
|
>
|
||||||
Customize Time Range
|
Customize time range
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
</CustomSelect>
|
</CustomSelect>
|
||||||
|
@ -72,7 +72,7 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
|
|||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||||
Customise Time Range
|
Customize time range
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div className="mt-8 flex items-center gap-2">
|
<div className="mt-8 flex items-center gap-2">
|
||||||
<div className="flex w-full flex-col justify-center gap-1">
|
<div className="flex w-full flex-col justify-center gap-1">
|
||||||
|
@ -154,237 +154,239 @@ export const CommandModal: React.FC = observer(() => {
|
|||||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
|
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||||
<Transition.Child
|
<div className="flex items-center justify-center p-4 sm:p-6 md:p-20">
|
||||||
as={React.Fragment}
|
<Transition.Child
|
||||||
enter="ease-out duration-300"
|
as={React.Fragment}
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
enter="ease-out duration-300"
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
leave="ease-in duration-200"
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leave="ease-in duration-200"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
>
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
<Dialog.Panel className="relative flex w-full items-center justify-center ">
|
>
|
||||||
<div className="w-full max-w-2xl transform divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
|
<Dialog.Panel className="relative flex w-full max-w-2xl items-center justify-center transform divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
|
||||||
<Command
|
<div className="w-full max-w-2xl">
|
||||||
filter={(value, search) => {
|
<Command
|
||||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
filter={(value, search) => {
|
||||||
return 0;
|
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||||
}}
|
return 0;
|
||||||
onKeyDown={(e) => {
|
}}
|
||||||
// when search is empty and page is undefined
|
onKeyDown={(e) => {
|
||||||
// when user tries to close the modal with esc
|
// when search is empty and page is undefined
|
||||||
if (e.key === "Escape" && !page && !searchTerm) closePalette();
|
// when user tries to close the modal with esc
|
||||||
|
if (e.key === "Escape" && !page && !searchTerm) closePalette();
|
||||||
|
|
||||||
// Escape goes to previous page
|
// Escape goes to previous page
|
||||||
// Backspace goes to previous page when search is empty
|
// Backspace goes to previous page when search is empty
|
||||||
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
|
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPages((pages) => pages.slice(0, -1));
|
setPages((pages) => pages.slice(0, -1));
|
||||||
setPlaceholder("Type a command or search...");
|
setPlaceholder("Type a command or search...");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex gap-4 p-3 pb-0 sm:items-center ${
|
|
||||||
issueDetails ? "flex-col justify-between sm:flex-row" : "justify-end"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{issueDetails && (
|
<div
|
||||||
<div className="overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
|
className={`flex gap-4 p-3 pb-0 sm:items-center ${
|
||||||
{projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name}
|
issueDetails ? "flex-col justify-between sm:flex-row" : "justify-end"
|
||||||
</div>
|
}`}
|
||||||
)}
|
>
|
||||||
{projectId && (
|
{issueDetails && (
|
||||||
<Tooltip tooltipContent="Toggle workspace level search">
|
<div className="overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
|
||||||
<div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center">
|
{projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
Workspace Level
|
|
||||||
</button>
|
|
||||||
<ToggleSwitch
|
|
||||||
value={isWorkspaceLevel}
|
|
||||||
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
)}
|
||||||
)}
|
{projectId && (
|
||||||
</div>
|
<Tooltip tooltipContent="Toggle workspace level search">
|
||||||
<div className="relative">
|
<div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center">
|
||||||
<Search
|
<button
|
||||||
className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-200"
|
type="button"
|
||||||
aria-hidden="true"
|
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||||
strokeWidth={2}
|
className="flex-shrink-0"
|
||||||
/>
|
>
|
||||||
<Command.Input
|
Workspace Level
|
||||||
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
|
</button>
|
||||||
placeholder={placeholder}
|
<ToggleSwitch
|
||||||
value={searchTerm}
|
value={isWorkspaceLevel}
|
||||||
onValueChange={(e) => setSearchTerm(e)}
|
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||||
autoFocus
|
/>
|
||||||
tabIndex={1}
|
</div>
|
||||||
/>
|
</Tooltip>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-200"
|
||||||
|
aria-hidden="true"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Command.Input
|
||||||
|
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={searchTerm}
|
||||||
|
onValueChange={(e) => setSearchTerm(e)}
|
||||||
|
autoFocus
|
||||||
|
tabIndex={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Command.List className="max-h-96 overflow-scroll p-2 vertical-scrollbar scrollbar-sm">
|
<Command.List className="max-h-96 overflow-scroll p-2 vertical-scrollbar scrollbar-sm">
|
||||||
{searchTerm !== "" && (
|
{searchTerm !== "" && (
|
||||||
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
|
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
|
||||||
Search results for{" "}
|
Search results for{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{'"'}
|
{'"'}
|
||||||
{searchTerm}
|
{searchTerm}
|
||||||
{'"'}
|
{'"'}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
|
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
|
||||||
</h5>
|
</h5>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
|
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
|
||||||
<div className="my-4 text-center text-sm text-custom-text-200">No results found.</div>
|
<div className="my-4 text-center text-sm text-custom-text-200">No results found.</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(isLoading || isSearching) && (
|
{(isLoading || isSearching) && (
|
||||||
<Command.Loading>
|
<Command.Loading>
|
||||||
<Loader className="space-y-3">
|
<Loader className="space-y-3">
|
||||||
<Loader.Item height="40px" />
|
<Loader.Item height="40px" />
|
||||||
<Loader.Item height="40px" />
|
<Loader.Item height="40px" />
|
||||||
<Loader.Item height="40px" />
|
<Loader.Item height="40px" />
|
||||||
<Loader.Item height="40px" />
|
<Loader.Item height="40px" />
|
||||||
</Loader>
|
</Loader>
|
||||||
</Command.Loading>
|
</Command.Loading>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{debouncedSearchTerm !== "" && (
|
{debouncedSearchTerm !== "" && (
|
||||||
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
|
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!page && (
|
{!page && (
|
||||||
<>
|
<>
|
||||||
{/* issue actions */}
|
{/* issue actions */}
|
||||||
{issueId && (
|
{issueId && (
|
||||||
<CommandPaletteIssueActions
|
<CommandPaletteIssueActions
|
||||||
closePalette={closePalette}
|
closePalette={closePalette}
|
||||||
issueDetails={issueDetails}
|
issueDetails={issueDetails}
|
||||||
pages={pages}
|
pages={pages}
|
||||||
setPages={(newPages) => setPages(newPages)}
|
setPages={(newPages) => setPages(newPages)}
|
||||||
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
|
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
|
||||||
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
|
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Command.Group heading="Issue">
|
<Command.Group heading="Issue">
|
||||||
<Command.Item
|
|
||||||
onSelect={() => {
|
|
||||||
closePalette();
|
|
||||||
setTrackElement("Command Palette");
|
|
||||||
toggleCreateIssueModal(true);
|
|
||||||
}}
|
|
||||||
className="focus:bg-custom-background-80"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 text-custom-text-200">
|
|
||||||
<LayersIcon className="h-3.5 w-3.5" />
|
|
||||||
Create new issue
|
|
||||||
</div>
|
|
||||||
<kbd>C</kbd>
|
|
||||||
</Command.Item>
|
|
||||||
</Command.Group>
|
|
||||||
|
|
||||||
{workspaceSlug && (
|
|
||||||
<Command.Group heading="Project">
|
|
||||||
<Command.Item
|
<Command.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
closePalette();
|
closePalette();
|
||||||
setTrackElement("Command palette");
|
setTrackElement("Command Palette");
|
||||||
toggleCreateProjectModal(true);
|
toggleCreateIssueModal(true);
|
||||||
|
}}
|
||||||
|
className="focus:bg-custom-background-80"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-custom-text-200">
|
||||||
|
<LayersIcon className="h-3.5 w-3.5" />
|
||||||
|
Create new issue
|
||||||
|
</div>
|
||||||
|
<kbd>C</kbd>
|
||||||
|
</Command.Item>
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
{workspaceSlug && (
|
||||||
|
<Command.Group heading="Project">
|
||||||
|
<Command.Item
|
||||||
|
onSelect={() => {
|
||||||
|
closePalette();
|
||||||
|
setTrackElement("Command palette");
|
||||||
|
toggleCreateProjectModal(true);
|
||||||
|
}}
|
||||||
|
className="focus:outline-none"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-custom-text-200">
|
||||||
|
<FolderPlus className="h-3.5 w-3.5" />
|
||||||
|
Create new project
|
||||||
|
</div>
|
||||||
|
<kbd>P</kbd>
|
||||||
|
</Command.Item>
|
||||||
|
</Command.Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* project actions */}
|
||||||
|
{projectId && <CommandPaletteProjectActions closePalette={closePalette} />}
|
||||||
|
|
||||||
|
<Command.Group heading="Workspace Settings">
|
||||||
|
<Command.Item
|
||||||
|
onSelect={() => {
|
||||||
|
setPlaceholder("Search workspace settings...");
|
||||||
|
setSearchTerm("");
|
||||||
|
setPages([...pages, "settings"]);
|
||||||
}}
|
}}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-custom-text-200">
|
<div className="flex items-center gap-2 text-custom-text-200">
|
||||||
<FolderPlus className="h-3.5 w-3.5" />
|
<Settings className="h-3.5 w-3.5" />
|
||||||
Create new project
|
Search settings...
|
||||||
|
</div>
|
||||||
|
</Command.Item>
|
||||||
|
</Command.Group>
|
||||||
|
<Command.Group heading="Account">
|
||||||
|
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
|
||||||
|
<div className="flex items-center gap-2 text-custom-text-200">
|
||||||
|
<FolderPlus className="h-3.5 w-3.5" />
|
||||||
|
Create new workspace
|
||||||
|
</div>
|
||||||
|
</Command.Item>
|
||||||
|
<Command.Item
|
||||||
|
onSelect={() => {
|
||||||
|
setPlaceholder("Change interface theme...");
|
||||||
|
setSearchTerm("");
|
||||||
|
setPages([...pages, "change-interface-theme"]);
|
||||||
|
}}
|
||||||
|
className="focus:outline-none"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-custom-text-200">
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
Change interface theme...
|
||||||
</div>
|
</div>
|
||||||
<kbd>P</kbd>
|
|
||||||
</Command.Item>
|
</Command.Item>
|
||||||
</Command.Group>
|
</Command.Group>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* project actions */}
|
{/* help options */}
|
||||||
{projectId && <CommandPaletteProjectActions closePalette={closePalette} />}
|
<CommandPaletteHelpActions closePalette={closePalette} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Command.Group heading="Workspace Settings">
|
{/* workspace settings actions */}
|
||||||
<Command.Item
|
{page === "settings" && workspaceSlug && (
|
||||||
onSelect={() => {
|
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
|
||||||
setPlaceholder("Search workspace settings...");
|
)}
|
||||||
setSearchTerm("");
|
|
||||||
setPages([...pages, "settings"]);
|
|
||||||
}}
|
|
||||||
className="focus:outline-none"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 text-custom-text-200">
|
|
||||||
<Settings className="h-3.5 w-3.5" />
|
|
||||||
Search settings...
|
|
||||||
</div>
|
|
||||||
</Command.Item>
|
|
||||||
</Command.Group>
|
|
||||||
<Command.Group heading="Account">
|
|
||||||
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
|
|
||||||
<div className="flex items-center gap-2 text-custom-text-200">
|
|
||||||
<FolderPlus className="h-3.5 w-3.5" />
|
|
||||||
Create new workspace
|
|
||||||
</div>
|
|
||||||
</Command.Item>
|
|
||||||
<Command.Item
|
|
||||||
onSelect={() => {
|
|
||||||
setPlaceholder("Change interface theme...");
|
|
||||||
setSearchTerm("");
|
|
||||||
setPages([...pages, "change-interface-theme"]);
|
|
||||||
}}
|
|
||||||
className="focus:outline-none"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 text-custom-text-200">
|
|
||||||
<Settings className="h-3.5 w-3.5" />
|
|
||||||
Change interface theme...
|
|
||||||
</div>
|
|
||||||
</Command.Item>
|
|
||||||
</Command.Group>
|
|
||||||
|
|
||||||
{/* help options */}
|
{/* issue details page actions */}
|
||||||
<CommandPaletteHelpActions closePalette={closePalette} />
|
{page === "change-issue-state" && issueDetails && (
|
||||||
</>
|
<ChangeIssueState closePalette={closePalette} issue={issueDetails} />
|
||||||
)}
|
)}
|
||||||
|
{page === "change-issue-priority" && issueDetails && (
|
||||||
|
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
|
||||||
|
)}
|
||||||
|
{page === "change-issue-assignee" && issueDetails && (
|
||||||
|
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* workspace settings actions */}
|
{/* theme actions */}
|
||||||
{page === "settings" && workspaceSlug && (
|
{page === "change-interface-theme" && (
|
||||||
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
|
<CommandPaletteThemeActions
|
||||||
)}
|
closePalette={() => {
|
||||||
|
closePalette();
|
||||||
{/* issue details page actions */}
|
setPages((pages) => pages.slice(0, -1));
|
||||||
{page === "change-issue-state" && issueDetails && (
|
}}
|
||||||
<ChangeIssueState closePalette={closePalette} issue={issueDetails} />
|
/>
|
||||||
)}
|
)}
|
||||||
{page === "change-issue-priority" && issueDetails && (
|
</Command.List>
|
||||||
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
|
</Command>
|
||||||
)}
|
</div>
|
||||||
{page === "change-issue-assignee" && issueDetails && (
|
</Dialog.Panel>
|
||||||
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
|
</Transition.Child>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* theme actions */}
|
|
||||||
{page === "change-interface-theme" && (
|
|
||||||
<CommandPaletteThemeActions
|
|
||||||
closePalette={() => {
|
|
||||||
closePalette();
|
|
||||||
setPages((pages) => pages.slice(0, -1));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Command.List>
|
|
||||||
</Command>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Transition.Root>
|
</Transition.Root>
|
||||||
|
@ -49,8 +49,10 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
// fetching project issues.
|
// fetching project issues.
|
||||||
const { data: issues } = useSWR(
|
const { data: issues } = useSWR(
|
||||||
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
workspaceSlug && projectId && isOpen ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
||||||
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
|
workspaceSlug && projectId && isOpen
|
||||||
|
? () => issueService.getIssues(workspaceSlug as string, projectId as string)
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
@ -67,6 +67,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
|
|
||||||
const filterParams = getRedirectionFilters(selectedTab);
|
const filterParams = getRedirectionFilters(selectedTab);
|
||||||
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||||
|
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
||||||
|
|
||||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
@ -84,30 +85,25 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
if (val === selectedDurationFilter) return;
|
if (val === selectedDurationFilter) return;
|
||||||
|
|
||||||
|
let newTab = selectedTab;
|
||||||
// switch to pending tab if target date is changed to none
|
// switch to pending tab if target date is changed to none
|
||||||
if (val === "none" && selectedTab !== "completed") {
|
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
||||||
handleUpdateFilters({ duration: val, tab: "pending" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// switch to upcoming tab if target date is changed to other than none
|
// switch to upcoming tab if target date is changed to other than none
|
||||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
|
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming";
|
||||||
handleUpdateFilters({
|
|
||||||
duration: val,
|
|
||||||
tab: "upcoming",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleUpdateFilters({ duration: val });
|
handleUpdateFilters({
|
||||||
|
duration: val,
|
||||||
|
tab: newTab,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Tab.Group
|
<Tab.Group
|
||||||
as="div"
|
as="div"
|
||||||
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)}
|
selectedIndex={selectedTabIndex}
|
||||||
onChange={(i) => {
|
onChange={(i) => {
|
||||||
const selectedTab = tabsList[i];
|
const newSelectedTab = tabsList[i];
|
||||||
handleUpdateFilters({ tab: selectedTab?.key ?? "pending" });
|
handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" });
|
||||||
}}
|
}}
|
||||||
className="h-full flex flex-col"
|
className="h-full flex flex-col"
|
||||||
>
|
>
|
||||||
@ -115,18 +111,21 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||||
</div>
|
</div>
|
||||||
<Tab.Panels as="div" className="h-full">
|
<Tab.Panels as="div" className="h-full">
|
||||||
{tabsList.map((tab) => (
|
{tabsList.map((tab) => {
|
||||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
|
if (tab.key !== selectedTab) return null;
|
||||||
<WidgetIssuesList
|
|
||||||
issues={widgetStats.issues}
|
return (
|
||||||
tab={tab.key}
|
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
||||||
totalIssues={widgetStats.count}
|
<WidgetIssuesList
|
||||||
type="assigned"
|
tab={tab.key}
|
||||||
workspaceSlug={workspaceSlug}
|
type="assigned"
|
||||||
isLoading={fetching}
|
workspaceSlug={workspaceSlug}
|
||||||
/>
|
widgetStats={widgetStats}
|
||||||
</Tab.Panel>
|
isLoading={fetching}
|
||||||
))}
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
</div>
|
</div>
|
||||||
|
@ -64,6 +64,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
|
|
||||||
const filterParams = getRedirectionFilters(selectedTab);
|
const filterParams = getRedirectionFilters(selectedTab);
|
||||||
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||||
|
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
||||||
|
|
||||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
@ -81,30 +82,25 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
if (val === selectedDurationFilter) return;
|
if (val === selectedDurationFilter) return;
|
||||||
|
|
||||||
|
let newTab = selectedTab;
|
||||||
// switch to pending tab if target date is changed to none
|
// switch to pending tab if target date is changed to none
|
||||||
if (val === "none" && selectedTab !== "completed") {
|
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
||||||
handleUpdateFilters({ duration: val, tab: "pending" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// switch to upcoming tab if target date is changed to other than none
|
// switch to upcoming tab if target date is changed to other than none
|
||||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
|
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming";
|
||||||
handleUpdateFilters({
|
|
||||||
duration: val,
|
|
||||||
tab: "upcoming",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleUpdateFilters({ duration: val });
|
handleUpdateFilters({
|
||||||
|
duration: val,
|
||||||
|
tab: newTab,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Tab.Group
|
<Tab.Group
|
||||||
as="div"
|
as="div"
|
||||||
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)}
|
selectedIndex={selectedTabIndex}
|
||||||
onChange={(i) => {
|
onChange={(i) => {
|
||||||
const selectedTab = tabsList[i];
|
const newSelectedTab = tabsList[i];
|
||||||
handleUpdateFilters({ tab: selectedTab.key ?? "pending" });
|
handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" });
|
||||||
}}
|
}}
|
||||||
className="h-full flex flex-col"
|
className="h-full flex flex-col"
|
||||||
>
|
>
|
||||||
@ -112,18 +108,21 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||||
</div>
|
</div>
|
||||||
<Tab.Panels as="div" className="h-full">
|
<Tab.Panels as="div" className="h-full">
|
||||||
{tabsList.map((tab) => (
|
{tabsList.map((tab) => {
|
||||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
|
if (tab.key !== selectedTab) return null;
|
||||||
<WidgetIssuesList
|
|
||||||
issues={widgetStats.issues}
|
return (
|
||||||
tab={tab.key}
|
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
||||||
totalIssues={widgetStats.count}
|
<WidgetIssuesList
|
||||||
type="created"
|
tab={tab.key}
|
||||||
workspaceSlug={workspaceSlug}
|
type="created"
|
||||||
isLoading={fetching}
|
workspaceSlug={workspaceSlug}
|
||||||
/>
|
widgetStats={widgetStats}
|
||||||
</Tab.Panel>
|
isLoading={fetching}
|
||||||
))}
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,19 +19,18 @@ import { Loader, getButtonStyling } from "@plane/ui";
|
|||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
import { getRedirectionFilters } from "helpers/dashboard.helper";
|
import { getRedirectionFilters } from "helpers/dashboard.helper";
|
||||||
// types
|
// types
|
||||||
import { TIssue, TIssuesListTypes } from "@plane/types";
|
import { TAssignedIssuesWidgetResponse, TCreatedIssuesWidgetResponse, TIssue, TIssuesListTypes } from "@plane/types";
|
||||||
|
|
||||||
export type WidgetIssuesListProps = {
|
export type WidgetIssuesListProps = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
issues: TIssue[];
|
|
||||||
tab: TIssuesListTypes;
|
tab: TIssuesListTypes;
|
||||||
totalIssues: number;
|
|
||||||
type: "assigned" | "created";
|
type: "assigned" | "created";
|
||||||
|
widgetStats: TAssignedIssuesWidgetResponse | TCreatedIssuesWidgetResponse;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||||
const { isLoading, issues, tab, totalIssues, type, workspaceSlug } = props;
|
const { isLoading, tab, type, widgetStats, workspaceSlug } = props;
|
||||||
// store hooks
|
// store hooks
|
||||||
const { setPeekIssue } = useIssueDetail();
|
const { setPeekIssue } = useIssueDetail();
|
||||||
|
|
||||||
@ -59,6 +58,8 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const issuesList = widgetStats.issues;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
@ -69,7 +70,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
|||||||
<Loader.Item height="25px" />
|
<Loader.Item height="25px" />
|
||||||
<Loader.Item height="25px" />
|
<Loader.Item height="25px" />
|
||||||
</Loader>
|
</Loader>
|
||||||
) : issues.length > 0 ? (
|
) : issuesList.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1">
|
<div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1">
|
||||||
<h6
|
<h6
|
||||||
@ -80,7 +81,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
|||||||
>
|
>
|
||||||
Issues
|
Issues
|
||||||
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-3 flex items-center text-center justify-center">
|
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-3 flex items-center text-center justify-center">
|
||||||
{totalIssues}
|
{widgetStats.count}
|
||||||
</span>
|
</span>
|
||||||
</h6>
|
</h6>
|
||||||
{["upcoming", "pending"].includes(tab) && <h6 className="text-center">Due date</h6>}
|
{["upcoming", "pending"].includes(tab) && <h6 className="text-center">Due date</h6>}
|
||||||
@ -89,7 +90,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
|||||||
{type === "created" && <h6 className="text-center">Assigned to</h6>}
|
{type === "created" && <h6 className="text-center">Assigned to</h6>}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 pb-3 mt-2">
|
<div className="px-4 pb-3 mt-2">
|
||||||
{issues.map((issue) => {
|
{issuesList.map((issue) => {
|
||||||
const IssueListItem = ISSUE_LIST_ITEM[type][tab];
|
const IssueListItem = ISSUE_LIST_ITEM[type][tab];
|
||||||
|
|
||||||
if (!IssueListItem) return null;
|
if (!IssueListItem) return null;
|
||||||
@ -112,7 +113,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{issues.length > 0 && (
|
{!isLoading && issuesList.length > 0 && (
|
||||||
<Link
|
<Link
|
||||||
href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`}
|
href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
@ -1,269 +0,0 @@
|
|||||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { Combobox } from "@headlessui/react";
|
|
||||||
import { usePopper } from "react-popper";
|
|
||||||
import { Check, ChevronDown, Search } from "lucide-react";
|
|
||||||
// hooks
|
|
||||||
import { useApplication, useCycle } from "hooks/store";
|
|
||||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
|
||||||
// components
|
|
||||||
import { DropdownButton } from "./buttons";
|
|
||||||
// icons
|
|
||||||
import { ContrastIcon, CycleGroupIcon } from "@plane/ui";
|
|
||||||
// helpers
|
|
||||||
import { cn } from "helpers/common.helper";
|
|
||||||
// types
|
|
||||||
import { TDropdownProps } from "./types";
|
|
||||||
import { TCycleGroups } from "@plane/types";
|
|
||||||
// constants
|
|
||||||
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
|
|
||||||
|
|
||||||
type Props = TDropdownProps & {
|
|
||||||
button?: ReactNode;
|
|
||||||
dropdownArrow?: boolean;
|
|
||||||
dropdownArrowClassName?: string;
|
|
||||||
onChange: (val: string | null) => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
projectId: string;
|
|
||||||
value: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DropdownOptions =
|
|
||||||
| {
|
|
||||||
value: string | null;
|
|
||||||
query: string;
|
|
||||||
content: JSX.Element;
|
|
||||||
}[]
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
export const CycleDropdown: React.FC<Props> = observer((props) => {
|
|
||||||
const {
|
|
||||||
button,
|
|
||||||
buttonClassName,
|
|
||||||
buttonContainerClassName,
|
|
||||||
buttonVariant,
|
|
||||||
className = "",
|
|
||||||
disabled = false,
|
|
||||||
dropdownArrow = false,
|
|
||||||
dropdownArrowClassName = "",
|
|
||||||
hideIcon = false,
|
|
||||||
onChange,
|
|
||||||
onClose,
|
|
||||||
placeholder = "Cycle",
|
|
||||||
placement,
|
|
||||||
projectId,
|
|
||||||
showTooltip = false,
|
|
||||||
tabIndex,
|
|
||||||
value,
|
|
||||||
} = props;
|
|
||||||
// states
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
// refs
|
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
// popper-js refs
|
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
|
||||||
// popper-js init
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
|
||||||
placement: placement ?? "bottom-start",
|
|
||||||
modifiers: [
|
|
||||||
{
|
|
||||||
name: "preventOverflow",
|
|
||||||
options: {
|
|
||||||
padding: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// store hooks
|
|
||||||
const {
|
|
||||||
router: { workspaceSlug },
|
|
||||||
} = useApplication();
|
|
||||||
const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle();
|
|
||||||
|
|
||||||
const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => {
|
|
||||||
const cycleDetails = getCycleById(cycleId);
|
|
||||||
return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const options: DropdownOptions = cycleIds?.map((cycleId) => {
|
|
||||||
const cycleDetails = getCycleById(cycleId);
|
|
||||||
const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: cycleId,
|
|
||||||
query: `${cycleDetails?.name}`,
|
|
||||||
content: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
|
|
||||||
<span className="flex-grow truncate">{cycleDetails?.name}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
options?.unshift({
|
|
||||||
value: null,
|
|
||||||
query: "No cycle",
|
|
||||||
content: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ContrastIcon className="h-3 w-3 flex-shrink-0" />
|
|
||||||
<span className="flex-grow truncate">No cycle</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredOptions =
|
|
||||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
|
||||||
|
|
||||||
const selectedCycle = value ? getCycleById(value) : null;
|
|
||||||
|
|
||||||
const onOpen = () => {
|
|
||||||
if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
setIsOpen(false);
|
|
||||||
onClose && onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDropdown = () => {
|
|
||||||
if (!isOpen) onOpen();
|
|
||||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropdownOnChange = (val: string | null) => {
|
|
||||||
onChange(val);
|
|
||||||
handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
|
||||||
|
|
||||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
toggleDropdown();
|
|
||||||
};
|
|
||||||
|
|
||||||
useOutsideClickDetector(dropdownRef, handleClose);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Combobox
|
|
||||||
as="div"
|
|
||||||
ref={dropdownRef}
|
|
||||||
tabIndex={tabIndex}
|
|
||||||
className={cn("h-full", className)}
|
|
||||||
value={value}
|
|
||||||
onChange={dropdownOnChange}
|
|
||||||
disabled={disabled}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<Combobox.Button as={Fragment}>
|
|
||||||
{button ? (
|
|
||||||
<button
|
|
||||||
ref={setReferenceElement}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
|
|
||||||
buttonContainerClassName
|
|
||||||
)}
|
|
||||||
onClick={handleOnClick}
|
|
||||||
>
|
|
||||||
{button}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
ref={setReferenceElement}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
|
|
||||||
{
|
|
||||||
"cursor-not-allowed text-custom-text-200": disabled,
|
|
||||||
"cursor-pointer": !disabled,
|
|
||||||
},
|
|
||||||
buttonContainerClassName
|
|
||||||
)}
|
|
||||||
onClick={handleOnClick}
|
|
||||||
>
|
|
||||||
<DropdownButton
|
|
||||||
className={buttonClassName}
|
|
||||||
isActive={isOpen}
|
|
||||||
tooltipHeading="Cycle"
|
|
||||||
tooltipContent={selectedCycle?.name ?? placeholder}
|
|
||||||
showTooltip={showTooltip}
|
|
||||||
variant={buttonVariant}
|
|
||||||
>
|
|
||||||
{!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />}
|
|
||||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
|
||||||
<span className="flex-grow truncate max-w-40">{selectedCycle?.name ?? placeholder}</span>
|
|
||||||
)}
|
|
||||||
{dropdownArrow && (
|
|
||||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</DropdownButton>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</Combobox.Button>
|
|
||||||
{isOpen && (
|
|
||||||
<Combobox.Options className="fixed z-10" static>
|
|
||||||
<div
|
|
||||||
className="my-1 w-48 rounded 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"
|
|
||||||
ref={setPopperElement}
|
|
||||||
style={styles.popper}
|
|
||||||
{...attributes.popper}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
|
||||||
<Combobox.Input
|
|
||||||
as="input"
|
|
||||||
ref={inputRef}
|
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Search"
|
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
|
||||||
{filteredOptions ? (
|
|
||||||
filteredOptions.length > 0 ? (
|
|
||||||
filteredOptions.map((option) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className={({ active, selected }) =>
|
|
||||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
|
||||||
active ? "bg-custom-background-80" : ""
|
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className="flex-grow truncate">{option.content}</span>
|
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Combobox.Options>
|
|
||||||
)}
|
|
||||||
</Combobox>
|
|
||||||
);
|
|
||||||
});
|
|
162
web/components/dropdowns/cycle/cycle-options.tsx
Normal file
162
web/components/dropdowns/cycle/cycle-options.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Combobox } from "@headlessui/react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
//components
|
||||||
|
import { ContrastIcon, CycleGroupIcon } from "@plane/ui";
|
||||||
|
//store
|
||||||
|
import { useApplication, useCycle } from "hooks/store";
|
||||||
|
//hooks
|
||||||
|
import { usePopper } from "react-popper";
|
||||||
|
//icon
|
||||||
|
import { Check, Search } from "lucide-react";
|
||||||
|
//types
|
||||||
|
import { Placement } from "@popperjs/core";
|
||||||
|
import { TCycleGroups } from "@plane/types";
|
||||||
|
|
||||||
|
type DropdownOptions =
|
||||||
|
| {
|
||||||
|
value: string | null;
|
||||||
|
query: string;
|
||||||
|
content: JSX.Element;
|
||||||
|
}[]
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
referenceElement: HTMLButtonElement | null;
|
||||||
|
placement: Placement | undefined;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CycleOptions = observer((props: any) => {
|
||||||
|
const { projectId, isOpen, referenceElement, placement } = props;
|
||||||
|
|
||||||
|
//state hooks
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
router: { workspaceSlug },
|
||||||
|
} = useApplication();
|
||||||
|
const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
onOpen();
|
||||||
|
inputRef.current && inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// popper-js init
|
||||||
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
|
placement: placement ?? "bottom-start",
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: "preventOverflow",
|
||||||
|
options: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => {
|
||||||
|
const cycleDetails = getCycleById(cycleId);
|
||||||
|
return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onOpen = () => {
|
||||||
|
if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (query !== "" && e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: DropdownOptions = cycleIds?.map((cycleId) => {
|
||||||
|
const cycleDetails = getCycleById(cycleId);
|
||||||
|
const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: cycleId,
|
||||||
|
query: `${cycleDetails?.name}`,
|
||||||
|
content: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span className="flex-grow truncate">{cycleDetails?.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
options?.unshift({
|
||||||
|
value: null,
|
||||||
|
query: "No cycle",
|
||||||
|
content: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ContrastIcon className="h-3 w-3 flex-shrink-0" />
|
||||||
|
<span className="flex-grow truncate">No cycle</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredOptions =
|
||||||
|
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox.Options className="fixed z-10" static>
|
||||||
|
<div
|
||||||
|
className="my-1 w-48 rounded 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"
|
||||||
|
ref={setPopperElement}
|
||||||
|
style={styles.popper}
|
||||||
|
{...attributes.popper}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
|
<Combobox.Input
|
||||||
|
as="input"
|
||||||
|
ref={inputRef}
|
||||||
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search"
|
||||||
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
|
onKeyDown={searchInputKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
|
{filteredOptions ? (
|
||||||
|
filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||||
|
active ? "bg-custom-background-80" : ""
|
||||||
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span className="flex-grow truncate">{option.content}</span>
|
||||||
|
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Combobox.Options>
|
||||||
|
);
|
||||||
|
});
|
149
web/components/dropdowns/cycle/index.tsx
Normal file
149
web/components/dropdowns/cycle/index.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { Fragment, ReactNode, useRef, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Combobox } from "@headlessui/react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useCycle } from "hooks/store";
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
|
// components
|
||||||
|
import { DropdownButton } from "../buttons";
|
||||||
|
// icons
|
||||||
|
import { ContrastIcon } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
// types
|
||||||
|
import { TDropdownProps } from "../types";
|
||||||
|
// constants
|
||||||
|
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
|
||||||
|
import { CycleOptions } from "./cycle-options";
|
||||||
|
|
||||||
|
type Props = TDropdownProps & {
|
||||||
|
button?: ReactNode;
|
||||||
|
dropdownArrow?: boolean;
|
||||||
|
dropdownArrowClassName?: string;
|
||||||
|
onChange: (val: string | null) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
projectId: string;
|
||||||
|
value: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||||
|
const {
|
||||||
|
button,
|
||||||
|
buttonClassName,
|
||||||
|
buttonContainerClassName,
|
||||||
|
buttonVariant,
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
dropdownArrow = false,
|
||||||
|
dropdownArrowClassName = "",
|
||||||
|
hideIcon = false,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
placeholder = "Cycle",
|
||||||
|
placement,
|
||||||
|
projectId,
|
||||||
|
showTooltip = false,
|
||||||
|
tabIndex,
|
||||||
|
value,
|
||||||
|
} = props;
|
||||||
|
// states
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { getCycleNameById } = useCycle();
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// popper-js refs
|
||||||
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const selectedName = value ? getCycleNameById(value) : null;
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setIsOpen(false);
|
||||||
|
onClose && onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropdownOnChange = (val: string | null) => {
|
||||||
|
onChange(val);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
||||||
|
|
||||||
|
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
toggleDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
useOutsideClickDetector(dropdownRef, handleClose);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
className={cn("h-full", className)}
|
||||||
|
value={value}
|
||||||
|
onChange={dropdownOnChange}
|
||||||
|
disabled={disabled}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<Combobox.Button as={Fragment}>
|
||||||
|
{button ? (
|
||||||
|
<button
|
||||||
|
ref={setReferenceElement}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
|
||||||
|
buttonContainerClassName
|
||||||
|
)}
|
||||||
|
onClick={handleOnClick}
|
||||||
|
>
|
||||||
|
{button}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
ref={setReferenceElement}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
|
||||||
|
{
|
||||||
|
"cursor-not-allowed text-custom-text-200": disabled,
|
||||||
|
"cursor-pointer": !disabled,
|
||||||
|
},
|
||||||
|
buttonContainerClassName
|
||||||
|
)}
|
||||||
|
onClick={handleOnClick}
|
||||||
|
>
|
||||||
|
<DropdownButton
|
||||||
|
className={buttonClassName}
|
||||||
|
isActive={isOpen}
|
||||||
|
tooltipHeading="Cycle"
|
||||||
|
tooltipContent={selectedName ?? placeholder}
|
||||||
|
showTooltip={showTooltip}
|
||||||
|
variant={buttonVariant}
|
||||||
|
>
|
||||||
|
{!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />}
|
||||||
|
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||||
|
<span className="flex-grow truncate max-w-40">{selectedName ?? placeholder}</span>
|
||||||
|
)}
|
||||||
|
{dropdownArrow && (
|
||||||
|
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</DropdownButton>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Combobox.Button>
|
||||||
|
{isOpen && (
|
||||||
|
<CycleOptions isOpen={isOpen} projectId={projectId} placement={placement} referenceElement={referenceElement} />
|
||||||
|
)}
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
});
|
@ -86,6 +86,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
|||||||
const toggleDropdown = () => {
|
const toggleDropdown = () => {
|
||||||
if (!isOpen) onOpen();
|
if (!isOpen) onOpen();
|
||||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||||
|
if (isOpen) onClose && onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropdownOnChange = (val: Date | null) => {
|
const dropdownOnChange = (val: Date | null) => {
|
||||||
@ -146,7 +147,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
|||||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||||
<span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span>
|
<span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span>
|
||||||
)}
|
)}
|
||||||
{isClearable && isDateSelected && (
|
{isClearable && !disabled && isDateSelected && (
|
||||||
<X
|
<X
|
||||||
className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)}
|
className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
@ -122,6 +122,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
const toggleDropdown = () => {
|
const toggleDropdown = () => {
|
||||||
if (!isOpen) onOpen();
|
if (!isOpen) onOpen();
|
||||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||||
|
if (isOpen) onClose && onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropdownOnChange = (val: number | null) => {
|
const dropdownOnChange = (val: number | null) => {
|
||||||
@ -137,6 +138,13 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
toggleDropdown();
|
toggleDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (query !== "" && e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useOutsideClickDetector(dropdownRef, handleClose);
|
useOutsideClickDetector(dropdownRef, handleClose);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -217,6 +225,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
|
onKeyDown={searchInputKeyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
|
@ -1,2 +0,0 @@
|
|||||||
export * from "./project-member";
|
|
||||||
export * from "./workspace-member";
|
|
156
web/components/dropdowns/member/index.tsx
Normal file
156
web/components/dropdowns/member/index.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { Fragment, useRef, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Combobox } from "@headlessui/react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useMember } from "hooks/store";
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
|
// components
|
||||||
|
import { ButtonAvatars } from "./avatar";
|
||||||
|
import { DropdownButton } from "../buttons";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
// types
|
||||||
|
import { MemberDropdownProps } from "./types";
|
||||||
|
// constants
|
||||||
|
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
|
||||||
|
import { MemberOptions } from "./member-options";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
projectId?: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
} & MemberDropdownProps;
|
||||||
|
|
||||||
|
export const MemberDropdown: React.FC<Props> = observer((props) => {
|
||||||
|
const {
|
||||||
|
button,
|
||||||
|
buttonClassName,
|
||||||
|
buttonContainerClassName,
|
||||||
|
buttonVariant,
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
dropdownArrow = false,
|
||||||
|
dropdownArrowClassName = "",
|
||||||
|
hideIcon = false,
|
||||||
|
multiple,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
placeholder = "Members",
|
||||||
|
placement,
|
||||||
|
projectId,
|
||||||
|
showTooltip = false,
|
||||||
|
tabIndex,
|
||||||
|
value,
|
||||||
|
} = props;
|
||||||
|
// states
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// popper-js refs
|
||||||
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const { getUserDetails } = useMember();
|
||||||
|
|
||||||
|
const comboboxProps: any = {
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
};
|
||||||
|
if (multiple) comboboxProps.multiple = true;
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setIsOpen(false);
|
||||||
|
onClose && onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropdownOnChange = (val: string & string[]) => {
|
||||||
|
onChange(val);
|
||||||
|
if (!multiple) handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
||||||
|
|
||||||
|
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
toggleDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
useOutsideClickDetector(dropdownRef, handleClose);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
className={cn("h-full", className)}
|
||||||
|
onChange={dropdownOnChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
{...comboboxProps}
|
||||||
|
>
|
||||||
|
<Combobox.Button as={Fragment}>
|
||||||
|
{button ? (
|
||||||
|
<button
|
||||||
|
ref={setReferenceElement}
|
||||||
|
type="button"
|
||||||
|
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||||
|
onClick={handleOnClick}
|
||||||
|
>
|
||||||
|
{button}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
ref={setReferenceElement}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"clickable block h-full max-w-full outline-none",
|
||||||
|
{
|
||||||
|
"cursor-not-allowed text-custom-text-200": disabled,
|
||||||
|
"cursor-pointer": !disabled,
|
||||||
|
},
|
||||||
|
buttonContainerClassName
|
||||||
|
)}
|
||||||
|
onClick={handleOnClick}
|
||||||
|
>
|
||||||
|
<DropdownButton
|
||||||
|
className={buttonClassName}
|
||||||
|
isActive={isOpen}
|
||||||
|
tooltipHeading={placeholder}
|
||||||
|
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
|
||||||
|
showTooltip={showTooltip}
|
||||||
|
variant={buttonVariant}
|
||||||
|
>
|
||||||
|
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
|
||||||
|
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||||
|
<span className="flex-grow truncate text-xs leading-5">
|
||||||
|
{Array.isArray(value) && value.length > 0
|
||||||
|
? value.length === 1
|
||||||
|
? getUserDetails(value[0])?.display_name
|
||||||
|
: ""
|
||||||
|
: placeholder}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dropdownArrow && (
|
||||||
|
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</DropdownButton>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Combobox.Button>
|
||||||
|
{isOpen && (
|
||||||
|
<MemberOptions
|
||||||
|
isOpen={isOpen}
|
||||||
|
projectId={projectId}
|
||||||
|
placement={placement}
|
||||||
|
referenceElement={referenceElement}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
});
|
142
web/components/dropdowns/member/member-options.tsx
Normal file
142
web/components/dropdowns/member/member-options.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Combobox } from "@headlessui/react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
//components
|
||||||
|
import { Avatar } from "@plane/ui";
|
||||||
|
//store
|
||||||
|
import { useApplication, useMember, useUser } from "hooks/store";
|
||||||
|
//hooks
|
||||||
|
import { usePopper } from "react-popper";
|
||||||
|
//icon
|
||||||
|
import { Check, Search } from "lucide-react";
|
||||||
|
//types
|
||||||
|
import { Placement } from "@popperjs/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId?: string;
|
||||||
|
referenceElement: HTMLButtonElement | null;
|
||||||
|
placement: Placement | undefined;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemberOptions = observer((props: Props) => {
|
||||||
|
const { projectId, referenceElement, placement, isOpen } = props;
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
router: { workspaceSlug },
|
||||||
|
} = useApplication();
|
||||||
|
const {
|
||||||
|
getUserDetails,
|
||||||
|
project: { getProjectMemberIds, fetchProjectMembers },
|
||||||
|
workspace: { workspaceMemberIds },
|
||||||
|
} = useMember();
|
||||||
|
const { currentUser } = useUser();
|
||||||
|
|
||||||
|
// popper-js init
|
||||||
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
|
placement: placement ?? "bottom-start",
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: "preventOverflow",
|
||||||
|
options: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
onOpen();
|
||||||
|
inputRef.current && inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const memberIds = projectId ? getProjectMemberIds(projectId) : workspaceMemberIds;
|
||||||
|
const onOpen = () => {
|
||||||
|
if (!memberIds && workspaceSlug && projectId) fetchProjectMembers(workspaceSlug, projectId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (query !== "" && e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = memberIds?.map((userId) => {
|
||||||
|
const userDetails = getUserDetails(userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: userId,
|
||||||
|
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
|
||||||
|
content: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar name={userDetails?.display_name} src={userDetails?.avatar} />
|
||||||
|
<span className="flex-grow truncate">{currentUser?.id === userId ? "You" : userDetails?.display_name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredOptions =
|
||||||
|
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox.Options className="fixed z-10" static>
|
||||||
|
<div
|
||||||
|
className="my-1 w-48 rounded 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"
|
||||||
|
ref={setPopperElement}
|
||||||
|
style={styles.popper}
|
||||||
|
{...attributes.popper}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
|
<Combobox.Input
|
||||||
|
as="input"
|
||||||
|
ref={inputRef}
|
||||||
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search"
|
||||||
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
|
onKeyDown={searchInputKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
|
{filteredOptions ? (
|
||||||
|
filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||||
|
active ? "bg-custom-background-80" : ""
|
||||||
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span className="flex-grow truncate">{option.content}</span>
|
||||||
|
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Combobox.Options>
|
||||||
|
);
|
||||||
|
});
|
@ -1,253 +0,0 @@
|
|||||||
import { Fragment, useEffect, useRef, useState } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { Combobox } from "@headlessui/react";
|
|
||||||
import { usePopper } from "react-popper";
|
|
||||||
import { Check, ChevronDown, Search } from "lucide-react";
|
|
||||||
// hooks
|
|
||||||
import { useApplication, useMember, useUser } from "hooks/store";
|
|
||||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
|
||||||
// components
|
|
||||||
import { ButtonAvatars } from "./avatar";
|
|
||||||
import { DropdownButton } from "../buttons";
|
|
||||||
// icons
|
|
||||||
import { Avatar } from "@plane/ui";
|
|
||||||
// helpers
|
|
||||||
import { cn } from "helpers/common.helper";
|
|
||||||
// types
|
|
||||||
import { MemberDropdownProps } from "./types";
|
|
||||||
// constants
|
|
||||||
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
projectId: string;
|
|
||||||
onClose?: () => void;
|
|
||||||
} & MemberDropdownProps;
|
|
||||||
|
|
||||||
export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
|
||||||
const {
|
|
||||||
button,
|
|
||||||
buttonClassName,
|
|
||||||
buttonContainerClassName,
|
|
||||||
buttonVariant,
|
|
||||||
className = "",
|
|
||||||
disabled = false,
|
|
||||||
dropdownArrow = false,
|
|
||||||
dropdownArrowClassName = "",
|
|
||||||
hideIcon = false,
|
|
||||||
multiple,
|
|
||||||
onChange,
|
|
||||||
onClose,
|
|
||||||
placeholder = "Members",
|
|
||||||
placement,
|
|
||||||
projectId,
|
|
||||||
showTooltip = false,
|
|
||||||
tabIndex,
|
|
||||||
value,
|
|
||||||
} = props;
|
|
||||||
// states
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
// refs
|
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
// popper-js refs
|
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
|
||||||
// popper-js init
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
|
||||||
placement: placement ?? "bottom-start",
|
|
||||||
modifiers: [
|
|
||||||
{
|
|
||||||
name: "preventOverflow",
|
|
||||||
options: {
|
|
||||||
padding: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// store hooks
|
|
||||||
const {
|
|
||||||
router: { workspaceSlug },
|
|
||||||
} = useApplication();
|
|
||||||
const { currentUser } = useUser();
|
|
||||||
const {
|
|
||||||
getUserDetails,
|
|
||||||
project: { getProjectMemberIds, fetchProjectMembers },
|
|
||||||
} = useMember();
|
|
||||||
const projectMemberIds = getProjectMemberIds(projectId);
|
|
||||||
|
|
||||||
const options = projectMemberIds?.map((userId) => {
|
|
||||||
const userDetails = getUserDetails(userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: userId,
|
|
||||||
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
|
|
||||||
content: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar name={userDetails?.display_name} src={userDetails?.avatar} />
|
|
||||||
<span className="flex-grow truncate">{currentUser?.id === userId ? "You" : userDetails?.display_name}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredOptions =
|
|
||||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
|
||||||
|
|
||||||
const comboboxProps: any = {
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
};
|
|
||||||
if (multiple) comboboxProps.multiple = true;
|
|
||||||
|
|
||||||
const onOpen = () => {
|
|
||||||
if (!projectMemberIds && workspaceSlug) fetchProjectMembers(workspaceSlug, projectId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
setIsOpen(false);
|
|
||||||
onClose && onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDropdown = () => {
|
|
||||||
if (!isOpen) onOpen();
|
|
||||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropdownOnChange = (val: string & string[]) => {
|
|
||||||
onChange(val);
|
|
||||||
if (!multiple) handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
|
||||||
|
|
||||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
toggleDropdown();
|
|
||||||
};
|
|
||||||
|
|
||||||
useOutsideClickDetector(dropdownRef, handleClose);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Combobox
|
|
||||||
as="div"
|
|
||||||
ref={dropdownRef}
|
|
||||||
tabIndex={tabIndex}
|
|
||||||
className={cn("h-full", className)}
|
|
||||||
onChange={dropdownOnChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
{...comboboxProps}
|
|
||||||
>
|
|
||||||
<Combobox.Button as={Fragment}>
|
|
||||||
{button ? (
|
|
||||||
<button
|
|
||||||
ref={setReferenceElement}
|
|
||||||
type="button"
|
|
||||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
|
||||||
onClick={handleOnClick}
|
|
||||||
>
|
|
||||||
{button}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
ref={setReferenceElement}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"clickable block h-full max-w-full outline-none",
|
|
||||||
{
|
|
||||||
"cursor-not-allowed text-custom-text-200": disabled,
|
|
||||||
"cursor-pointer": !disabled,
|
|
||||||
},
|
|
||||||
buttonContainerClassName
|
|
||||||
)}
|
|
||||||
onClick={handleOnClick}
|
|
||||||
>
|
|
||||||
<DropdownButton
|
|
||||||
className={buttonClassName}
|
|
||||||
isActive={isOpen}
|
|
||||||
tooltipHeading={placeholder}
|
|
||||||
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
|
|
||||||
showTooltip={showTooltip}
|
|
||||||
variant={buttonVariant}
|
|
||||||
>
|
|
||||||
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
|
|
||||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
|
||||||
<span className="flex-grow truncate text-xs leading-5">
|
|
||||||
{Array.isArray(value) && value.length > 0
|
|
||||||
? value.length === 1
|
|
||||||
? getUserDetails(value[0])?.display_name
|
|
||||||
: ""
|
|
||||||
: placeholder}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{dropdownArrow && (
|
|
||||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</DropdownButton>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</Combobox.Button>
|
|
||||||
{isOpen && (
|
|
||||||
<Combobox.Options className="fixed z-10" static>
|
|
||||||
<div
|
|
||||||
className="my-1 w-48 rounded 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"
|
|
||||||
ref={setPopperElement}
|
|
||||||
style={styles.popper}
|
|
||||||
{...attributes.popper}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
|
||||||
<Combobox.Input
|
|
||||||
as="input"
|
|
||||||
ref={inputRef}
|
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Search"
|
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
|
||||||
{filteredOptions ? (
|
|
||||||
filteredOptions.length > 0 ? (
|
|
||||||
filteredOptions.map((option) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className={({ active, selected }) =>
|
|
||||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
|
||||||
active ? "bg-custom-background-80" : ""
|
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className="flex-grow truncate">{option.content}</span>
|
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Combobox.Options>
|
|
||||||
)}
|
|
||||||
</Combobox>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,238 +0,0 @@
|
|||||||
import { Fragment, useEffect, useRef, useState } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { Combobox } from "@headlessui/react";
|
|
||||||
import { usePopper } from "react-popper";
|
|
||||||
import { Check, ChevronDown, Search } from "lucide-react";
|
|
||||||
// hooks
|
|
||||||
import { useMember, useUser } from "hooks/store";
|
|
||||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
|
||||||
// components
|
|
||||||
import { ButtonAvatars } from "./avatar";
|
|
||||||
import { DropdownButton } from "../buttons";
|
|
||||||
// icons
|
|
||||||
import { Avatar } from "@plane/ui";
|
|
||||||
// helpers
|
|
||||||
import { cn } from "helpers/common.helper";
|
|
||||||
// types
|
|
||||||
import { MemberDropdownProps } from "./types";
|
|
||||||
// constants
|
|
||||||
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
|
|
||||||
|
|
||||||
export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((props) => {
|
|
||||||
const {
|
|
||||||
button,
|
|
||||||
buttonClassName,
|
|
||||||
buttonContainerClassName,
|
|
||||||
buttonVariant,
|
|
||||||
className = "",
|
|
||||||
disabled = false,
|
|
||||||
dropdownArrow = false,
|
|
||||||
dropdownArrowClassName = "",
|
|
||||||
hideIcon = false,
|
|
||||||
multiple,
|
|
||||||
onChange,
|
|
||||||
onClose,
|
|
||||||
placeholder = "Members",
|
|
||||||
placement,
|
|
||||||
showTooltip = false,
|
|
||||||
tabIndex,
|
|
||||||
value,
|
|
||||||
} = props;
|
|
||||||
// states
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
|
||||||
// refs
|
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
// popper-js refs
|
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
|
||||||
// popper-js init
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
|
||||||
placement: placement ?? "bottom-start",
|
|
||||||
modifiers: [
|
|
||||||
{
|
|
||||||
name: "preventOverflow",
|
|
||||||
options: {
|
|
||||||
padding: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// store hooks
|
|
||||||
const { currentUser } = useUser();
|
|
||||||
const {
|
|
||||||
getUserDetails,
|
|
||||||
workspace: { workspaceMemberIds },
|
|
||||||
} = useMember();
|
|
||||||
|
|
||||||
const options = workspaceMemberIds?.map((userId) => {
|
|
||||||
const userDetails = getUserDetails(userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: userId,
|
|
||||||
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
|
|
||||||
content: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar name={userDetails?.display_name} src={userDetails?.avatar} />
|
|
||||||
<span className="flex-grow truncate">{currentUser?.id === userId ? "You" : userDetails?.display_name}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredOptions =
|
|
||||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
|
||||||
|
|
||||||
const comboboxProps: any = {
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
};
|
|
||||||
if (multiple) comboboxProps.multiple = true;
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
setIsOpen(false);
|
|
||||||
onClose && onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDropdown = () => {
|
|
||||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropdownOnChange = (val: string & string[]) => {
|
|
||||||
onChange(val);
|
|
||||||
if (!multiple) handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
|
||||||
|
|
||||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
toggleDropdown();
|
|
||||||
};
|
|
||||||
|
|
||||||
useOutsideClickDetector(dropdownRef, handleClose);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Combobox
|
|
||||||
as="div"
|
|
||||||
ref={dropdownRef}
|
|
||||||
tabIndex={tabIndex}
|
|
||||||
className={cn("h-full", className)}
|
|
||||||
{...comboboxProps}
|
|
||||||
handleKeyDown={handleKeyDown}
|
|
||||||
onChange={dropdownOnChange}
|
|
||||||
>
|
|
||||||
<Combobox.Button as={Fragment}>
|
|
||||||
{button ? (
|
|
||||||
<button
|
|
||||||
ref={setReferenceElement}
|
|
||||||
type="button"
|
|
||||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
|
||||||
onClick={handleOnClick}
|
|
||||||
>
|
|
||||||
{button}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
ref={setReferenceElement}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"clickable block h-full max-w-full outline-none",
|
|
||||||
{
|
|
||||||
"cursor-not-allowed text-custom-text-200": disabled,
|
|
||||||
"cursor-pointer": !disabled,
|
|
||||||
},
|
|
||||||
buttonContainerClassName
|
|
||||||
)}
|
|
||||||
onClick={handleOnClick}
|
|
||||||
>
|
|
||||||
<DropdownButton
|
|
||||||
className={buttonClassName}
|
|
||||||
isActive={isOpen}
|
|
||||||
tooltipHeading={placeholder}
|
|
||||||
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
|
|
||||||
showTooltip={showTooltip}
|
|
||||||
variant={buttonVariant}
|
|
||||||
>
|
|
||||||
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
|
|
||||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
|
||||||
<span className="flex-grow truncate text-xs leading-5">
|
|
||||||
{Array.isArray(value) && value.length > 0
|
|
||||||
? value.length === 1
|
|
||||||
? getUserDetails(value[0])?.display_name
|
|
||||||
: ""
|
|
||||||
: placeholder}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{dropdownArrow && (
|
|
||||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</DropdownButton>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</Combobox.Button>
|
|
||||||
{isOpen && (
|
|
||||||
<Combobox.Options className="fixed z-10" static>
|
|
||||||
<div
|
|
||||||
className="my-1 w-48 rounded 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"
|
|
||||||
ref={setPopperElement}
|
|
||||||
style={styles.popper}
|
|
||||||
{...attributes.popper}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
|
||||||
<Combobox.Input
|
|
||||||
as="input"
|
|
||||||
ref={inputRef}
|
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Search"
|
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
|
||||||
{filteredOptions ? (
|
|
||||||
filteredOptions.length > 0 ? (
|
|
||||||
filteredOptions.map((option) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className={({ active, selected }) =>
|
|
||||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
|
||||||
active ? "bg-custom-background-80" : ""
|
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className="flex-grow truncate">{option.content}</span>
|
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Combobox.Options>
|
|
||||||
)}
|
|
||||||
</Combobox>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,22 +1,22 @@
|
|||||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
import { usePopper } from "react-popper";
|
import { ChevronDown, X } from "lucide-react";
|
||||||
import { Check, ChevronDown, Search, X } from "lucide-react";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useModule } from "hooks/store";
|
import { useModule } from "hooks/store";
|
||||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// components
|
// components
|
||||||
import { DropdownButton } from "./buttons";
|
import { DropdownButton } from "../buttons";
|
||||||
// icons
|
// icons
|
||||||
import { DiceIcon, Tooltip } from "@plane/ui";
|
import { DiceIcon, Tooltip } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
// types
|
// types
|
||||||
import { TDropdownProps } from "./types";
|
import { TDropdownProps } from "../types";
|
||||||
// constants
|
// constants
|
||||||
import { BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants";
|
import { BUTTON_VARIANTS_WITHOUT_TEXT } from "../constants";
|
||||||
|
import { ModuleOptions } from "./module-options";
|
||||||
|
|
||||||
type Props = TDropdownProps & {
|
type Props = TDropdownProps & {
|
||||||
button?: ReactNode;
|
button?: ReactNode;
|
||||||
@ -38,14 +38,6 @@ type Props = TDropdownProps & {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
type DropdownOptions =
|
|
||||||
| {
|
|
||||||
value: string | null;
|
|
||||||
query: string;
|
|
||||||
content: JSX.Element;
|
|
||||||
}[]
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
type ButtonContentProps = {
|
type ButtonContentProps = {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
dropdownArrow: boolean;
|
dropdownArrow: boolean;
|
||||||
@ -166,64 +158,14 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
value,
|
value,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
// refs
|
// refs
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
// popper-js refs
|
// popper-js refs
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
|
||||||
// popper-js init
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
|
||||||
placement: placement ?? "bottom-start",
|
|
||||||
modifiers: [
|
|
||||||
{
|
|
||||||
name: "preventOverflow",
|
|
||||||
options: {
|
|
||||||
padding: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// store hooks
|
|
||||||
const {
|
|
||||||
router: { workspaceSlug },
|
|
||||||
} = useApplication();
|
|
||||||
const { getProjectModuleIds, fetchModules, getModuleById } = useModule();
|
|
||||||
const moduleIds = getProjectModuleIds(projectId);
|
|
||||||
|
|
||||||
const options: DropdownOptions = moduleIds?.map((moduleId) => {
|
const { getModuleNameById } = useModule();
|
||||||
const moduleDetails = getModuleById(moduleId);
|
|
||||||
return {
|
|
||||||
value: moduleId,
|
|
||||||
query: `${moduleDetails?.name}`,
|
|
||||||
content: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
|
||||||
<span className="flex-grow truncate">{moduleDetails?.name}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (!multiple)
|
|
||||||
options?.unshift({
|
|
||||||
value: null,
|
|
||||||
query: "No module",
|
|
||||||
content: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
|
||||||
<span className="flex-grow truncate">No module</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredOptions =
|
|
||||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
|
||||||
|
|
||||||
const onOpen = () => {
|
|
||||||
if (!moduleIds && workspaceSlug) fetchModules(workspaceSlug, projectId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@ -232,8 +174,8 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleDropdown = () => {
|
const toggleDropdown = () => {
|
||||||
if (!isOpen) onOpen();
|
|
||||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||||
|
if (isOpen) onClose && onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropdownOnChange = (val: string & string[]) => {
|
const dropdownOnChange = (val: string & string[]) => {
|
||||||
@ -307,7 +249,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
tooltipContent={
|
tooltipContent={
|
||||||
Array.isArray(value)
|
Array.isArray(value)
|
||||||
? `${value
|
? `${value
|
||||||
.map((moduleId) => getModuleById(moduleId)?.name)
|
.map((moduleId) => getModuleNameById(moduleId))
|
||||||
.toString()
|
.toString()
|
||||||
.replaceAll(",", ", ")}`
|
.replaceAll(",", ", ")}`
|
||||||
: ""
|
: ""
|
||||||
@ -332,60 +274,13 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
)}
|
)}
|
||||||
</Combobox.Button>
|
</Combobox.Button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<Combobox.Options className="fixed z-10" static>
|
<ModuleOptions
|
||||||
<div
|
isOpen={isOpen}
|
||||||
className="my-1 w-48 rounded 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"
|
projectId={projectId}
|
||||||
ref={setPopperElement}
|
placement={placement}
|
||||||
style={styles.popper}
|
referenceElement={referenceElement}
|
||||||
{...attributes.popper}
|
multiple={multiple}
|
||||||
>
|
/>
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
|
||||||
<Combobox.Input
|
|
||||||
as="input"
|
|
||||||
ref={inputRef}
|
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Search"
|
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
|
||||||
{filteredOptions ? (
|
|
||||||
filteredOptions.length > 0 ? (
|
|
||||||
filteredOptions.map((option) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className={({ active, selected }) =>
|
|
||||||
cn(
|
|
||||||
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
|
|
||||||
{
|
|
||||||
"bg-custom-background-80": active,
|
|
||||||
"text-custom-text-100": selected,
|
|
||||||
"text-custom-text-200": !selected,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className="flex-grow truncate">{option.content}</span>
|
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Combobox.Options>
|
|
||||||
)}
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
163
web/components/dropdowns/module/module-options.tsx
Normal file
163
web/components/dropdowns/module/module-options.tsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Combobox } from "@headlessui/react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
//components
|
||||||
|
import { DiceIcon } from "@plane/ui";
|
||||||
|
//store
|
||||||
|
import { useApplication, useModule } from "hooks/store";
|
||||||
|
//hooks
|
||||||
|
import { usePopper } from "react-popper";
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
//icon
|
||||||
|
import { Check, Search } from "lucide-react";
|
||||||
|
//types
|
||||||
|
import { Placement } from "@popperjs/core";
|
||||||
|
|
||||||
|
type DropdownOptions =
|
||||||
|
| {
|
||||||
|
value: string | null;
|
||||||
|
query: string;
|
||||||
|
content: JSX.Element;
|
||||||
|
}[]
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
referenceElement: HTMLButtonElement | null;
|
||||||
|
placement: Placement | undefined;
|
||||||
|
isOpen: boolean;
|
||||||
|
multiple: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModuleOptions = observer((props: Props) => {
|
||||||
|
const { projectId, isOpen, referenceElement, placement, multiple } = props;
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
router: { workspaceSlug },
|
||||||
|
} = useApplication();
|
||||||
|
const { getProjectModuleIds, fetchModules, getModuleById } = useModule();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
onOpen();
|
||||||
|
inputRef.current && inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// popper-js init
|
||||||
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
|
placement: placement ?? "bottom-start",
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: "preventOverflow",
|
||||||
|
options: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const moduleIds = getProjectModuleIds(projectId);
|
||||||
|
|
||||||
|
const onOpen = () => {
|
||||||
|
if (workspaceSlug && !moduleIds) fetchModules(workspaceSlug, projectId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (query !== "" && e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: DropdownOptions = moduleIds?.map((moduleId) => {
|
||||||
|
const moduleDetails = getModuleById(moduleId);
|
||||||
|
return {
|
||||||
|
value: moduleId,
|
||||||
|
query: `${moduleDetails?.name}`,
|
||||||
|
content: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
||||||
|
<span className="flex-grow truncate">{moduleDetails?.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!multiple)
|
||||||
|
options?.unshift({
|
||||||
|
value: null,
|
||||||
|
query: "No module",
|
||||||
|
content: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
||||||
|
<span className="flex-grow truncate">No module</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredOptions =
|
||||||
|
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox.Options className="fixed z-10" static>
|
||||||
|
<div
|
||||||
|
className="my-1 w-48 rounded 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"
|
||||||
|
ref={setPopperElement}
|
||||||
|
style={styles.popper}
|
||||||
|
{...attributes.popper}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
|
<Combobox.Input
|
||||||
|
as="input"
|
||||||
|
ref={inputRef}
|
||||||
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search"
|
||||||
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
|
onKeyDown={searchInputKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
|
{filteredOptions ? (
|
||||||
|
filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
cn(
|
||||||
|
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
|
||||||
|
{
|
||||||
|
"bg-custom-background-80": active,
|
||||||
|
"text-custom-text-100": selected,
|
||||||
|
"text-custom-text-200": !selected,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span className="flex-grow truncate">{option.content}</span>
|
||||||
|
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Combobox.Options>
|
||||||
|
);
|
||||||
|
});
|
@ -314,6 +314,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
const toggleDropdown = () => {
|
const toggleDropdown = () => {
|
||||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||||
|
if (isOpen) onClose && onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropdownOnChange = (val: TIssuePriorities) => {
|
const dropdownOnChange = (val: TIssuePriorities) => {
|
||||||
@ -329,6 +330,13 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
|||||||
toggleDropdown();
|
toggleDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (query !== "" && e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useOutsideClickDetector(dropdownRef, handleClose);
|
useOutsideClickDetector(dropdownRef, handleClose);
|
||||||
|
|
||||||
const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant)
|
const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant)
|
||||||
@ -417,6 +425,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
|
onKeyDown={searchInputKeyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
|
@ -104,6 +104,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
const toggleDropdown = () => {
|
const toggleDropdown = () => {
|
||||||
if (!isOpen) onOpen();
|
if (!isOpen) onOpen();
|
||||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||||
|
if (isOpen) onClose && onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropdownOnChange = (val: string) => {
|
const dropdownOnChange = (val: string) => {
|
||||||
@ -119,6 +120,13 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
toggleDropdown();
|
toggleDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (query !== "" && e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useOutsideClickDetector(dropdownRef, handleClose);
|
useOutsideClickDetector(dropdownRef, handleClose);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -205,6 +213,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
|
onKeyDown={searchInputKeyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
|
@ -15,9 +15,9 @@ export const MonthChartView: FC<any> = observer(() => {
|
|||||||
const monthBlocks: IMonthBlock[] = renderView;
|
const monthBlocks: IMonthBlock[] = renderView;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-0 left-0 h-full w-max flex divide-x divide-custom-border-100/50">
|
<div className="absolute top-0 left-0 min-h-full h-max w-max flex divide-x divide-custom-border-100/50">
|
||||||
{monthBlocks?.map((block, rootIndex) => (
|
{monthBlocks?.map((block, rootIndex) => (
|
||||||
<div key={`month-${block?.month}-${block?.year}`} className="relative">
|
<div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col">
|
||||||
<div
|
<div
|
||||||
className="w-full sticky top-0 z-[5] bg-custom-background-100"
|
className="w-full sticky top-0 z-[5] bg-custom-background-100"
|
||||||
style={{
|
style={{
|
||||||
|
@ -71,7 +71,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
|
|||||||
link={
|
link={
|
||||||
<BreadcrumbLink
|
<BreadcrumbLink
|
||||||
href={`/${workspaceSlug}/projects/${projectId}/archived-issues`}
|
href={`/${workspaceSlug}/projects/${projectId}/archived-issues`}
|
||||||
label="Archived Issues"
|
label="Archived issues"
|
||||||
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@ -109,7 +109,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
|
|||||||
type="text"
|
type="text"
|
||||||
link={
|
link={
|
||||||
<BreadcrumbLink
|
<BreadcrumbLink
|
||||||
label="Archived Issues"
|
label="Archived issues"
|
||||||
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@ -92,7 +92,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
|||||||
id: inboxIssueId,
|
id: inboxIssueId,
|
||||||
state: "SUCCESS",
|
state: "SUCCESS",
|
||||||
element: "Inbox page",
|
element: "Inbox page",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
|
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
|
||||||
@ -269,12 +269,17 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
|||||||
<DayPicker
|
<DayPicker
|
||||||
selected={date ? new Date(date) : undefined}
|
selected={date ? new Date(date) : undefined}
|
||||||
defaultMonth={date ? new Date(date) : undefined}
|
defaultMonth={date ? new Date(date) : undefined}
|
||||||
onSelect={(date) => { if (!date) return; setDate(date) }}
|
onSelect={(date) => {
|
||||||
|
if (!date) return;
|
||||||
|
setDate(date);
|
||||||
|
}}
|
||||||
mode="single"
|
mode="single"
|
||||||
className="border border-custom-border-200 rounded-md p-3"
|
className="border border-custom-border-200 rounded-md p-3"
|
||||||
disabled={[{
|
disabled={[
|
||||||
before: tomorrow,
|
{
|
||||||
}]}
|
before: tomorrow,
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
106
web/components/issues/archive-issue-modal.tsx
Normal file
106
web/components/issues/archive-issue-modal.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useState, Fragment } from "react";
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
// hooks
|
||||||
|
import { useProject } from "hooks/store";
|
||||||
|
import { useIssues } from "hooks/store/use-issues";
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
// types
|
||||||
|
import { TIssue } from "@plane/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data?: TIssue;
|
||||||
|
dataId?: string | null | undefined;
|
||||||
|
handleClose: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
onSubmit?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ArchiveIssueModal: React.FC<Props> = (props) => {
|
||||||
|
const { dataId, data, isOpen, handleClose, onSubmit } = props;
|
||||||
|
// states
|
||||||
|
const [isArchiving, setIsArchiving] = useState(false);
|
||||||
|
// store hooks
|
||||||
|
const { getProjectById } = useProject();
|
||||||
|
const { issueMap } = useIssues();
|
||||||
|
// toast alert
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
if (!dataId && !data) return null;
|
||||||
|
|
||||||
|
const issue = data ? data : issueMap[dataId!];
|
||||||
|
const projectDetails = getProjectById(issue.project_id);
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
setIsArchiving(false);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchiveIssue = async () => {
|
||||||
|
if (!onSubmit) return;
|
||||||
|
|
||||||
|
setIsArchiving(true);
|
||||||
|
await onSubmit()
|
||||||
|
.then(() => onClose())
|
||||||
|
.catch(() =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Issue could not be archived. Please try again.",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.finally(() => setIsArchiving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<h3 className="text-xl font-medium 2xl:text-2xl">
|
||||||
|
Archive issue {projectDetails?.identifier} {issue.sequence_id}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-custom-text-200 mt-3">
|
||||||
|
Are you sure you want to archive the issue? All your archived issues can be restored later.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2 mt-3">
|
||||||
|
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
|
||||||
|
{isArchiving ? "Archiving" : "Archive"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
};
|
@ -1,139 +0,0 @@
|
|||||||
import { useEffect, useState, Fragment } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
// hooks
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
import { useIssues, useProject } from "hooks/store";
|
|
||||||
// ui
|
|
||||||
import { Button } from "@plane/ui";
|
|
||||||
// types
|
|
||||||
import type { TIssue } from "@plane/types";
|
|
||||||
import { EIssuesStoreType } from "constants/issue";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean;
|
|
||||||
handleClose: () => void;
|
|
||||||
data: TIssue;
|
|
||||||
onSubmit?: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DeleteArchivedIssueModal: React.FC<Props> = observer((props) => {
|
|
||||||
const { data, isOpen, handleClose, onSubmit } = props;
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug } = router.query;
|
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
const { getProjectById } = useProject();
|
|
||||||
|
|
||||||
const {
|
|
||||||
issues: { removeIssue },
|
|
||||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
|
||||||
|
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleIssueDelete = async () => {
|
|
||||||
if (!workspaceSlug) return;
|
|
||||||
|
|
||||||
setIsDeleteLoading(true);
|
|
||||||
|
|
||||||
await removeIssue(workspaceSlug.toString(), data.project_id, data.id)
|
|
||||||
.then(() => {
|
|
||||||
if (onSubmit) onSubmit();
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
const error = err?.detail;
|
|
||||||
const errorString = Array.isArray(error) ? error[0] : error;
|
|
||||||
|
|
||||||
setToastAlert({
|
|
||||||
title: "Error",
|
|
||||||
type: "error",
|
|
||||||
message: errorString || "Something went wrong.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
onClose();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition.Root show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl">
|
|
||||||
<div className="flex flex-col gap-6 p-6">
|
|
||||||
<div className="flex w-full items-center justify-start gap-6">
|
|
||||||
<span className="place-items-center rounded-full bg-red-500/20 p-4">
|
|
||||||
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center justify-start">
|
|
||||||
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Archived Issue</h3>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
<p className="text-sm text-custom-text-200">
|
|
||||||
Are you sure you want to delete issue{" "}
|
|
||||||
<span className="break-words font-medium text-custom-text-100">
|
|
||||||
{getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
|
|
||||||
</span>
|
|
||||||
{""}? All of the data related to the archived issue will be permanently removed. This action
|
|
||||||
cannot be undone.
|
|
||||||
</p>
|
|
||||||
</span>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
tabIndex={1}
|
|
||||||
onClick={handleIssueDelete}
|
|
||||||
loading={isDeleteLoading}
|
|
||||||
>
|
|
||||||
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,138 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
|
||||||
// services
|
|
||||||
import { IssueDraftService } from "services/issue";
|
|
||||||
// hooks
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// icons
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
// ui
|
|
||||||
import { Button } from "@plane/ui";
|
|
||||||
// types
|
|
||||||
import type { TIssue } from "@plane/types";
|
|
||||||
import { useProject } from "hooks/store";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean;
|
|
||||||
handleClose: () => void;
|
|
||||||
data: TIssue | null;
|
|
||||||
onSubmit?: () => Promise<void> | void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const issueDraftService = new IssueDraftService();
|
|
||||||
|
|
||||||
export const DeleteDraftIssueModal: React.FC<Props> = (props) => {
|
|
||||||
const { isOpen, handleClose, data, onSubmit } = props;
|
|
||||||
// states
|
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
|
||||||
// router
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug } = router.query;
|
|
||||||
// toast alert
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
// hooks
|
|
||||||
const { getProjectById } = useProject();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeletion = async () => {
|
|
||||||
if (!workspaceSlug || !data) return;
|
|
||||||
|
|
||||||
setIsDeleteLoading(true);
|
|
||||||
|
|
||||||
await issueDraftService
|
|
||||||
.deleteDraftIssue(workspaceSlug.toString(), data.project_id, data.id)
|
|
||||||
.then(() => {
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
handleClose();
|
|
||||||
|
|
||||||
setToastAlert({
|
|
||||||
title: "Success",
|
|
||||||
message: "Draft Issue deleted successfully",
|
|
||||||
type: "success",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
handleClose();
|
|
||||||
setToastAlert({
|
|
||||||
title: "Error",
|
|
||||||
message: "Something went wrong",
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
});
|
|
||||||
if (onSubmit) await onSubmit();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={React.Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
|
||||||
<Transition.Child
|
|
||||||
as={React.Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl">
|
|
||||||
<div className="flex flex-col gap-6 p-6">
|
|
||||||
<div className="flex w-full items-center justify-start gap-6">
|
|
||||||
<span className="place-items-center rounded-full bg-red-500/20 p-4">
|
|
||||||
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center justify-start">
|
|
||||||
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Draft Issue</h3>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
<p className="text-sm text-custom-text-200">
|
|
||||||
Are you sure you want to delete issue{" "}
|
|
||||||
<span className="break-words font-medium text-custom-text-100">
|
|
||||||
{data && getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
|
|
||||||
</span>
|
|
||||||
{""}? All of the data related to the draft issue will be permanently removed. This action cannot
|
|
||||||
be undone.
|
|
||||||
</p>
|
|
||||||
</span>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
|
|
||||||
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
);
|
|
||||||
};
|
|
@ -23,14 +23,14 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
const { issueMap } = useIssues();
|
const { issueMap } = useIssues();
|
||||||
|
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
// hooks
|
// hooks
|
||||||
const { getProjectById } = useProject();
|
const { getProjectById } = useProject();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsDeleteLoading(false);
|
setIsDeleting(false);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!dataId && !data) return null;
|
if (!dataId && !data) return null;
|
||||||
@ -38,12 +38,12 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
|
|||||||
const issue = data ? data : issueMap[dataId!];
|
const issue = data ? data : issueMap[dataId!];
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
setIsDeleteLoading(false);
|
setIsDeleting(false);
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIssueDelete = async () => {
|
const handleIssueDelete = async () => {
|
||||||
setIsDeleteLoading(true);
|
setIsDeleting(true);
|
||||||
if (onSubmit)
|
if (onSubmit)
|
||||||
await onSubmit()
|
await onSubmit()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@ -56,7 +56,7 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
|
|||||||
message: "Failed to delete issue",
|
message: "Failed to delete issue",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => setIsDeleteLoading(false));
|
.finally(() => setIsDeleting(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -109,14 +109,8 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
|
|||||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="danger" size="sm" tabIndex={1} onClick={handleIssueDelete} loading={isDeleting}>
|
||||||
variant="danger"
|
{isDeleting ? "Deleting" : "Delete"}
|
||||||
size="sm"
|
|
||||||
tabIndex={1}
|
|
||||||
onClick={handleIssueDelete}
|
|
||||||
loading={isDeleteLoading}
|
|
||||||
>
|
|
||||||
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,668 +0,0 @@
|
|||||||
import React, { FC, useState, useEffect, useRef } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { Sparkle, X } from "lucide-react";
|
|
||||||
// hooks
|
|
||||||
import { useApplication, useEstimate, useMention, useProject, useWorkspace } from "hooks/store";
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
import useLocalStorage from "hooks/use-local-storage";
|
|
||||||
// services
|
|
||||||
import { AIService } from "services/ai.service";
|
|
||||||
import { FileService } from "services/file.service";
|
|
||||||
// components
|
|
||||||
import { GptAssistantPopover } from "components/core";
|
|
||||||
import { ParentIssuesListModal } from "components/issues";
|
|
||||||
import { IssueLabelSelect } from "components/issues/select";
|
|
||||||
import { CreateStateModal } from "components/states";
|
|
||||||
import { CreateLabelModal } from "components/labels";
|
|
||||||
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
|
||||||
import {
|
|
||||||
CycleDropdown,
|
|
||||||
DateDropdown,
|
|
||||||
EstimateDropdown,
|
|
||||||
ModuleDropdown,
|
|
||||||
PriorityDropdown,
|
|
||||||
ProjectDropdown,
|
|
||||||
ProjectMemberDropdown,
|
|
||||||
StateDropdown,
|
|
||||||
} from "components/dropdowns";
|
|
||||||
// ui
|
|
||||||
import { Button, CustomMenu, Input, ToggleSwitch } from "@plane/ui";
|
|
||||||
// helpers
|
|
||||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
|
||||||
// types
|
|
||||||
import type { IUser, TIssue, ISearchIssueResponse } from "@plane/types";
|
|
||||||
|
|
||||||
const aiService = new AIService();
|
|
||||||
const fileService = new FileService();
|
|
||||||
|
|
||||||
const defaultValues: Partial<TIssue> = {
|
|
||||||
project_id: "",
|
|
||||||
name: "",
|
|
||||||
description_html: "<p></p>",
|
|
||||||
estimate_point: null,
|
|
||||||
state_id: "",
|
|
||||||
parent_id: null,
|
|
||||||
priority: "none",
|
|
||||||
assignee_ids: [],
|
|
||||||
label_ids: [],
|
|
||||||
start_date: undefined,
|
|
||||||
target_date: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IssueFormProps {
|
|
||||||
handleFormSubmit: (
|
|
||||||
formData: Partial<TIssue>,
|
|
||||||
action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue"
|
|
||||||
) => Promise<void>;
|
|
||||||
data?: Partial<TIssue> | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
prePopulatedData?: Partial<TIssue> | null;
|
|
||||||
projectId: string;
|
|
||||||
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
|
|
||||||
createMore: boolean;
|
|
||||||
setCreateMore: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
handleClose: () => void;
|
|
||||||
handleDiscard: () => void;
|
|
||||||
status: boolean;
|
|
||||||
user: IUser | undefined;
|
|
||||||
fieldsToShow: (
|
|
||||||
| "project"
|
|
||||||
| "name"
|
|
||||||
| "description"
|
|
||||||
| "state"
|
|
||||||
| "priority"
|
|
||||||
| "assignee"
|
|
||||||
| "label"
|
|
||||||
| "startDate"
|
|
||||||
| "dueDate"
|
|
||||||
| "estimate"
|
|
||||||
| "parent"
|
|
||||||
| "all"
|
|
||||||
)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DraftIssueForm: FC<IssueFormProps> = observer((props) => {
|
|
||||||
const {
|
|
||||||
handleFormSubmit,
|
|
||||||
data,
|
|
||||||
isOpen,
|
|
||||||
prePopulatedData,
|
|
||||||
projectId,
|
|
||||||
setActiveProject,
|
|
||||||
createMore,
|
|
||||||
setCreateMore,
|
|
||||||
status,
|
|
||||||
fieldsToShow,
|
|
||||||
handleDiscard,
|
|
||||||
} = props;
|
|
||||||
// states
|
|
||||||
const [stateModal, setStateModal] = useState(false);
|
|
||||||
const [labelModal, setLabelModal] = useState(false);
|
|
||||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
|
||||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
|
||||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
|
||||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
|
||||||
// store hooks
|
|
||||||
const { areEstimatesEnabledForProject } = useEstimate();
|
|
||||||
const { mentionHighlights, mentionSuggestions } = useMention();
|
|
||||||
// hooks
|
|
||||||
const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {});
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
// refs
|
|
||||||
const editorRef = useRef<any>(null);
|
|
||||||
// router
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug } = router.query;
|
|
||||||
const workspaceStore = useWorkspace();
|
|
||||||
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
|
|
||||||
|
|
||||||
// store
|
|
||||||
const {
|
|
||||||
config: { envConfig },
|
|
||||||
} = useApplication();
|
|
||||||
const { getProjectById } = useProject();
|
|
||||||
// form info
|
|
||||||
const {
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
watch,
|
|
||||||
control,
|
|
||||||
getValues,
|
|
||||||
setValue,
|
|
||||||
setFocus,
|
|
||||||
} = useForm<TIssue>({
|
|
||||||
defaultValues: prePopulatedData ?? defaultValues,
|
|
||||||
reValidateMode: "onChange",
|
|
||||||
});
|
|
||||||
|
|
||||||
const issueName = watch("name");
|
|
||||||
|
|
||||||
const payload: Partial<TIssue> = {
|
|
||||||
name: watch("name"),
|
|
||||||
description_html: watch("description_html"),
|
|
||||||
state_id: watch("state_id"),
|
|
||||||
priority: watch("priority"),
|
|
||||||
assignee_ids: watch("assignee_ids"),
|
|
||||||
label_ids: watch("label_ids"),
|
|
||||||
start_date: watch("start_date"),
|
|
||||||
target_date: watch("target_date"),
|
|
||||||
project_id: watch("project_id"),
|
|
||||||
parent_id: watch("parent_id"),
|
|
||||||
cycle_id: watch("cycle_id"),
|
|
||||||
module_ids: watch("module_ids"),
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen || data) return;
|
|
||||||
|
|
||||||
setLocalStorageValue(
|
|
||||||
JSON.stringify({
|
|
||||||
...payload,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [JSON.stringify(payload), isOpen, data]);
|
|
||||||
|
|
||||||
// const onClose = () => {
|
|
||||||
// handleClose();
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const onClose = () => {
|
|
||||||
// handleClose();
|
|
||||||
// };
|
|
||||||
|
|
||||||
const handleCreateUpdateIssue = async (
|
|
||||||
formData: Partial<TIssue>,
|
|
||||||
action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft"
|
|
||||||
) => {
|
|
||||||
await handleFormSubmit(
|
|
||||||
{
|
|
||||||
...(data ?? {}),
|
|
||||||
...formData,
|
|
||||||
// is_draft: action === "createDraft" || action === "updateDraft",
|
|
||||||
},
|
|
||||||
action
|
|
||||||
);
|
|
||||||
// TODO: check_with_backend
|
|
||||||
|
|
||||||
setGptAssistantModal(false);
|
|
||||||
|
|
||||||
reset({
|
|
||||||
...defaultValues,
|
|
||||||
project_id: projectId,
|
|
||||||
description_html: "<p></p>",
|
|
||||||
});
|
|
||||||
editorRef?.current?.clearEditor();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAiAssistance = async (response: string) => {
|
|
||||||
if (!workspaceSlug || !projectId) return;
|
|
||||||
|
|
||||||
// setValue("description", {});
|
|
||||||
setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
|
|
||||||
editorRef.current?.setEditorValue(`${watch("description_html")}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAutoGenerateDescription = async () => {
|
|
||||||
if (!workspaceSlug || !projectId) return;
|
|
||||||
|
|
||||||
setIAmFeelingLucky(true);
|
|
||||||
|
|
||||||
aiService
|
|
||||||
.createGptTask(workspaceSlug as string, projectId as string, {
|
|
||||||
prompt: issueName,
|
|
||||||
task: "Generate a proper description for this issue.",
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (res.response === "")
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message:
|
|
||||||
"Issue title isn't informative enough to generate the description. Please try with a different title.",
|
|
||||||
});
|
|
||||||
else handleAiAssistance(res.response_html);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
const error = err?.data?.error;
|
|
||||||
|
|
||||||
if (err.status === 429)
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: error || "You have reached the maximum number of requests of 50 requests per month per user.",
|
|
||||||
});
|
|
||||||
else
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: error || "Some error occurred. Please try again.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => setIAmFeelingLucky(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFocus("name");
|
|
||||||
}, [setFocus]);
|
|
||||||
|
|
||||||
// update projectId in form when projectId changes
|
|
||||||
useEffect(() => {
|
|
||||||
reset({
|
|
||||||
...getValues(),
|
|
||||||
project_id: projectId,
|
|
||||||
});
|
|
||||||
}, [getValues, projectId, reset]);
|
|
||||||
|
|
||||||
const startDate = watch("start_date");
|
|
||||||
const targetDate = watch("target_date");
|
|
||||||
|
|
||||||
const minDate = startDate ? new Date(startDate) : null;
|
|
||||||
minDate?.setDate(minDate.getDate());
|
|
||||||
|
|
||||||
const maxDate = targetDate ? new Date(targetDate) : null;
|
|
||||||
maxDate?.setDate(maxDate.getDate());
|
|
||||||
|
|
||||||
const projectDetails = getProjectById(projectId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{projectId && (
|
|
||||||
<>
|
|
||||||
<CreateStateModal isOpen={stateModal} handleClose={() => setStateModal(false)} projectId={projectId} />
|
|
||||||
<CreateLabelModal
|
|
||||||
isOpen={labelModal}
|
|
||||||
handleClose={() => setLabelModal(false)}
|
|
||||||
projectId={projectId}
|
|
||||||
onSuccess={(response) => setValue("label_ids", [...watch("label_ids"), response.id])}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit((formData) =>
|
|
||||||
handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createDraft")
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="project_id"
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<div className="h-7">
|
|
||||||
<ProjectDropdown
|
|
||||||
value={value}
|
|
||||||
onChange={(val) => {
|
|
||||||
onChange(val);
|
|
||||||
setActiveProject(val);
|
|
||||||
}}
|
|
||||||
buttonVariant="border-with-text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<h3 className="text-xl font-semibold leading-6 text-custom-text-100">
|
|
||||||
{status ? "Update" : "Create"} issue
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{watch("parent_id") &&
|
|
||||||
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) &&
|
|
||||||
selectedParentIssue && (
|
|
||||||
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="block h-1.5 w-1.5 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: selectedParentIssue.state__color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="flex-shrink-0 text-custom-text-200">
|
|
||||||
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
|
|
||||||
</span>
|
|
||||||
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
|
|
||||||
<X
|
|
||||||
className="h-3 w-3 cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
setValue("parent_id", null);
|
|
||||||
setSelectedParentIssue(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="mt-2 space-y-3">
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && (
|
|
||||||
<div>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="name"
|
|
||||||
rules={{
|
|
||||||
required: "Title is required",
|
|
||||||
maxLength: {
|
|
||||||
value: 255,
|
|
||||||
message: "Title should be less than 255 characters",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
render={({ field: { value, onChange, ref } }) => (
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
ref={ref}
|
|
||||||
hasError={Boolean(errors.name)}
|
|
||||||
placeholder="Title"
|
|
||||||
className="w-full resize-none text-xl"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="border-0.5 absolute bottom-3.5 right-3.5 flex items-center gap-2">
|
|
||||||
{issueName && issueName !== "" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-80 ${
|
|
||||||
iAmFeelingLucky ? "cursor-wait" : ""
|
|
||||||
}`}
|
|
||||||
onClick={handleAutoGenerateDescription}
|
|
||||||
disabled={iAmFeelingLucky}
|
|
||||||
>
|
|
||||||
{iAmFeelingLucky ? (
|
|
||||||
"Generating response..."
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkle className="h-3.5 w-3.5" />I{"'"}m feeling lucky
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{envConfig?.has_openai_configured && (
|
|
||||||
<GptAssistantPopover
|
|
||||||
isOpen={gptAssistantModal}
|
|
||||||
projectId={projectId}
|
|
||||||
handleClose={() => {
|
|
||||||
setGptAssistantModal((prevData) => !prevData);
|
|
||||||
// this is done so that the title do not reset after gpt popover closed
|
|
||||||
reset(getValues());
|
|
||||||
}}
|
|
||||||
onResponse={(response) => {
|
|
||||||
handleAiAssistance(response);
|
|
||||||
}}
|
|
||||||
button={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-80"
|
|
||||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
|
||||||
>
|
|
||||||
<Sparkle className="h-3.5 w-3.5" />
|
|
||||||
AI
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
className=" !min-w-[38rem]"
|
|
||||||
placement="top-end"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Controller
|
|
||||||
name="description_html"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<RichTextEditorWithRef
|
|
||||||
cancelUploadImage={fileService.cancelUpload}
|
|
||||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
|
||||||
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
|
|
||||||
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
|
|
||||||
ref={editorRef}
|
|
||||||
debouncedUpdatesEnabled={false}
|
|
||||||
value={
|
|
||||||
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
|
|
||||||
? watch("description_html")
|
|
||||||
: value
|
|
||||||
}
|
|
||||||
customClassName="min-h-[150px]"
|
|
||||||
onChange={(description: Object, description_html: string) => {
|
|
||||||
onChange(description_html);
|
|
||||||
}}
|
|
||||||
mentionHighlights={mentionHighlights}
|
|
||||||
mentionSuggestions={mentionSuggestions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="state_id"
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<div className="h-7">
|
|
||||||
<StateDropdown
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
projectId={projectId}
|
|
||||||
buttonVariant="border-with-text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="priority"
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<div className="h-7">
|
|
||||||
<PriorityDropdown value={value} onChange={onChange} buttonVariant="border-with-text" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="assignee_ids"
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<div className="h-7">
|
|
||||||
<ProjectMemberDropdown
|
|
||||||
projectId={projectId}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"}
|
|
||||||
buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
|
||||||
placeholder="Assignees"
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="label_ids"
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<div className="h-7">
|
|
||||||
<IssueLabelSelect
|
|
||||||
setIsOpen={setLabelModal}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="start_date"
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<div className="h-7">
|
|
||||||
<DateDropdown
|
|
||||||
value={value}
|
|
||||||
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)}
|
|
||||||
buttonVariant="border-with-text"
|
|
||||||
placeholder="Start date"
|
|
||||||
maxDate={maxDate ?? undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="target_date"
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<div className="h-7">
|
|
||||||
<DateDropdown
|
|
||||||
value={value}
|
|
||||||
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)}
|
|
||||||
buttonVariant="border-with-text"
|
|
||||||
placeholder="Due date"
|
|
||||||
minDate={minDate ?? undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{projectDetails?.cycle_view && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="cycle_id"
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<div className="h-7">
|
|
||||||
<CycleDropdown
|
|
||||||
projectId={projectId}
|
|
||||||
onChange={(cycleId) => onChange(cycleId)}
|
|
||||||
value={value}
|
|
||||||
buttonVariant="border-with-text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{projectDetails?.module_view && workspaceSlug && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="module_ids"
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<div className="h-7">
|
|
||||||
<ModuleDropdown
|
|
||||||
projectId={projectId}
|
|
||||||
value={value ?? []}
|
|
||||||
onChange={onChange}
|
|
||||||
buttonVariant="border-with-text"
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) &&
|
|
||||||
areEstimatesEnabledForProject(projectId) && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="estimate_point"
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<div className="h-7">
|
|
||||||
<EstimateDropdown
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
projectId={projectId}
|
|
||||||
buttonVariant="border-with-text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="parent_id"
|
|
||||||
render={({ field: { onChange } }) => (
|
|
||||||
<ParentIssuesListModal
|
|
||||||
isOpen={parentIssueListModalOpen}
|
|
||||||
handleClose={() => setParentIssueListModalOpen(false)}
|
|
||||||
onChange={(issue) => {
|
|
||||||
onChange(issue.id);
|
|
||||||
setSelectedParentIssue(issue);
|
|
||||||
}}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
|
||||||
<CustomMenu ellipsis>
|
|
||||||
{watch("parent_id") ? (
|
|
||||||
<>
|
|
||||||
<CustomMenu.MenuItem onClick={() => setParentIssueListModalOpen(true)}>
|
|
||||||
Change parent issue
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem onClick={() => setValue("parent_id", null)}>
|
|
||||||
Remove parent issue
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<CustomMenu.MenuItem onClick={() => setParentIssueListModalOpen(true)}>
|
|
||||||
Select Parent Issue
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
)}
|
|
||||||
</CustomMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="-mx-5 mt-5 flex items-center justify-between gap-2 border-t border-custom-border-200 px-5 pt-5">
|
|
||||||
<div
|
|
||||||
className="flex cursor-pointer items-center gap-1"
|
|
||||||
onClick={() => setCreateMore((prevData) => !prevData)}
|
|
||||||
>
|
|
||||||
<span className="text-xs">Create more</span>
|
|
||||||
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="neutral-primary" size="sm" onClick={handleDiscard}>
|
|
||||||
Discard
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="neutral-primary"
|
|
||||||
size="sm"
|
|
||||||
loading={isSubmitting}
|
|
||||||
onClick={handleSubmit((formData) =>
|
|
||||||
handleCreateUpdateIssue(formData, data?.id ? "updateDraft" : "createDraft")
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Saving..." : "Save Draft"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
loading={isSubmitting}
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSubmit((formData) =>
|
|
||||||
handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createNewIssue")
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Saving..." : "Add Issue"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,349 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { mutate } from "swr";
|
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
|
||||||
// services
|
|
||||||
import { IssueService } from "services/issue";
|
|
||||||
import { ModuleService } from "services/module.service";
|
|
||||||
// hooks
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
import useLocalStorage from "hooks/use-local-storage";
|
|
||||||
import { useIssues, useProject, useUser } from "hooks/store";
|
|
||||||
// components
|
|
||||||
import { DraftIssueForm } from "components/issues";
|
|
||||||
// types
|
|
||||||
import type { TIssue } from "@plane/types";
|
|
||||||
import { EIssuesStoreType } from "constants/issue";
|
|
||||||
// fetch-keys
|
|
||||||
import { PROJECT_ISSUES_DETAILS, USER_ISSUE, SUB_ISSUES } from "constants/fetch-keys";
|
|
||||||
|
|
||||||
interface IssuesModalProps {
|
|
||||||
data?: TIssue | null;
|
|
||||||
handleClose: () => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
isUpdatingSingleIssue?: boolean;
|
|
||||||
prePopulateData?: Partial<TIssue>;
|
|
||||||
fieldsToShow?: (
|
|
||||||
| "project"
|
|
||||||
| "name"
|
|
||||||
| "description"
|
|
||||||
| "state"
|
|
||||||
| "priority"
|
|
||||||
| "assignee"
|
|
||||||
| "label"
|
|
||||||
| "startDate"
|
|
||||||
| "dueDate"
|
|
||||||
| "estimate"
|
|
||||||
| "parent"
|
|
||||||
| "all"
|
|
||||||
)[];
|
|
||||||
onSubmit?: (data: Partial<TIssue>) => Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// services
|
|
||||||
const issueService = new IssueService();
|
|
||||||
const moduleService = new ModuleService();
|
|
||||||
|
|
||||||
export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer((props) => {
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
handleClose,
|
|
||||||
isOpen,
|
|
||||||
isUpdatingSingleIssue = false,
|
|
||||||
prePopulateData: prePopulateDataProps,
|
|
||||||
fieldsToShow = ["all"],
|
|
||||||
onSubmit,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
// states
|
|
||||||
const [createMore, setCreateMore] = useState(false);
|
|
||||||
const [activeProject, setActiveProject] = useState<string | null>(null);
|
|
||||||
const [prePopulateData, setPreloadedData] = useState<Partial<TIssue> | undefined>(undefined);
|
|
||||||
// router
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
|
||||||
// store
|
|
||||||
const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT);
|
|
||||||
const { currentUser } = useUser();
|
|
||||||
const { workspaceProjectIds: workspaceProjects } = useProject();
|
|
||||||
// derived values
|
|
||||||
const projects = workspaceProjects;
|
|
||||||
|
|
||||||
const { clearValue: clearDraftIssueLocalStorage } = useLocalStorage("draftedIssue", {});
|
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
handleClose();
|
|
||||||
setActiveProject(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDiscard = () => {
|
|
||||||
clearDraftIssueLocalStorage();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPreloadedData(prePopulateDataProps ?? {});
|
|
||||||
|
|
||||||
if (cycleId && !prePopulateDataProps?.cycle_id) {
|
|
||||||
setPreloadedData((prevData) => ({
|
|
||||||
...(prevData ?? {}),
|
|
||||||
...prePopulateDataProps,
|
|
||||||
cycle: cycleId.toString(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (moduleId && !prePopulateDataProps?.module_ids) {
|
|
||||||
setPreloadedData((prevData) => ({
|
|
||||||
...(prevData ?? {}),
|
|
||||||
...prePopulateDataProps,
|
|
||||||
module: moduleId.toString(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(router.asPath.includes("my-issues") || router.asPath.includes("assigned")) &&
|
|
||||||
!prePopulateDataProps?.assignee_ids
|
|
||||||
) {
|
|
||||||
setPreloadedData((prevData) => ({
|
|
||||||
...(prevData ?? {}),
|
|
||||||
...prePopulateDataProps,
|
|
||||||
assignees: prePopulateDataProps?.assignee_ids ?? [currentUser?.id ?? ""],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [prePopulateDataProps, cycleId, moduleId, router.asPath, currentUser?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPreloadedData(prePopulateDataProps ?? {});
|
|
||||||
|
|
||||||
if (cycleId && !prePopulateDataProps?.cycle_id) {
|
|
||||||
setPreloadedData((prevData) => ({
|
|
||||||
...(prevData ?? {}),
|
|
||||||
...prePopulateDataProps,
|
|
||||||
cycle: cycleId.toString(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (moduleId && !prePopulateDataProps?.module_ids) {
|
|
||||||
setPreloadedData((prevData) => ({
|
|
||||||
...(prevData ?? {}),
|
|
||||||
...prePopulateDataProps,
|
|
||||||
module: moduleId.toString(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(router.asPath.includes("my-issues") || router.asPath.includes("assigned")) &&
|
|
||||||
!prePopulateDataProps?.assignee_ids
|
|
||||||
) {
|
|
||||||
setPreloadedData((prevData) => ({
|
|
||||||
...(prevData ?? {}),
|
|
||||||
...prePopulateDataProps,
|
|
||||||
assignees: prePopulateDataProps?.assignee_ids ?? [currentUser?.id ?? ""],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [prePopulateDataProps, cycleId, moduleId, router.asPath, currentUser?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// if modal is closed, reset active project to null
|
|
||||||
// and return to avoid activeProject being set to some other project
|
|
||||||
if (!isOpen) {
|
|
||||||
setActiveProject(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if data is present, set active project to the project of the
|
|
||||||
// issue. This has more priority than the project in the url.
|
|
||||||
if (data && data.project_id) return setActiveProject(data.project_id);
|
|
||||||
|
|
||||||
if (prePopulateData && prePopulateData.project_id && !activeProject)
|
|
||||||
return setActiveProject(prePopulateData.project_id);
|
|
||||||
|
|
||||||
if (prePopulateData && prePopulateData.project_id && !activeProject)
|
|
||||||
return setActiveProject(prePopulateData.project_id);
|
|
||||||
|
|
||||||
// if data is not present, set active project to the project
|
|
||||||
// in the url. This has the least priority.
|
|
||||||
if (projects && projects.length > 0 && !activeProject)
|
|
||||||
setActiveProject(projects?.find((id) => id === projectId) ?? projects?.[0] ?? null);
|
|
||||||
}, [activeProject, data, projectId, projects, isOpen, prePopulateData]);
|
|
||||||
|
|
||||||
const createDraftIssue = async (payload: Partial<TIssue>) => {
|
|
||||||
if (!workspaceSlug || !activeProject || !currentUser) return;
|
|
||||||
|
|
||||||
await draftIssues
|
|
||||||
.createIssue(workspaceSlug as string, activeProject ?? "", payload)
|
|
||||||
.then(async () => {
|
|
||||||
await draftIssues.fetchIssues(workspaceSlug as string, activeProject ?? "", "mutation");
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Issue created successfully.",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (payload.assignee_ids?.some((assignee) => assignee === currentUser?.id))
|
|
||||||
mutate(USER_ISSUE(workspaceSlug.toString()));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Issue could not be created. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!createMore) onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateDraftIssue = async (payload: Partial<TIssue>) => {
|
|
||||||
await draftIssues
|
|
||||||
.updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload)
|
|
||||||
.then(() => {
|
|
||||||
if (isUpdatingSingleIssue) {
|
|
||||||
mutate<TIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...payload } as TIssue), false);
|
|
||||||
} else {
|
|
||||||
if (payload.parent_id) mutate(SUB_ISSUES(payload.parent_id.toString()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// if (!payload.is_draft) { // TODO: check_with_backend
|
|
||||||
// if (payload.cycle_id && payload.cycle_id !== "") addIssueToCycle(res.id, payload.cycle_id);
|
|
||||||
// if (payload.module_id && payload.module_id !== "") addIssueToModule(res.id, payload.module_id);
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (!createMore) onClose();
|
|
||||||
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Issue updated successfully.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Issue could not be updated. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addIssueToCycle = async (issueId: string, cycleId: string) => {
|
|
||||||
if (!workspaceSlug || !activeProject) return;
|
|
||||||
|
|
||||||
await issueService.addIssueToCycle(workspaceSlug as string, activeProject ?? "", cycleId, {
|
|
||||||
issues: [issueId],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addIssueToModule = async (issueId: string, moduleIds: string[]) => {
|
|
||||||
if (!workspaceSlug || !activeProject) return;
|
|
||||||
|
|
||||||
await moduleService.addModulesToIssue(workspaceSlug as string, activeProject ?? "", issueId as string, {
|
|
||||||
modules: moduleIds,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const createIssue = async (payload: Partial<TIssue>) => {
|
|
||||||
if (!workspaceSlug || !activeProject) return;
|
|
||||||
|
|
||||||
await issueService
|
|
||||||
.createIssue(workspaceSlug.toString(), activeProject, payload)
|
|
||||||
.then(async (res) => {
|
|
||||||
if (payload.cycle_id && payload.cycle_id !== "") await addIssueToCycle(res.id, payload.cycle_id);
|
|
||||||
if (payload.module_ids && payload.module_ids.length > 0) await addIssueToModule(res.id, payload.module_ids);
|
|
||||||
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Issue created successfully.",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!createMore) onClose();
|
|
||||||
|
|
||||||
if (payload.assignee_ids?.some((assignee) => assignee === currentUser?.id))
|
|
||||||
mutate(USER_ISSUE(workspaceSlug as string));
|
|
||||||
|
|
||||||
if (payload.parent_id && payload.parent_id !== "") mutate(SUB_ISSUES(payload.parent_id));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Issue could not be created. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormSubmit = async (
|
|
||||||
formData: Partial<TIssue>,
|
|
||||||
action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft"
|
|
||||||
) => {
|
|
||||||
if (!workspaceSlug || !activeProject) return;
|
|
||||||
|
|
||||||
const payload: Partial<TIssue> = {
|
|
||||||
...formData,
|
|
||||||
// description: formData.description ?? "",
|
|
||||||
description_html: formData.description_html ?? "<p></p>",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (action === "createDraft") await createDraftIssue(payload);
|
|
||||||
else if (action === "updateDraft" || action === "convertToNewIssue") await updateDraftIssue(payload);
|
|
||||||
else if (action === "createNewIssue") await createIssue(payload);
|
|
||||||
|
|
||||||
clearDraftIssueLocalStorage();
|
|
||||||
|
|
||||||
if (onSubmit) await onSubmit(payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!projects || projects.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={React.Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
|
||||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
|
||||||
<Transition.Child
|
|
||||||
as={React.Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-4xl">
|
|
||||||
<DraftIssueForm
|
|
||||||
isOpen={isOpen}
|
|
||||||
handleFormSubmit={handleFormSubmit}
|
|
||||||
prePopulatedData={prePopulateData}
|
|
||||||
data={data}
|
|
||||||
createMore={createMore}
|
|
||||||
setCreateMore={setCreateMore}
|
|
||||||
handleClose={onClose}
|
|
||||||
handleDiscard={onDiscard}
|
|
||||||
projectId={activeProject ?? ""}
|
|
||||||
setActiveProject={setActiveProject}
|
|
||||||
status={data ? true : false}
|
|
||||||
user={currentUser ?? undefined}
|
|
||||||
fieldsToShow={fieldsToShow}
|
|
||||||
/>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -14,10 +14,5 @@ export * from "./issue-detail";
|
|||||||
|
|
||||||
export * from "./peek-overview";
|
export * from "./peek-overview";
|
||||||
|
|
||||||
// draft issue
|
|
||||||
export * from "./draft-issue-form";
|
|
||||||
export * from "./draft-issue-modal";
|
|
||||||
export * from "./delete-draft-issue-modal";
|
|
||||||
|
|
||||||
// archived issue
|
// archived issue
|
||||||
export * from "./delete-archived-issue-modal";
|
export * from "./archive-issue-modal";
|
||||||
|
@ -30,6 +30,8 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
|
|||||||
} = useInboxIssues();
|
} = useInboxIssues();
|
||||||
const {
|
const {
|
||||||
issue: { getIssueById },
|
issue: { getIssueById },
|
||||||
|
fetchActivities,
|
||||||
|
fetchComments,
|
||||||
} = useIssueDetail();
|
} = useIssueDetail();
|
||||||
const { captureIssueEvent } = useEventTracker();
|
const { captureIssueEvent } = useEventTracker();
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
@ -54,7 +56,7 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
|
|||||||
showToast: boolean = true
|
showToast: boolean = true
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data);
|
await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data);
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
title: "Issue updated successfully",
|
title: "Issue updated successfully",
|
||||||
@ -64,7 +66,7 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
|
|||||||
}
|
}
|
||||||
captureIssueEvent({
|
captureIssueEvent({
|
||||||
eventName: "Inbox issue updated",
|
eventName: "Inbox issue updated",
|
||||||
payload: { ...response, state: "SUCCESS", element: "Inbox" },
|
payload: { ...data, state: "SUCCESS", element: "Inbox" },
|
||||||
updates: {
|
updates: {
|
||||||
changed_property: Object.keys(data).join(","),
|
changed_property: Object.keys(data).join(","),
|
||||||
change_details: Object.values(data).join(","),
|
change_details: Object.values(data).join(","),
|
||||||
@ -125,6 +127,8 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
|
|||||||
async () => {
|
async () => {
|
||||||
if (workspaceSlug && projectId && inboxId && issueId) {
|
if (workspaceSlug && projectId && inboxId && issueId) {
|
||||||
await issueOperations.fetch(workspaceSlug, projectId, issueId);
|
await issueOperations.fetch(workspaceSlug, projectId, issueId);
|
||||||
|
await fetchActivities(workspaceSlug, projectId, issueId);
|
||||||
|
await fetchComments(workspaceSlug, projectId, issueId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -5,7 +5,7 @@ import { CalendarCheck2, Signal, Tag } from "lucide-react";
|
|||||||
import { useIssueDetail, useProject, useProjectState } from "hooks/store";
|
import { useIssueDetail, useProject, useProjectState } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { IssueLabel, TIssueOperations } from "components/issues";
|
import { IssueLabel, TIssueOperations } from "components/issues";
|
||||||
import { DateDropdown, PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns";
|
import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns";
|
||||||
// icons
|
// icons
|
||||||
import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
|
import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
|
||||||
// helper
|
// helper
|
||||||
@ -80,7 +80,7 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
|
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>Assignees</span>
|
<span>Assignees</span>
|
||||||
</div>
|
</div>
|
||||||
<ProjectMemberDropdown
|
<MemberDropdown
|
||||||
value={issue?.assignee_ids ?? undefined}
|
value={issue?.assignee_ids ?? undefined}
|
||||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
||||||
disabled={!is_editable}
|
disabled={!is_editable}
|
||||||
@ -154,6 +154,10 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
disabled={!is_editable}
|
disabled={!is_editable}
|
||||||
|
isInboxIssue
|
||||||
|
onLabelUpdate={(val: string[]) =>
|
||||||
|
issueOperations.update(workspaceSlug, projectId, issueId, { label_ids: val })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { MessageSquare } from "lucide-react";
|
import { RotateCcw } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail } from "hooks/store";
|
import { useIssueDetail } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { IssueActivityBlockComponent } from "./";
|
import { IssueActivityBlockComponent } from "./";
|
||||||
|
// ui
|
||||||
|
import { ArchiveIcon } from "@plane/ui";
|
||||||
|
|
||||||
type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||||
|
|
||||||
@ -18,13 +20,21 @@ export const IssueArchivedAtActivity: FC<TIssueArchivedAtActivity> = observer((p
|
|||||||
const activity = getActivityById(activityId);
|
const activity = getActivityById(activityId);
|
||||||
|
|
||||||
if (!activity) return <></>;
|
if (!activity) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IssueActivityBlockComponent
|
<IssueActivityBlockComponent
|
||||||
icon={<MessageSquare size={14} color="#6b7280" aria-hidden="true" />}
|
icon={
|
||||||
|
activity.new_value === "restore" ? (
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" color="#6b7280" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ArchiveIcon className="h-3.5 w-3.5" color="#6b7280" aria-hidden="true" />
|
||||||
|
)
|
||||||
|
}
|
||||||
activityId={activityId}
|
activityId={activityId}
|
||||||
ends={ends}
|
ends={ends}
|
||||||
|
customUserName={activity.new_value === "archive" ? "Plane" : undefined}
|
||||||
>
|
>
|
||||||
{activity.new_value === "restore" ? `restored the issue` : `archived the issue`}.
|
{activity.new_value === "restore" ? "restored the issue" : "archived the issue"}.
|
||||||
</IssueActivityBlockComponent>
|
</IssueActivityBlockComponent>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -14,10 +14,11 @@ type TIssueActivityBlockComponent = {
|
|||||||
activityId: string;
|
activityId: string;
|
||||||
ends: "top" | "bottom" | undefined;
|
ends: "top" | "bottom" | undefined;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
customUserName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (props) => {
|
export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (props) => {
|
||||||
const { icon, activityId, ends, children } = props;
|
const { icon, activityId, ends, children, customUserName } = props;
|
||||||
// hooks
|
// hooks
|
||||||
const {
|
const {
|
||||||
activity: { getActivityById },
|
activity: { getActivityById },
|
||||||
@ -37,7 +38,7 @@ export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (pr
|
|||||||
{icon ? icon : <Network className="w-3.5 h-3.5" />}
|
{icon ? icon : <Network className="w-3.5 h-3.5" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full text-custom-text-200">
|
<div className="w-full text-custom-text-200">
|
||||||
<IssueUser activityId={activityId} />
|
<IssueUser activityId={activityId} customUserName={customUserName} />
|
||||||
<span> {children} </span>
|
<span> {children} </span>
|
||||||
<span>
|
<span>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail } from "hooks/store";
|
import { useIssueDetail } from "hooks/store";
|
||||||
// ui
|
|
||||||
|
|
||||||
type TIssueUser = {
|
type TIssueUser = {
|
||||||
activityId: string;
|
activityId: string;
|
||||||
|
customUserName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IssueUser: FC<TIssueUser> = (props) => {
|
export const IssueUser: FC<TIssueUser> = (props) => {
|
||||||
const { activityId } = props;
|
const { activityId, customUserName } = props;
|
||||||
// hooks
|
// hooks
|
||||||
const {
|
const {
|
||||||
activity: { getActivityById },
|
activity: { getActivityById },
|
||||||
@ -18,12 +18,19 @@ export const IssueUser: FC<TIssueUser> = (props) => {
|
|||||||
const activity = getActivityById(activityId);
|
const activity = getActivityById(activityId);
|
||||||
|
|
||||||
if (!activity) return <></>;
|
if (!activity) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<>
|
||||||
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
|
{customUserName ? (
|
||||||
className="hover:underline text-custom-text-100 font-medium capitalize"
|
<span className="text-custom-text-100 font-medium">{customUserName}</span>
|
||||||
>
|
) : (
|
||||||
{activity.actor_detail?.display_name}
|
<Link
|
||||||
</a>
|
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
|
||||||
|
className="hover:underline text-custom-text-100 font-medium"
|
||||||
|
>
|
||||||
|
{activity.actor_detail?.display_name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -14,6 +14,8 @@ import { FileService } from "services/file.service";
|
|||||||
// types
|
// types
|
||||||
import { TIssueComment } from "@plane/types";
|
import { TIssueComment } from "@plane/types";
|
||||||
import { TActivityOperations } from "../root";
|
import { TActivityOperations } from "../root";
|
||||||
|
// helpers
|
||||||
|
import { isEmptyHtmlString } from "helpers/string.helper";
|
||||||
|
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
|
|
||||||
@ -67,6 +69,12 @@ export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
|
|||||||
isEditing && setFocus("comment_html");
|
isEditing && setFocus("comment_html");
|
||||||
}, [isEditing, setFocus]);
|
}, [isEditing, setFocus]);
|
||||||
|
|
||||||
|
const isEmpty =
|
||||||
|
watch("comment_html") === "" ||
|
||||||
|
watch("comment_html")?.trim() === "" ||
|
||||||
|
watch("comment_html") === "<p></p>" ||
|
||||||
|
isEmptyHtmlString(watch("comment_html") ?? "");
|
||||||
|
|
||||||
if (!comment || !currentUser) return <></>;
|
if (!comment || !currentUser) return <></>;
|
||||||
return (
|
return (
|
||||||
<IssueCommentBlock
|
<IssueCommentBlock
|
||||||
@ -115,9 +123,14 @@ export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
|
|||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<form className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}>
|
<form className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}>
|
||||||
<div>
|
<div
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !isEmpty) {
|
||||||
|
handleSubmit(onEnter)(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LiteTextEditorWithRef
|
<LiteTextEditorWithRef
|
||||||
onEnterKeyPress={handleSubmit(onEnter)}
|
|
||||||
cancelUploadImage={fileService.cancelUpload}
|
cancelUploadImage={fileService.cancelUpload}
|
||||||
uploadFile={fileService.getUploadFileFunction(comment?.workspace_detail?.slug as string)}
|
uploadFile={fileService.getUploadFileFunction(comment?.workspace_detail?.slug as string)}
|
||||||
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
|
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
|
||||||
@ -135,10 +148,14 @@ export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit(onEnter)}
|
onClick={handleSubmit(onEnter)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || isEmpty}
|
||||||
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
|
className={`group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 ${
|
||||||
|
isEmpty ? "bg-gray-200 cursor-not-allowed" : "hover:bg-green-500"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Check className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
|
<Check
|
||||||
|
className={`h-3 w-3 text-green-500 duration-300 ${isEmpty ? "text-black" : "group-hover:text-white"}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -11,6 +11,8 @@ import { TIssueComment } from "@plane/types";
|
|||||||
// icons
|
// icons
|
||||||
import { Globe2, Lock } from "lucide-react";
|
import { Globe2, Lock } from "lucide-react";
|
||||||
import { useMention, useWorkspace } from "hooks/store";
|
import { useMention, useWorkspace } from "hooks/store";
|
||||||
|
// helpers
|
||||||
|
import { isEmptyHtmlString } from "helpers/string.helper";
|
||||||
|
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
|
|
||||||
@ -51,10 +53,10 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
|
|||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
|
watch,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
reset,
|
reset,
|
||||||
watch,
|
} = useForm<Partial<TIssueComment>>({ defaultValues: { comment_html: "<p></p>" } });
|
||||||
} = useForm<Partial<TIssueComment>>({ defaultValues: { comment_html: "" } });
|
|
||||||
|
|
||||||
const onSubmit = async (formData: Partial<TIssueComment>) => {
|
const onSubmit = async (formData: Partial<TIssueComment>) => {
|
||||||
await activityOperations.createComment(formData).finally(() => {
|
await activityOperations.createComment(formData).finally(() => {
|
||||||
@ -63,14 +65,19 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEmpty =
|
||||||
|
watch("comment_html") === "" ||
|
||||||
|
watch("comment_html")?.trim() === "" ||
|
||||||
|
watch("comment_html") === "<p></p>" ||
|
||||||
|
isEmptyHtmlString(watch("comment_html") ?? "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
// onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey && !isEmpty) {
|
||||||
// e.preventDefault();
|
handleSubmit(onSubmit)(e);
|
||||||
// // handleSubmit(onSubmit)(e);
|
}
|
||||||
// }
|
}}
|
||||||
// }}
|
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
name="access"
|
name="access"
|
||||||
@ -81,15 +88,12 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
|
|||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<LiteTextEditorWithRef
|
<LiteTextEditorWithRef
|
||||||
onEnterKeyPress={(e) => {
|
|
||||||
handleSubmit(onSubmit)(e);
|
|
||||||
}}
|
|
||||||
cancelUploadImage={fileService.cancelUpload}
|
cancelUploadImage={fileService.cancelUpload}
|
||||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
|
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
|
||||||
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
|
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={value ?? ""}
|
value={!value ? "<p></p>" : value}
|
||||||
customClassName="p-2"
|
customClassName="p-2"
|
||||||
editorContentCustomClassNames="min-h-[35px]"
|
editorContentCustomClassNames="min-h-[35px]"
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
@ -105,7 +109,7 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
|
|||||||
}
|
}
|
||||||
submitButton={
|
submitButton={
|
||||||
<Button
|
<Button
|
||||||
disabled={isSubmitting || watch("comment_html") === ""}
|
disabled={isSubmitting || isEmpty}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
className="!px-2.5 !py-1.5 !text-xs"
|
className="!px-2.5 !py-1.5 !text-xs"
|
||||||
|
@ -20,7 +20,7 @@ type TActivityTabs = "all" | "activity" | "comments";
|
|||||||
const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [
|
const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [
|
||||||
{
|
{
|
||||||
key: "all",
|
key: "all",
|
||||||
title: "All Activity",
|
title: "All activity",
|
||||||
icon: History,
|
icon: History,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -13,6 +13,8 @@ export type TIssueLabel = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
isInboxIssue?: boolean;
|
||||||
|
onLabelUpdate?: (labelIds: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TLabelOperations = {
|
export type TLabelOperations = {
|
||||||
@ -21,7 +23,7 @@ export type TLabelOperations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, issueId, disabled = false } = props;
|
const { workspaceSlug, projectId, issueId, disabled = false, isInboxIssue = false, onLabelUpdate } = props;
|
||||||
// hooks
|
// hooks
|
||||||
const { updateIssue } = useIssueDetail();
|
const { updateIssue } = useIssueDetail();
|
||||||
const { createLabel } = useLabel();
|
const { createLabel } = useLabel();
|
||||||
@ -31,12 +33,14 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
|||||||
() => ({
|
() => ({
|
||||||
updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||||
try {
|
try {
|
||||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
if (onLabelUpdate) onLabelUpdate(data.label_ids || []);
|
||||||
setToastAlert({
|
else await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||||
title: "Issue updated successfully",
|
if (!isInboxIssue)
|
||||||
type: "success",
|
setToastAlert({
|
||||||
message: "Issue updated successfully",
|
title: "Issue updated successfully",
|
||||||
});
|
type: "success",
|
||||||
|
message: "Issue updated successfully",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
title: "Issue update failed",
|
title: "Issue update failed",
|
||||||
@ -48,11 +52,12 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
|||||||
createLabel: async (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => {
|
createLabel: async (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => {
|
||||||
try {
|
try {
|
||||||
const labelResponse = await createLabel(workspaceSlug, projectId, data);
|
const labelResponse = await createLabel(workspaceSlug, projectId, data);
|
||||||
setToastAlert({
|
if (!isInboxIssue)
|
||||||
title: "Label created successfully",
|
setToastAlert({
|
||||||
type: "success",
|
title: "Label created successfully",
|
||||||
message: "Label created successfully",
|
type: "success",
|
||||||
});
|
message: "Label created successfully",
|
||||||
|
});
|
||||||
return labelResponse;
|
return labelResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
@ -64,7 +69,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[updateIssue, createLabel, setToastAlert]
|
[updateIssue, createLabel, setToastAlert, onLabelUpdate]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -80,6 +80,13 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (query !== "" && e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!issue) return <></>;
|
if (!issue) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -118,6 +125,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
|
onKeyDown={searchInputKeyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -16,7 +16,7 @@ import { TIssue } from "@plane/types";
|
|||||||
// constants
|
// constants
|
||||||
import { EUserProjectRoles } from "constants/project";
|
import { EUserProjectRoles } from "constants/project";
|
||||||
import { EIssuesStoreType } from "constants/issue";
|
import { EIssuesStoreType } from "constants/issue";
|
||||||
import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker";
|
import { ISSUE_UPDATED, ISSUE_DELETED, ISSUE_ARCHIVED } from "constants/event-tracker";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
export type TIssueOperations = {
|
export type TIssueOperations = {
|
||||||
@ -29,6 +29,8 @@ export type TIssueOperations = {
|
|||||||
showToast?: boolean
|
showToast?: boolean
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||||
|
archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||||
|
restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||||
addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
|
addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
|
||||||
removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
|
removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
|
||||||
addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>;
|
addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>;
|
||||||
@ -63,6 +65,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
|||||||
fetchIssue,
|
fetchIssue,
|
||||||
updateIssue,
|
updateIssue,
|
||||||
removeIssue,
|
removeIssue,
|
||||||
|
archiveIssue,
|
||||||
addIssueToCycle,
|
addIssueToCycle,
|
||||||
removeIssueFromCycle,
|
removeIssueFromCycle,
|
||||||
addModulesToIssue,
|
addModulesToIssue,
|
||||||
@ -158,6 +161,32 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||||
|
try {
|
||||||
|
await archiveIssue(workspaceSlug, projectId, issueId);
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "Issue archived successfully.",
|
||||||
|
});
|
||||||
|
captureIssueEvent({
|
||||||
|
eventName: ISSUE_ARCHIVED,
|
||||||
|
payload: { id: issueId, state: "SUCCESS", element: "Issue details page" },
|
||||||
|
path: router.asPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Issue could not be archived. Please try again.",
|
||||||
|
});
|
||||||
|
captureIssueEvent({
|
||||||
|
eventName: ISSUE_ARCHIVED,
|
||||||
|
payload: { id: issueId, state: "FAILED", element: "Issue details page" },
|
||||||
|
path: router.asPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
|
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
|
||||||
try {
|
try {
|
||||||
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
|
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
|
||||||
@ -321,6 +350,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
|||||||
fetchIssue,
|
fetchIssue,
|
||||||
updateIssue,
|
updateIssue,
|
||||||
removeIssue,
|
removeIssue,
|
||||||
|
archiveIssue,
|
||||||
removeArchivedIssue,
|
removeArchivedIssue,
|
||||||
addIssueToCycle,
|
addIssueToCycle,
|
||||||
removeIssueFromCycle,
|
removeIssueFromCycle,
|
||||||
@ -350,7 +380,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full h-full overflow-hidden">
|
<div className="flex w-full h-full overflow-hidden">
|
||||||
<div className="h-full w-full max-w-2/3 space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5">
|
<div className="h-full w-full max-w-2/3 space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto p-5">
|
||||||
<IssueMainContent
|
<IssueMainContent
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@ -360,7 +390,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="h-full w-full sm:w-1/2 md:w-1/3 space-y-5 overflow-hidden border-l border-custom-border-300 py-5 fixed md:relative bg-custom-sidebar-background-100 right-0 z-[5]"
|
className="h-full w-full min-w-[300px] lg:min-w-80 xl:min-w-96 sm:w-1/2 md:w-1/3 space-y-5 overflow-hidden border-l border-custom-border-200 py-5 fixed md:relative bg-custom-sidebar-background-100 right-0 z-[5]"
|
||||||
style={themeStore.issueDetailSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
|
style={themeStore.issueDetailSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
|
||||||
>
|
>
|
||||||
<IssueDetailsSidebar
|
<IssueDetailsSidebar
|
||||||
|
@ -25,17 +25,12 @@ import {
|
|||||||
IssueModuleSelect,
|
IssueModuleSelect,
|
||||||
IssueParentSelect,
|
IssueParentSelect,
|
||||||
IssueLabel,
|
IssueLabel,
|
||||||
|
ArchiveIssueModal,
|
||||||
} from "components/issues";
|
} from "components/issues";
|
||||||
import { IssueSubscription } from "./subscription";
|
import { IssueSubscription } from "./subscription";
|
||||||
import {
|
import { DateDropdown, EstimateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns";
|
||||||
DateDropdown,
|
|
||||||
EstimateDropdown,
|
|
||||||
PriorityDropdown,
|
|
||||||
ProjectMemberDropdown,
|
|
||||||
StateDropdown,
|
|
||||||
} from "components/dropdowns";
|
|
||||||
// icons
|
// icons
|
||||||
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, UserGroupIcon } from "@plane/ui";
|
import { ArchiveIcon, ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, Tooltip, UserGroupIcon } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||||
import { copyTextToClipboard } from "helpers/string.helper";
|
import { copyTextToClipboard } from "helpers/string.helper";
|
||||||
@ -43,6 +38,7 @@ import { cn } from "helpers/common.helper";
|
|||||||
import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
|
import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
|
||||||
// types
|
// types
|
||||||
import type { TIssueOperations } from "./root";
|
import type { TIssueOperations } from "./root";
|
||||||
|
import { STATE_GROUPS } from "constants/state";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
@ -55,6 +51,9 @@ type Props = {
|
|||||||
|
|
||||||
export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props;
|
const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props;
|
||||||
|
// states
|
||||||
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
|
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// store hooks
|
// store hooks
|
||||||
@ -66,8 +65,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
issue: { getIssueById },
|
issue: { getIssueById },
|
||||||
} = useIssueDetail();
|
} = useIssueDetail();
|
||||||
const { getStateById } = useProjectState();
|
const { getStateById } = useProjectState();
|
||||||
// states
|
|
||||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
|
||||||
|
|
||||||
const issue = getIssueById(issueId);
|
const issue = getIssueById(issueId);
|
||||||
if (!issue) return <></>;
|
if (!issue) return <></>;
|
||||||
@ -83,8 +80,23 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectDetails = issue ? getProjectById(issue.project_id) : null;
|
const handleDeleteIssue = async () => {
|
||||||
|
await issueOperations.remove(workspaceSlug, projectId, issueId);
|
||||||
|
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchiveIssue = async () => {
|
||||||
|
if (!issueOperations.archive) return;
|
||||||
|
await issueOperations.archive(workspaceSlug, projectId, issueId);
|
||||||
|
router.push(`/${workspaceSlug}/projects/${projectId}/archived-issues/${issue.id}`);
|
||||||
|
};
|
||||||
|
// derived values
|
||||||
|
const projectDetails = getProjectById(issue.project_id);
|
||||||
const stateDetails = getStateById(issue.state_id);
|
const stateDetails = getStateById(issue.state_id);
|
||||||
|
// auth
|
||||||
|
const isArchivingAllowed = !is_archived && issueOperations.archive && is_editable;
|
||||||
|
const isInArchivableGroup =
|
||||||
|
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
|
||||||
|
|
||||||
const minDate = issue.start_date ? new Date(issue.start_date) : null;
|
const minDate = issue.start_date ? new Date(issue.start_date) : null;
|
||||||
minDate?.setDate(minDate.getDate());
|
minDate?.setDate(minDate.getDate());
|
||||||
@ -94,46 +106,72 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{workspaceSlug && projectId && issue && (
|
<DeleteIssueModal
|
||||||
<DeleteIssueModal
|
handleClose={() => setDeleteIssueModal(false)}
|
||||||
handleClose={() => setDeleteIssueModal(false)}
|
isOpen={deleteIssueModal}
|
||||||
isOpen={deleteIssueModal}
|
data={issue}
|
||||||
data={issue}
|
onSubmit={handleDeleteIssue}
|
||||||
onSubmit={async () => {
|
/>
|
||||||
await issueOperations.remove(workspaceSlug, projectId, issueId);
|
<ArchiveIssueModal
|
||||||
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
|
isOpen={archiveIssueModal}
|
||||||
}}
|
handleClose={() => setArchiveIssueModal(false)}
|
||||||
/>
|
data={issue}
|
||||||
)}
|
onSubmit={handleArchiveIssue}
|
||||||
|
/>
|
||||||
<div className="flex h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
|
<div className="flex h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
|
||||||
<div className="flex items-center justify-end px-5 pb-3">
|
<div className="flex items-center justify-end px-5 pb-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
{currentUser && !is_archived && (
|
{currentUser && !is_archived && (
|
||||||
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center flex-wrap gap-2.5 text-custom-text-300">
|
||||||
<button
|
<Tooltip tooltipContent="Copy link">
|
||||||
type="button"
|
<button
|
||||||
className="rounded-md border border-custom-border-200 p-2 shadow-sm duration-300 hover:bg-custom-background-90 focus:border-custom-primary focus:outline-none focus:ring-1 focus:ring-custom-primary"
|
type="button"
|
||||||
onClick={handleCopyText}
|
className="h-5 w-5 grid place-items-center hover:text-custom-text-200 rounded focus:outline-none focus:ring-2 focus:ring-custom-primary"
|
||||||
>
|
onClick={handleCopyText}
|
||||||
<LinkIcon className="h-3.5 w-3.5" />
|
>
|
||||||
</button>
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
{is_editable && (
|
</Tooltip>
|
||||||
<button
|
{isArchivingAllowed && (
|
||||||
type="button"
|
<Tooltip
|
||||||
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-500/20 focus:outline-none"
|
tooltipContent={isInArchivableGroup ? "Archive" : "Only completed or canceled issues can be archived"}
|
||||||
onClick={() => setDeleteIssueModal(true)}
|
>
|
||||||
>
|
<button
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
type="button"
|
||||||
</button>
|
className={cn(
|
||||||
)}
|
"h-5 w-5 grid place-items-center rounded focus:outline-none focus:ring-2 focus:ring-custom-primary",
|
||||||
|
{
|
||||||
|
"hover:text-custom-text-200": isInArchivableGroup,
|
||||||
|
"cursor-not-allowed text-custom-text-400": !isInArchivableGroup,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isInArchivableGroup) return;
|
||||||
|
setArchiveIssueModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArchiveIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{is_editable && (
|
||||||
|
<Tooltip tooltipContent="Delete">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-5 w-5 grid place-items-center hover:text-custom-text-200 rounded focus:outline-none focus:ring-2 focus:ring-custom-primary"
|
||||||
|
onClick={() => setDeleteIssueModal(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-full w-full overflow-y-auto px-5">
|
<div className="h-full w-full overflow-y-auto px-6">
|
||||||
<h5 className="text-sm font-medium mt-6">Properties</h5>
|
<h5 className="text-sm font-medium mt-6">Properties</h5>
|
||||||
{/* TODO: render properties using a common component */}
|
{/* TODO: render properties using a common component */}
|
||||||
<div className={`mt-3 mb-2 space-y-2.5 ${!is_editable ? "opacity-60" : ""}`}>
|
<div className={`mt-3 mb-2 space-y-2.5 ${!is_editable ? "opacity-60" : ""}`}>
|
||||||
@ -161,7 +199,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
|
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>Assignees</span>
|
<span>Assignees</span>
|
||||||
</div>
|
</div>
|
||||||
<ProjectMemberDropdown
|
<MemberDropdown
|
||||||
value={issue?.assignee_ids ?? undefined}
|
value={issue?.assignee_ids ?? undefined}
|
||||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
||||||
disabled={!is_editable}
|
disabled={!is_editable}
|
||||||
|
@ -67,7 +67,7 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span>
|
<span>
|
||||||
<span className="hidden sm:block">Loading</span>...
|
<span className="hidden sm:block">Loading...</span>
|
||||||
</span>
|
</span>
|
||||||
) : isSubscribed ? (
|
) : isSubscribed ? (
|
||||||
<div className="hidden sm:block">Unsubscribe</div>
|
<div className="hidden sm:block">Unsubscribe</div>
|
||||||
|
@ -26,6 +26,8 @@ interface IBaseCalendarRoot {
|
|||||||
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
|
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
|
||||||
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
|
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
|
||||||
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
|
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
|
||||||
|
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
|
||||||
|
[EIssueActions.RESTORE]?: (issue: TIssue) => Promise<void>;
|
||||||
};
|
};
|
||||||
viewId?: string;
|
viewId?: string;
|
||||||
isCompletedCycle?: boolean;
|
isCompletedCycle?: boolean;
|
||||||
@ -114,6 +116,16 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
|||||||
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.REMOVE)
|
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.REMOVE)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
handleArchive={
|
||||||
|
issueActions[EIssueActions.ARCHIVE]
|
||||||
|
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.ARCHIVE)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
handleRestore={
|
||||||
|
issueActions[EIssueActions.RESTORE]
|
||||||
|
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.RESTORE)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
readOnly={!isEditingAllowed || isCompletedCycle}
|
readOnly={!isEditingAllowed || isCompletedCycle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -26,7 +26,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
|||||||
const {
|
const {
|
||||||
router: { workspaceSlug, projectId },
|
router: { workspaceSlug, projectId },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
const { getProjectById } = useProject();
|
const { getProjectIdentifierById } = useProject();
|
||||||
const { getProjectStates } = useProjectState();
|
const { getProjectStates } = useProjectState();
|
||||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||||
// states
|
// states
|
||||||
@ -108,7 +108,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex-shrink-0 text-xs text-custom-text-300">
|
<div className="flex-shrink-0 text-xs text-custom-text-300">
|
||||||
{getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id}
|
{getProjectIdentifierById(issue?.project_id)}-{issue.sequence_id}
|
||||||
</div>
|
</div>
|
||||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||||
<div className="truncate text-xs">{issue.name}</div>
|
<div className="truncate text-xs">{issue.name}</div>
|
||||||
|
@ -33,6 +33,10 @@ export const CycleCalendarLayout: React.FC = observer(() => {
|
|||||||
if (!workspaceSlug || !cycleId || !projectId) return;
|
if (!workspaceSlug || !cycleId || !projectId) return;
|
||||||
await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id);
|
await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id);
|
||||||
},
|
},
|
||||||
|
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
|
||||||
|
if (!workspaceSlug || !cycleId) return;
|
||||||
|
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString());
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[issues, workspaceSlug, cycleId, projectId]
|
[issues, workspaceSlug, cycleId, projectId]
|
||||||
);
|
);
|
||||||
|
@ -34,6 +34,10 @@ export const ModuleCalendarLayout: React.FC = observer(() => {
|
|||||||
if (!workspaceSlug || !moduleId) return;
|
if (!workspaceSlug || !moduleId) return;
|
||||||
await issues.removeIssueFromModule(workspaceSlug, issue.project_id, moduleId, issue.id);
|
await issues.removeIssueFromModule(workspaceSlug, issue.project_id, moduleId, issue.id);
|
||||||
},
|
},
|
||||||
|
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
|
||||||
|
if (!workspaceSlug || !moduleId) return;
|
||||||
|
await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, moduleId);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[issues, workspaceSlug, moduleId]
|
[issues, workspaceSlug, moduleId]
|
||||||
);
|
);
|
||||||
|
@ -28,6 +28,11 @@ export const CalendarLayout: React.FC = observer(() => {
|
|||||||
|
|
||||||
await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id);
|
await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id);
|
||||||
},
|
},
|
||||||
|
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[issues, workspaceSlug]
|
[issues, workspaceSlug]
|
||||||
);
|
);
|
||||||
|
@ -16,6 +16,7 @@ export interface IViewCalendarLayout {
|
|||||||
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
|
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
|
||||||
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
|
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
|
||||||
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
|
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
|
||||||
|
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user