mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of https://github.com/makeplane/plane into chore/event-improvements
This commit is contained in:
commit
b7aeb0f70d
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
|
||||
title: "[bug]: "
|
||||
labels: [🐛bug]
|
||||
assignees: [srinivaspendem, pushya-plane]
|
||||
assignees: [srinivaspendem, pushya22]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@ -45,7 +45,7 @@ body:
|
||||
- Deploy preview
|
||||
validations:
|
||||
required: true
|
||||
type: dropdown
|
||||
- type: dropdown
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
|
@ -2,7 +2,7 @@ name: Feature request
|
||||
description: Suggest a feature to improve Plane
|
||||
title: "[feature]: "
|
||||
labels: [✨feature]
|
||||
assignees: [srinivaspendem, pushya-plane]
|
||||
assignees: [srinivaspendem, pushya22]
|
||||
body:
|
||||
- type: markdown
|
||||
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_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
||||
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:
|
||||
- id: set_env_variables
|
||||
@ -41,7 +45,36 @@ jobs:
|
||||
fi
|
||||
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:
|
||||
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
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@ -55,9 +88,9 @@ jobs:
|
||||
- name: Set Frontend Docker Tag
|
||||
run: |
|
||||
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
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest
|
||||
else
|
||||
TAG=${{ env.FRONTEND_TAG }}
|
||||
fi
|
||||
@ -77,7 +110,7 @@ jobs:
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push Frontend to Docker Container Registry
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
@ -93,6 +126,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
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
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@ -106,9 +140,9 @@ jobs:
|
||||
- name: Set Space Docker Tag
|
||||
run: |
|
||||
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
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest
|
||||
else
|
||||
TAG=${{ env.SPACE_TAG }}
|
||||
fi
|
||||
@ -128,7 +162,7 @@ jobs:
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push Space to Docker Hub
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
@ -144,6 +178,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
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
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@ -157,9 +192,9 @@ jobs:
|
||||
- name: Set Backend Docker Tag
|
||||
run: |
|
||||
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
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest
|
||||
else
|
||||
TAG=${{ env.BACKEND_TAG }}
|
||||
fi
|
||||
@ -179,7 +214,7 @@ jobs:
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push Backend to Docker Hub
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
@ -194,8 +229,8 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
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
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@ -209,9 +244,9 @@ jobs:
|
||||
- name: Set Proxy Docker Tag
|
||||
run: |
|
||||
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
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest
|
||||
else
|
||||
TAG=${{ env.PROXY_TAG }}
|
||||
fi
|
||||
@ -231,7 +266,7 @@ jobs:
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push Plane-Proxy to Docker Hub
|
||||
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:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
push:
|
||||
branches:
|
||||
- preview
|
||||
|
||||
@ -17,7 +17,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4.1.1
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@ -31,14 +31,25 @@ jobs:
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Push Changes to Target Repo
|
||||
- name: Push Changes to Target Repo A
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}"
|
||||
TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}"
|
||||
TARGET_REPO="${{ secrets.TARGET_REPO_A }}"
|
||||
TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH
|
||||
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
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",
|
||||
"version": "0.15.1"
|
||||
"version": "0.16.0"
|
||||
}
|
||||
|
@ -45,7 +45,10 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
return (
|
||||
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.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("workspace")
|
||||
.select_related("owned_by")
|
||||
@ -390,7 +393,10 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.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"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
|
@ -352,7 +352,10 @@ class LabelAPIEndpoint(BaseAPIView):
|
||||
return (
|
||||
Label.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.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("workspace")
|
||||
.select_related("parent")
|
||||
@ -481,7 +484,10 @@ class IssueLinkAPIEndpoint(BaseAPIView):
|
||||
IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||
.distinct()
|
||||
)
|
||||
@ -607,11 +613,11 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("issue")
|
||||
.select_related("actor")
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.select_related("workspace", "project", "issue", "actor")
|
||||
.annotate(
|
||||
is_member=Exists(
|
||||
ProjectMember.objects.filter(
|
||||
@ -784,6 +790,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
|
||||
.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.select_related("actor", "workspace", "issue", "project")
|
||||
).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(project_id=self.kwargs.get("project_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("workspace")
|
||||
.select_related("module")
|
||||
|
@ -24,7 +24,10 @@ class StateAPIEndpoint(BaseAPIView):
|
||||
return (
|
||||
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.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"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
|
@ -601,15 +601,15 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
# ids
|
||||
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
module_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(), required=False, allow_null=True
|
||||
child=serializers.UUIDField(), required=False,
|
||||
)
|
||||
|
||||
# Many to many
|
||||
label_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(), required=False, allow_null=True
|
||||
child=serializers.UUIDField(), required=False,
|
||||
)
|
||||
assignee_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(), required=False, allow_null=True
|
||||
child=serializers.UUIDField(), required=False,
|
||||
)
|
||||
|
||||
# Count items
|
||||
|
@ -259,23 +259,15 @@ urlpatterns = [
|
||||
name="project-issue-archive",
|
||||
),
|
||||
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(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
"post": "archive",
|
||||
"delete": "unarchive",
|
||||
}
|
||||
),
|
||||
name="project-issue-archive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/unarchive/<uuid:pk>/",
|
||||
IssueArchiveViewSet.as_view(
|
||||
{
|
||||
"post": "unarchive",
|
||||
}
|
||||
),
|
||||
name="project-issue-archive",
|
||||
name="project-issue-archive-unarchive",
|
||||
),
|
||||
## End Issue Archives
|
||||
## Issue Relation
|
||||
|
@ -66,15 +66,15 @@ class ConfigurationEndpoint(BaseAPIView):
|
||||
},
|
||||
{
|
||||
"key": "SLACK_CLIENT_ID",
|
||||
"default": os.environ.get("SLACK_CLIENT_ID", "1"),
|
||||
"default": os.environ.get("SLACK_CLIENT_ID", None),
|
||||
},
|
||||
{
|
||||
"key": "POSTHOG_API_KEY",
|
||||
"default": os.environ.get("POSTHOG_API_KEY", "1"),
|
||||
"default": os.environ.get("POSTHOG_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "POSTHOG_HOST",
|
||||
"default": os.environ.get("POSTHOG_HOST", "1"),
|
||||
"default": os.environ.get("POSTHOG_HOST", None),
|
||||
},
|
||||
{
|
||||
"key": "UNSPLASH_ACCESS_KEY",
|
||||
@ -181,11 +181,11 @@ class MobileConfigurationEndpoint(BaseAPIView):
|
||||
},
|
||||
{
|
||||
"key": "POSTHOG_API_KEY",
|
||||
"default": os.environ.get("POSTHOG_API_KEY", "1"),
|
||||
"default": os.environ.get("POSTHOG_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "POSTHOG_HOST",
|
||||
"default": os.environ.get("POSTHOG_HOST", "1"),
|
||||
"default": os.environ.get("POSTHOG_HOST", None),
|
||||
},
|
||||
{
|
||||
"key": "UNSPLASH_ACCESS_KEY",
|
||||
|
@ -85,7 +85,10 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.select_related("project", "workspace", "owned_by")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
@ -689,7 +692,10 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.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"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
|
@ -58,6 +58,7 @@ def dashboard_overview_stats(self, request, slug):
|
||||
|
||||
pending_issues_count = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
target_date__lt=timezone.now().date(),
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
|
@ -33,6 +33,7 @@ from plane.app.serializers import (
|
||||
IssueSerializer,
|
||||
InboxSerializer,
|
||||
InboxIssueSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
@ -296,11 +297,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
issue_data = request.data.pop("issue", False)
|
||||
|
||||
if bool(issue_data):
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first()
|
||||
# Only allow guests and viewers to edit name and description
|
||||
if project_member.role <= 10:
|
||||
# viewers and guests since only viewers and guests
|
||||
@ -430,11 +427,10 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if issue is None:
|
||||
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)
|
||||
|
||||
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
||||
|
@ -36,7 +36,10 @@ class SlackProjectSyncViewSet(BaseViewSet):
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
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):
|
||||
|
@ -572,12 +572,18 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
|
||||
if not issue:
|
||||
return Response(
|
||||
{"error": "Issue not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
current_instance = json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
serializer = IssueCreateSerializer(
|
||||
issue, data=request.data, partial=True
|
||||
@ -767,7 +773,10 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
issues = (
|
||||
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")
|
||||
)
|
||||
serializer = IssueSerializer(issues, many=True)
|
||||
@ -790,6 +799,7 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.filter(**filters)
|
||||
@ -799,6 +809,7 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
IssueComment.objects.filter(issue_id=issue_id)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.filter(**filters)
|
||||
@ -850,7 +861,10 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("issue")
|
||||
@ -1012,7 +1026,10 @@ class LabelViewSet(BaseViewSet):
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("parent")
|
||||
@ -1225,7 +1242,10 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
@ -1627,6 +1647,36 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
serializer = IssueDetailSerializer(issue, expand=self.expand)
|
||||
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):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug,
|
||||
@ -1650,7 +1700,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
issue.archived_at = None
|
||||
issue.save()
|
||||
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class IssueSubscriberViewSet(BaseViewSet):
|
||||
@ -1686,7 +1736,10 @@ class IssueSubscriberViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
@ -1770,7 +1823,10 @@ class IssueReactionViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
@ -1839,7 +1895,10 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_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")
|
||||
.distinct()
|
||||
)
|
||||
@ -1909,7 +1968,10 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("issue")
|
||||
@ -2296,20 +2358,18 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
serializer = IssueSerializer(issue, data=request.data, partial=True)
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
|
||||
if not issue:
|
||||
return Response(
|
||||
{"error": "Issue does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
serializer = IssueCreateSerializer(issue, data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
if request.data.get(
|
||||
"is_draft"
|
||||
) is not None and not request.data.get("is_draft"):
|
||||
serializer.save(
|
||||
created_at=timezone.now(), updated_at=timezone.now()
|
||||
)
|
||||
else:
|
||||
serializer.save()
|
||||
serializer.save()
|
||||
issue_activity.delay(
|
||||
type="issue_draft.activity.updated",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
|
@ -673,7 +673,10 @@ class ModuleLinkViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_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")
|
||||
.distinct()
|
||||
)
|
||||
|
@ -60,7 +60,10 @@ class PageViewSet(BaseViewSet):
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(parent__isnull=True)
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.select_related("project")
|
||||
|
@ -77,6 +77,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
]
|
||||
|
||||
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(
|
||||
super()
|
||||
.get_queryset()
|
||||
@ -147,6 +153,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(sort_order=Subquery(sort_order))
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"project_projectmember",
|
||||
@ -166,16 +173,8 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
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 = (
|
||||
self.get_queryset()
|
||||
.annotate(sort_order=Subquery(sort_order_query))
|
||||
.order_by("sort_order", "name")
|
||||
)
|
||||
if request.GET.get("per_page", False) and request.GET.get(
|
||||
@ -204,7 +203,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
serializer.save()
|
||||
|
||||
# Add the user as Administrator to the project
|
||||
project_member = ProjectMember.objects.create(
|
||||
_ = ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
member=request.user,
|
||||
role=20,
|
||||
|
@ -48,8 +48,8 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
return (
|
||||
Project.objects.filter(
|
||||
q,
|
||||
Q(project_projectmember__member=self.request.user)
|
||||
| Q(network=2),
|
||||
project_projectmember__member=self.request.user,
|
||||
project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.distinct()
|
||||
@ -71,6 +71,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
issues = Issue.issue_objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
@ -95,6 +96,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
cycles = Cycle.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
@ -118,6 +120,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
modules = Module.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
@ -141,6 +144,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
pages = Page.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
@ -164,6 +168,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
issue_views = IssueView.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
@ -236,6 +241,7 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
|
||||
if workspace_search == "false":
|
||||
|
@ -31,7 +31,10 @@ class StateViewSet(BaseViewSet):
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(~Q(name="Triage"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
|
@ -86,6 +86,10 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.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")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
@ -163,7 +167,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
)
|
||||
|
||||
@ -284,7 +287,10 @@ class IssueViewViewSet(BaseViewSet):
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
|
@ -1086,6 +1086,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(state_group=F("state__group"))
|
||||
@ -1101,6 +1102,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True
|
||||
)
|
||||
.filter(**filters)
|
||||
.values("priority")
|
||||
@ -1123,6 +1125,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
.filter(**filters)
|
||||
@ -1134,6 +1137,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.count()
|
||||
@ -1145,6 +1149,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.count()
|
||||
@ -1156,6 +1161,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
assignees__in=[user_id],
|
||||
state__group="completed",
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True
|
||||
)
|
||||
.filter(**filters)
|
||||
.count()
|
||||
@ -1166,6 +1172,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
workspace__slug=slug,
|
||||
subscriber_id=user_id,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True
|
||||
)
|
||||
.filter(**filters)
|
||||
.count()
|
||||
@ -1215,6 +1222,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
actor=user_id,
|
||||
).select_related("actor", "workspace", "issue", "project")
|
||||
|
||||
@ -1355,6 +1363,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
| Q(issue_subscribers__subscriber_id=user_id),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
@ -1486,6 +1495,7 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
|
||||
labels = Label.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True
|
||||
)
|
||||
serializer = LabelSerializer(labels, many=True).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
@ -1500,6 +1510,7 @@ class WorkspaceStatesEndpoint(BaseAPIView):
|
||||
states = State.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True
|
||||
)
|
||||
serializer = StateSerializer(states, many=True).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
@ -10,6 +10,7 @@ from django.utils import timezone
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EmailNotificationLog, User, Issue
|
||||
@ -301,5 +302,7 @@ def send_email_notification(
|
||||
print("Duplicate task recived. Skipping...")
|
||||
return
|
||||
except (Issue.DoesNotExist, User.DoesNotExist) as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
|
@ -292,6 +292,7 @@ def issue_export_task(
|
||||
workspace__id=workspace_id,
|
||||
project_id__in=project_ids,
|
||||
project__project_projectmember__member=exporter_instance.initiated_by_id,
|
||||
project__project_projectmember__is_active=True
|
||||
)
|
||||
.select_related(
|
||||
"project", "workspace", "state", "parent", "created_by"
|
||||
|
@ -60,15 +60,6 @@ def service_importer(service, importer_id):
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
_ = [
|
||||
send_welcome_slack.delay(
|
||||
str(user.id),
|
||||
True,
|
||||
f"{user.email} was imported to Plane from {service}",
|
||||
)
|
||||
for user in new_users
|
||||
]
|
||||
|
||||
workspace_users = User.objects.filter(
|
||||
email__in=[
|
||||
user.get("email").strip().lower()
|
||||
|
@ -483,17 +483,23 @@ def track_archive_at(
|
||||
)
|
||||
)
|
||||
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(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment="Plane has archived the issue",
|
||||
comment=comment,
|
||||
verb="updated",
|
||||
actor_id=actor_id,
|
||||
field="archived_at",
|
||||
old_value=None,
|
||||
new_value="archive",
|
||||
new_value=new_value,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
@ -79,7 +79,7 @@ def archive_old_issues():
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps(
|
||||
{"archived_at": str(archive_at)}
|
||||
{"archived_at": str(archive_at), "automation": True}
|
||||
),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
|
@ -12,15 +12,9 @@ from django.contrib.auth.models import (
|
||||
PermissionsMixin,
|
||||
)
|
||||
from django.db.models.signals import post_save
|
||||
from django.conf import settings
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from sentry_sdk import capture_exception
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
|
||||
def get_default_onboarding():
|
||||
return {
|
||||
@ -144,25 +138,6 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
super(User, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def send_welcome_slack(sender, instance, created, **kwargs):
|
||||
try:
|
||||
if created and not instance.is_bot:
|
||||
# Send message on slack as well
|
||||
if settings.SLACK_BOT_TOKEN:
|
||||
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
||||
try:
|
||||
_ = client.chat_postMessage(
|
||||
channel="#trackers",
|
||||
text=f"New user {instance.email} has signed up and begun the onboarding journey.",
|
||||
)
|
||||
except SlackApiError as e:
|
||||
print(f"Got an error: {e.response['error']}")
|
||||
return
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_notification(sender, instance, created, **kwargs):
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""Global Settings"""
|
||||
|
||||
# Python imports
|
||||
import os
|
||||
import ssl
|
||||
@ -307,7 +308,9 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
|
||||
traces_sample_rate=1,
|
||||
send_default_pii=True,
|
||||
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
|
||||
profiles_sample_rate=1.0,
|
||||
profiles_sample_rate=float(
|
||||
os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0.5)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@ -9,11 +9,11 @@ from plane.db.models import Issue
|
||||
|
||||
|
||||
def search_issues(query, queryset):
|
||||
fields = ["name", "sequence_id"]
|
||||
fields = ["name", "sequence_id", "project__identifier"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
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:
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
|
@ -30,7 +30,7 @@ openpyxl==3.1.2
|
||||
beautifulsoup4==4.12.2
|
||||
dj-database-url==2.1.0
|
||||
posthog==3.0.2
|
||||
cryptography==42.0.0
|
||||
cryptography==42.0.4
|
||||
lxml==4.9.3
|
||||
boto3==1.28.40
|
||||
|
||||
|
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
|
||||
|
||||
export GIT_REPO=makeplane/plane
|
||||
|
||||
# Check if the user has sudo access
|
||||
if command -v curl &> /dev/null; then
|
||||
sudo curl -sSL \
|
||||
-o /usr/local/bin/plane-app \
|
||||
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
||||
https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
||||
else
|
||||
sudo wget -q \
|
||||
-O /usr/local/bin/plane-app \
|
||||
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
||||
https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
||||
fi
|
||||
|
||||
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
|
||||
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
|
||||
if ! command -v $tool &> /dev/null; then
|
||||
@ -150,11 +150,11 @@ function download_plane() {
|
||||
show_message "Downloading Plane Setup Files ✋" >&2
|
||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||
-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' \
|
||||
-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 [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
|
||||
@ -202,7 +202,7 @@ function printUsageInstructions() {
|
||||
}
|
||||
function build_local_image() {
|
||||
show_message "- Downloading Plane Source Code ✋" >&2
|
||||
REPO=https://github.com/makeplane/plane.git
|
||||
REPO=https://github.com/$CODE_REPO.git
|
||||
CURR_DIR=$PWD
|
||||
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
|
||||
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
|
||||
@ -290,40 +290,40 @@ function configure_plane() {
|
||||
fi
|
||||
|
||||
|
||||
smtp_host=$(read_env "EMAIL_HOST")
|
||||
smtp_user=$(read_env "EMAIL_HOST_USER")
|
||||
smtp_password=$(read_env "EMAIL_HOST_PASSWORD")
|
||||
smtp_port=$(read_env "EMAIL_PORT")
|
||||
smtp_from=$(read_env "EMAIL_FROM")
|
||||
smtp_tls=$(read_env "EMAIL_USE_TLS")
|
||||
smtp_ssl=$(read_env "EMAIL_USE_SSL")
|
||||
# smtp_host=$(read_env "EMAIL_HOST")
|
||||
# smtp_user=$(read_env "EMAIL_HOST_USER")
|
||||
# smtp_password=$(read_env "EMAIL_HOST_PASSWORD")
|
||||
# smtp_port=$(read_env "EMAIL_PORT")
|
||||
# smtp_from=$(read_env "EMAIL_FROM")
|
||||
# smtp_tls=$(read_env "EMAIL_USE_TLS")
|
||||
# smtp_ssl=$(read_env "EMAIL_USE_SSL")
|
||||
|
||||
SMTP_SETTINGS=$(dialog \
|
||||
--ok-label "Next" \
|
||||
--cancel-label "Skip" \
|
||||
--backtitle "Plane Configuration" \
|
||||
--title "SMTP Settings" \
|
||||
--form "" \
|
||||
0 0 0 \
|
||||
"Host:" 1 1 "$smtp_host" 1 10 80 0 \
|
||||
"User:" 2 1 "$smtp_user" 2 10 80 0 \
|
||||
"Password:" 3 1 "$smtp_password" 3 10 80 0 \
|
||||
"Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \
|
||||
"From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \
|
||||
"TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \
|
||||
"SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \
|
||||
2>&1 1>&3)
|
||||
# SMTP_SETTINGS=$(dialog \
|
||||
# --ok-label "Next" \
|
||||
# --cancel-label "Skip" \
|
||||
# --backtitle "Plane Configuration" \
|
||||
# --title "SMTP Settings" \
|
||||
# --form "" \
|
||||
# 0 0 0 \
|
||||
# "Host:" 1 1 "$smtp_host" 1 10 80 0 \
|
||||
# "User:" 2 1 "$smtp_user" 2 10 80 0 \
|
||||
# "Password:" 3 1 "$smtp_password" 3 10 80 0 \
|
||||
# "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \
|
||||
# "From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \
|
||||
# "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \
|
||||
# "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \
|
||||
# 2>&1 1>&3)
|
||||
|
||||
save_smtp_settings=0
|
||||
if [ $? -eq 0 ]; then
|
||||
save_smtp_settings=1
|
||||
smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p)
|
||||
smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p)
|
||||
smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p)
|
||||
smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p)
|
||||
smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p)
|
||||
smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p)
|
||||
fi
|
||||
# save_smtp_settings=0
|
||||
# if [ $? -eq 0 ]; then
|
||||
# save_smtp_settings=1
|
||||
# smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p)
|
||||
# smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p)
|
||||
# smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p)
|
||||
# smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p)
|
||||
# smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p)
|
||||
# smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p)
|
||||
# fi
|
||||
external_pgdb_url=$(dialog \
|
||||
--backtitle "Plane Configuration" \
|
||||
--title "Using External Postgres Database ?" \
|
||||
@ -383,15 +383,6 @@ function configure_plane() {
|
||||
domain_name: $domain_name
|
||||
upload_limit: $upload_limit
|
||||
|
||||
save_smtp_settings: $save_smtp_settings
|
||||
smtp_host: $smtp_host
|
||||
smtp_user: $smtp_user
|
||||
smtp_password: $smtp_password
|
||||
smtp_port: $smtp_port
|
||||
smtp_from: $smtp_from
|
||||
smtp_tls: $smtp_tls
|
||||
smtp_ssl: $smtp_ssl
|
||||
|
||||
save_aws_settings: $save_aws_settings
|
||||
aws_region: $aws_region
|
||||
aws_access_key: $aws_access_key
|
||||
@ -413,15 +404,15 @@ function configure_plane() {
|
||||
fi
|
||||
|
||||
# check enable smpt settings value
|
||||
if [ $save_smtp_settings == 1 ]; then
|
||||
update_env "EMAIL_HOST" "$smtp_host"
|
||||
update_env "EMAIL_HOST_USER" "$smtp_user"
|
||||
update_env "EMAIL_HOST_PASSWORD" "$smtp_password"
|
||||
update_env "EMAIL_PORT" "$smtp_port"
|
||||
update_env "EMAIL_FROM" "$smtp_from"
|
||||
update_env "EMAIL_USE_TLS" "$smtp_tls"
|
||||
update_env "EMAIL_USE_SSL" "$smtp_ssl"
|
||||
fi
|
||||
# if [ $save_smtp_settings == 1 ]; then
|
||||
# update_env "EMAIL_HOST" "$smtp_host"
|
||||
# update_env "EMAIL_HOST_USER" "$smtp_user"
|
||||
# update_env "EMAIL_HOST_PASSWORD" "$smtp_password"
|
||||
# update_env "EMAIL_PORT" "$smtp_port"
|
||||
# update_env "EMAIL_FROM" "$smtp_from"
|
||||
# update_env "EMAIL_USE_TLS" "$smtp_tls"
|
||||
# update_env "EMAIL_USE_SSL" "$smtp_ssl"
|
||||
# fi
|
||||
|
||||
# check enable aws settings value
|
||||
if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then
|
||||
@ -493,13 +484,24 @@ function install() {
|
||||
check_for_docker_images
|
||||
|
||||
last_installed_on=$(read_config "INSTALLATION_DATE")
|
||||
if [ "$last_installed_on" == "" ]; then
|
||||
configure_plane
|
||||
fi
|
||||
printUsageInstructions
|
||||
|
||||
update_config "INSTALLATION_DATE" "$(date)"
|
||||
# if [ "$last_installed_on" == "" ]; then
|
||||
# configure_plane
|
||||
# fi
|
||||
|
||||
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 ""
|
||||
else
|
||||
@ -539,12 +541,15 @@ function upgrade() {
|
||||
prepare_environment
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
stop_server
|
||||
download_plane
|
||||
if [ $? -eq 0 ]; then
|
||||
check_for_docker_images
|
||||
upgrade_configuration
|
||||
update_config "UPGRADE_DATE" "$(date)"
|
||||
|
||||
|
||||
start_server
|
||||
|
||||
show_message ""
|
||||
show_message "Plane Upgraded Successfully ✅"
|
||||
show_message ""
|
||||
@ -601,6 +606,11 @@ function uninstall() {
|
||||
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
||||
|
||||
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
|
||||
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
|
||||
sleep 1
|
||||
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 "---------------------------------------------------------------" >&2
|
||||
show_message "Access the Plane application at http://$MY_IP" >&2
|
||||
show_message "---------------------------------------------------------------" >&2
|
||||
|
||||
else
|
||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||
fi
|
||||
@ -694,7 +736,7 @@ function update_installer() {
|
||||
show_message "Updating Plane Installer ✋" >&2
|
||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||
-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
|
||||
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
|
||||
@ -711,12 +753,14 @@ fi
|
||||
|
||||
PLANE_INSTALL_DIR=/opt/plane
|
||||
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
|
||||
CPU_ARCH=$(uname -m)
|
||||
PROGRESS_MSG=""
|
||||
USE_GLOBAL_IMAGES=0
|
||||
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
|
||||
USE_GLOBAL_IMAGES=1
|
||||
@ -740,6 +784,9 @@ elif [ "$1" == "restart" ]; then
|
||||
restart_server
|
||||
elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then
|
||||
install
|
||||
start_server
|
||||
show_message "" >&2
|
||||
show_message "To view help, use plane-app --help " >&2
|
||||
elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then
|
||||
configure_plane
|
||||
printUsageInstructions
|
||||
|
@ -56,8 +56,6 @@ x-app-env : &app-env
|
||||
- BUCKET_NAME=${BUCKET_NAME:-uploads}
|
||||
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
||||
|
||||
|
||||
|
||||
services:
|
||||
web:
|
||||
<<: *app-env
|
||||
@ -138,7 +136,6 @@ services:
|
||||
command: postgres -c 'max_connections=1000'
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
plane-redis:
|
||||
<<: *app-env
|
||||
image: redis:6.2.7-alpine
|
||||
|
@ -13,6 +13,23 @@ YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
function print_header() {
|
||||
clear
|
||||
|
||||
cat <<"EOF"
|
||||
---------------------------------------
|
||||
____ _
|
||||
| _ \| | __ _ _ __ ___
|
||||
| |_) | |/ _` | '_ \ / _ \
|
||||
| __/| | (_| | | | | __/
|
||||
|_| |_|\__,_|_| |_|\___|
|
||||
|
||||
---------------------------------------
|
||||
Project management tool from the future
|
||||
---------------------------------------
|
||||
EOF
|
||||
}
|
||||
|
||||
function buildLocalImage() {
|
||||
if [ "$1" == "--force-build" ]; then
|
||||
DO_BUILD="1"
|
||||
@ -110,7 +127,7 @@ function download() {
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml pull
|
||||
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@ -121,19 +138,48 @@ function download() {
|
||||
|
||||
}
|
||||
function startServices() {
|
||||
cd $PLANE_INSTALL_DIR
|
||||
docker compose up -d --quiet-pull
|
||||
cd $SCRIPT_DIR
|
||||
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull
|
||||
|
||||
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() {
|
||||
cd $PLANE_INSTALL_DIR
|
||||
docker compose down
|
||||
cd $SCRIPT_DIR
|
||||
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH down
|
||||
}
|
||||
function restartServices() {
|
||||
cd $PLANE_INSTALL_DIR
|
||||
docker compose restart
|
||||
cd $SCRIPT_DIR
|
||||
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH restart
|
||||
}
|
||||
function upgrade() {
|
||||
echo "***** STOPPING SERVICES ****"
|
||||
@ -144,47 +190,137 @@ function upgrade() {
|
||||
download
|
||||
|
||||
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() {
|
||||
echo
|
||||
echo "Select a Action you want to perform:"
|
||||
echo " 1) Install (${CPU_ARCH})"
|
||||
echo " 2) Start"
|
||||
echo " 3) Stop"
|
||||
echo " 4) Restart"
|
||||
echo " 5) Upgrade"
|
||||
echo " 6) Exit"
|
||||
echo
|
||||
read -p "Action [2]: " ACTION
|
||||
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do
|
||||
echo "$ACTION: invalid selection."
|
||||
local DEFAULT_ACTION=$1
|
||||
|
||||
if [ -z "$DEFAULT_ACTION" ];
|
||||
then
|
||||
echo
|
||||
echo "Select a Action you want to perform:"
|
||||
echo " 1) Install (${CPU_ARCH})"
|
||||
echo " 2) Start"
|
||||
echo " 3) Stop"
|
||||
echo " 4) Restart"
|
||||
echo " 5) Upgrade"
|
||||
echo " 6) View Logs"
|
||||
echo " 7) Exit"
|
||||
echo
|
||||
read -p "Action [2]: " ACTION
|
||||
done
|
||||
echo
|
||||
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-7]$ ]]; do
|
||||
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
|
||||
install
|
||||
askForAction
|
||||
elif [ "$ACTION" == "2" ] || [ "$ACTION" == "" ]
|
||||
elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "start" ]
|
||||
then
|
||||
startServices
|
||||
askForAction
|
||||
elif [ "$ACTION" == "3" ]
|
||||
elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "stop" ]
|
||||
then
|
||||
stopServices
|
||||
askForAction
|
||||
elif [ "$ACTION" == "4" ]
|
||||
elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "restart" ]
|
||||
then
|
||||
restartServices
|
||||
askForAction
|
||||
elif [ "$ACTION" == "5" ]
|
||||
elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ]
|
||||
then
|
||||
upgrade
|
||||
askForAction
|
||||
elif [ "$ACTION" == "6" ]
|
||||
elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ]
|
||||
then
|
||||
viewLogs $@
|
||||
askForAction
|
||||
elif [ "$ACTION" == "7" ]
|
||||
then
|
||||
exit 0
|
||||
else
|
||||
@ -217,4 +353,8 @@ then
|
||||
fi
|
||||
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",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor-core",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
@ -97,8 +97,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run();
|
||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run();
|
||||
};
|
||||
|
||||
export const unsetLinkEditor = (editor: Editor) => {
|
||||
|
@ -170,68 +170,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
}
|
||||
}
|
||||
|
||||
#editor-container {
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin: 0.5em 0 0.5em 0;
|
||||
|
||||
border: 1px solid rgb(var(--color-border-200));
|
||||
width: 100%;
|
||||
|
||||
td,
|
||||
th {
|
||||
min-width: 1em;
|
||||
border: 1px solid rgb(var(--color-border-200));
|
||||
padding: 10px 15px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
> * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
background-color: rgb(var(--color-primary-100));
|
||||
}
|
||||
|
||||
td:hover {
|
||||
background-color: rgba(var(--color-primary-300), 0.1);
|
||||
}
|
||||
|
||||
.selectedCell:after {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
content: "";
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(var(--color-primary-300), 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: -2px;
|
||||
width: 2px;
|
||||
background-color: rgb(var(--color-primary-400));
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
|
@ -9,15 +9,15 @@
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin: 0;
|
||||
margin-bottom: 3rem;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
margin-bottom: 1rem;
|
||||
border: 2px solid rgba(var(--color-border-300));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tableWrapper table td,
|
||||
.tableWrapper table th {
|
||||
min-width: 1em;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
border: 1px solid rgba(var(--color-border-300));
|
||||
padding: 10px 15px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
@ -43,7 +43,8 @@
|
||||
.tableWrapper table th {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
background-color: rgba(var(--color-primary-100));
|
||||
background-color: #d9e4ff;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.tableWrapper table th * {
|
||||
@ -62,6 +63,35 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.colorPicker {
|
||||
display: grid;
|
||||
padding: 8px 8px;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.colorPickerLabel {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
padding: 8px 8px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.colorPickerItem {
|
||||
margin: 2px 0px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.divider {
|
||||
background-color: #e5e7eb;
|
||||
height: 1px;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.tableWrapper table .column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
@ -69,7 +99,7 @@
|
||||
bottom: -2px;
|
||||
width: 4px;
|
||||
z-index: 99;
|
||||
background-color: rgba(var(--color-primary-400));
|
||||
background-color: #d9e4ff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@ -112,7 +142,7 @@
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .rowsControlDiv {
|
||||
background-color: rgba(var(--color-primary-100));
|
||||
background-color: #d9e4ff;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
border-radius: 2px;
|
||||
background-size: 1.25rem;
|
||||
@ -127,7 +157,7 @@
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .columnsControlDiv {
|
||||
background-color: rgba(var(--color-primary-100));
|
||||
background-color: #d9e4ff;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
border-radius: 2px;
|
||||
background-size: 1.25rem;
|
||||
@ -144,10 +174,12 @@
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox {
|
||||
border: 1px solid rgba(var(--color-border-300));
|
||||
background-color: rgba(var(--color-background-100));
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
width: max-content;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
@ -158,7 +190,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: none;
|
||||
padding: 0.1rem;
|
||||
padding: 0.3rem 0.5rem 0.1rem 0.1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
@ -173,9 +205,7 @@
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
|
||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
|
||||
border: 1px solid rgba(var(--color-border-300));
|
||||
border-radius: 3px;
|
||||
padding: 4px;
|
||||
padding: 4px 0px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -187,8 +217,8 @@
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
|
||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.tableToolbox {
|
||||
|
@ -13,7 +13,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
};
|
||||
},
|
||||
|
||||
content: "paragraph+",
|
||||
content: "block+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
@ -33,7 +33,10 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "none",
|
||||
default: null,
|
||||
},
|
||||
textColor: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
@ -50,7 +53,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
return [
|
||||
"td",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `background-color: ${node.attrs.background}`,
|
||||
style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
|
@ -33,7 +33,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "rgb(var(--color-primary-100))",
|
||||
default: "none",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -13,6 +13,17 @@ export const TableRow = Node.create<TableRowOptions>({
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
background: {
|
||||
default: null,
|
||||
},
|
||||
textColor: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
content: "(tableCell | tableHeader)*",
|
||||
|
||||
tableRole: "row",
|
||||
@ -22,6 +33,12 @@ export const TableRow = Node.create<TableRowOptions>({
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["tr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
const style = HTMLAttributes.background
|
||||
? `background-color: ${HTMLAttributes.background}; color: ${HTMLAttributes.textColor}`
|
||||
: "";
|
||||
|
||||
const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style });
|
||||
|
||||
return ["tr", attributes, 0];
|
||||
},
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
export const icons = {
|
||||
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
|
||||
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
|
||||
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`,
|
||||
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||
insertLeftTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={24}
|
||||
@ -35,6 +35,8 @@ export const icons = {
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
toggleColumnHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
toggleRowHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
insertBottomTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={24}
|
||||
|
@ -81,53 +81,75 @@ const defaultTippyOptions: Partial<Props> = {
|
||||
placement: "right",
|
||||
};
|
||||
|
||||
function setCellsBackgroundColor(editor: Editor, backgroundColor: string) {
|
||||
function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
background: backgroundColor,
|
||||
})
|
||||
.updateAttributes("tableHeader", {
|
||||
background: backgroundColor,
|
||||
background: color.backgroundColor,
|
||||
textColor: color.textColor,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
|
||||
const { state, dispatch } = editor.view;
|
||||
const { selection } = state;
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the position of the hovered cell in the selection to determine the row.
|
||||
const hoveredCell = selection.$headCell || selection.$anchorCell;
|
||||
|
||||
// Find the depth of the table row node
|
||||
let rowDepth = hoveredCell.depth;
|
||||
while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||
rowDepth--;
|
||||
}
|
||||
|
||||
// If we couldn't find a tableRow node, we can't set the background color
|
||||
if (hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the position where the table row starts
|
||||
const rowStartPos = hoveredCell.start(rowDepth);
|
||||
|
||||
// Create a transaction that sets the background color on the tableRow node.
|
||||
const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, {
|
||||
...hoveredCell.node(rowDepth).attrs,
|
||||
background: color.backgroundColor,
|
||||
textColor: color.textColor,
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
const columnsToolboxItems: ToolboxItem[] = [
|
||||
{
|
||||
label: "Add Column Before",
|
||||
label: "Toggle column header",
|
||||
icon: icons.toggleColumnHeader,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(),
|
||||
},
|
||||
{
|
||||
label: "Add column before",
|
||||
icon: icons.insertLeftTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(),
|
||||
},
|
||||
{
|
||||
label: "Add Column After",
|
||||
label: "Add column after",
|
||||
icon: icons.insertRightTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(),
|
||||
},
|
||||
{
|
||||
label: "Pick Column Color",
|
||||
icon: icons.colorPicker,
|
||||
action: ({
|
||||
editor,
|
||||
triggerButton,
|
||||
controlsContainer,
|
||||
}: {
|
||||
editor: Editor;
|
||||
triggerButton: HTMLElement;
|
||||
controlsContainer: Element;
|
||||
}) => {
|
||||
createColorPickerToolbox({
|
||||
triggerButton,
|
||||
tippyOptions: {
|
||||
appendTo: controlsContainer,
|
||||
},
|
||||
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
|
||||
});
|
||||
},
|
||||
label: "Pick color",
|
||||
icon: "", // No icon needed for color picker
|
||||
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||
},
|
||||
{
|
||||
label: "Delete Column",
|
||||
label: "Delete column",
|
||||
icon: icons.deleteColumn,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(),
|
||||
},
|
||||
@ -135,35 +157,24 @@ const columnsToolboxItems: ToolboxItem[] = [
|
||||
|
||||
const rowsToolboxItems: ToolboxItem[] = [
|
||||
{
|
||||
label: "Add Row Above",
|
||||
label: "Toggle row header",
|
||||
icon: icons.toggleRowHeader,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(),
|
||||
},
|
||||
{
|
||||
label: "Add row above",
|
||||
icon: icons.insertTopTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(),
|
||||
},
|
||||
{
|
||||
label: "Add Row Below",
|
||||
label: "Add row below",
|
||||
icon: icons.insertBottomTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(),
|
||||
},
|
||||
{
|
||||
label: "Pick Row Color",
|
||||
icon: icons.colorPicker,
|
||||
action: ({
|
||||
editor,
|
||||
triggerButton,
|
||||
controlsContainer,
|
||||
}: {
|
||||
editor: Editor;
|
||||
triggerButton: HTMLButtonElement;
|
||||
controlsContainer: Element | "parent" | ((ref: Element) => Element) | undefined;
|
||||
}) => {
|
||||
createColorPickerToolbox({
|
||||
triggerButton,
|
||||
tippyOptions: {
|
||||
appendTo: controlsContainer,
|
||||
},
|
||||
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
|
||||
});
|
||||
},
|
||||
label: "Pick color",
|
||||
icon: "",
|
||||
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||
},
|
||||
{
|
||||
label: "Delete Row",
|
||||
@ -176,37 +187,57 @@ function createToolbox({
|
||||
triggerButton,
|
||||
items,
|
||||
tippyOptions,
|
||||
onSelectColor,
|
||||
onClickItem,
|
||||
colors,
|
||||
}: {
|
||||
triggerButton: Element | null;
|
||||
items: ToolboxItem[];
|
||||
tippyOptions: any;
|
||||
onClickItem: (item: ToolboxItem) => void;
|
||||
onSelectColor: (color: { backgroundColor: string; textColor: string }) => void;
|
||||
colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } };
|
||||
}): Instance<Props> {
|
||||
// @ts-expect-error
|
||||
const toolbox = tippy(triggerButton, {
|
||||
content: h(
|
||||
"div",
|
||||
{ className: "tableToolbox" },
|
||||
items.map((item) =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: "toolboxItem",
|
||||
itemType: "button",
|
||||
onClick() {
|
||||
onClickItem(item);
|
||||
items.map((item, index) => {
|
||||
if (item.label === "Pick color") {
|
||||
return h("div", { className: "flex flex-col" }, [
|
||||
h("div", { className: "divider" }),
|
||||
h("div", { className: "colorPickerLabel" }, item.label),
|
||||
h(
|
||||
"div",
|
||||
{ className: "colorPicker grid" },
|
||||
Object.entries(colors).map(([colorName, colorValue]) =>
|
||||
h("div", {
|
||||
className: "colorPickerItem",
|
||||
style: `background-color: ${colorValue.backgroundColor};
|
||||
color: ${colorValue.textColor || "inherit"};`,
|
||||
innerHTML: colorValue?.icon || "",
|
||||
onClick: () => onSelectColor(colorValue),
|
||||
})
|
||||
)
|
||||
),
|
||||
h("div", { className: "divider" }),
|
||||
]);
|
||||
} else {
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
className: "toolboxItem",
|
||||
itemType: "div",
|
||||
onClick: () => onClickItem(item),
|
||||
},
|
||||
},
|
||||
[
|
||||
h("div", {
|
||||
className: "iconContainer",
|
||||
innerHTML: item.icon,
|
||||
}),
|
||||
h("div", { className: "label" }, item.label),
|
||||
]
|
||||
)
|
||||
)
|
||||
[
|
||||
h("div", { className: "iconContainer", innerHTML: item.icon }),
|
||||
h("div", { className: "label" }, item.label),
|
||||
]
|
||||
);
|
||||
}
|
||||
})
|
||||
),
|
||||
...tippyOptions,
|
||||
});
|
||||
@ -214,71 +245,6 @@ function createToolbox({
|
||||
return Array.isArray(toolbox) ? toolbox[0] : toolbox;
|
||||
}
|
||||
|
||||
function createColorPickerToolbox({
|
||||
triggerButton,
|
||||
tippyOptions,
|
||||
onSelectColor = () => {},
|
||||
}: {
|
||||
triggerButton: HTMLElement;
|
||||
tippyOptions: Partial<Props>;
|
||||
onSelectColor?: (color: string) => void;
|
||||
}) {
|
||||
const items = {
|
||||
Default: "rgb(var(--color-primary-100))",
|
||||
Orange: "#FFE5D1",
|
||||
Grey: "#F1F1F1",
|
||||
Yellow: "#FEF3C7",
|
||||
Green: "#DCFCE7",
|
||||
Red: "#FFDDDD",
|
||||
Blue: "#D9E4FF",
|
||||
Pink: "#FFE8FA",
|
||||
Purple: "#E8DAFB",
|
||||
};
|
||||
|
||||
const colorPicker = tippy(triggerButton, {
|
||||
...defaultTippyOptions,
|
||||
content: h(
|
||||
"div",
|
||||
{ className: "tableColorPickerToolbox" },
|
||||
Object.entries(items).map(([key, value]) =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: "toolboxItem",
|
||||
itemType: "button",
|
||||
onClick: () => {
|
||||
onSelectColor(value);
|
||||
colorPicker.hide();
|
||||
},
|
||||
},
|
||||
[
|
||||
h("div", {
|
||||
className: "colorContainer",
|
||||
style: {
|
||||
backgroundColor: value,
|
||||
},
|
||||
}),
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: "label",
|
||||
},
|
||||
key
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
onHidden: (instance) => {
|
||||
instance.destroy();
|
||||
},
|
||||
showOnCreate: true,
|
||||
...tippyOptions,
|
||||
});
|
||||
|
||||
return colorPicker;
|
||||
}
|
||||
|
||||
export class TableView implements NodeView {
|
||||
node: ProseMirrorNode;
|
||||
cellMinWidth: number;
|
||||
@ -347,10 +313,27 @@ export class TableView implements NodeView {
|
||||
this.rowsControl,
|
||||
this.columnsControl
|
||||
);
|
||||
const columnColors = {
|
||||
Blue: { backgroundColor: "#D9E4FF", textColor: "#171717" },
|
||||
Orange: { backgroundColor: "#FFEDD5", textColor: "#171717" },
|
||||
Grey: { backgroundColor: "#F1F1F1", textColor: "#171717" },
|
||||
Yellow: { backgroundColor: "#FEF3C7", textColor: "#171717" },
|
||||
Green: { backgroundColor: "#DCFCE7", textColor: "#171717" },
|
||||
Red: { backgroundColor: "#FFDDDD", textColor: "#171717" },
|
||||
Pink: { backgroundColor: "#FFE8FA", textColor: "#171717" },
|
||||
Purple: { backgroundColor: "#E8DAFB", textColor: "#171717" },
|
||||
None: {
|
||||
backgroundColor: "none",
|
||||
textColor: "none",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>`,
|
||||
},
|
||||
};
|
||||
|
||||
this.columnsToolbox = createToolbox({
|
||||
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
|
||||
items: columnsToolboxItems,
|
||||
colors: columnColors,
|
||||
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
|
||||
tippyOptions: {
|
||||
...defaultTippyOptions,
|
||||
appendTo: this.controls,
|
||||
@ -368,10 +351,12 @@ export class TableView implements NodeView {
|
||||
this.rowsToolbox = createToolbox({
|
||||
triggerButton: this.rowsControl.firstElementChild,
|
||||
items: rowsToolboxItems,
|
||||
colors: columnColors,
|
||||
tippyOptions: {
|
||||
...defaultTippyOptions,
|
||||
appendTo: this.controls,
|
||||
},
|
||||
onSelectColor: (color) => setTableRowBackgroundColor(editor, color),
|
||||
onClickItem: (item) => {
|
||||
item.action({
|
||||
editor: this.editor,
|
||||
@ -383,8 +368,6 @@ export class TableView implements NodeView {
|
||||
});
|
||||
}
|
||||
|
||||
// Table
|
||||
|
||||
this.colgroup = h(
|
||||
"colgroup",
|
||||
null,
|
||||
@ -437,16 +420,19 @@ export class TableView implements NodeView {
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => {
|
||||
if (curr.spec.hoveredCell !== undefined) {
|
||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||
}
|
||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce(
|
||||
(acc, curr) => {
|
||||
if (curr.spec.hoveredCell !== undefined) {
|
||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||
}
|
||||
|
||||
if (curr.spec.hoveredTable !== undefined) {
|
||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, HTMLElement>) as any;
|
||||
if (curr.spec.hoveredTable !== undefined) {
|
||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, HTMLElement>
|
||||
) as any;
|
||||
|
||||
if (table === undefined || cell === undefined) {
|
||||
return this.root.classList.add("controls--disabled");
|
||||
@ -457,12 +443,12 @@ export class TableView implements NodeView {
|
||||
|
||||
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
|
||||
|
||||
if (!this.table) {
|
||||
if (!this.table || !cellDom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tableRect = this.table.getBoundingClientRect();
|
||||
const cellRect = cellDom.getBoundingClientRect();
|
||||
const tableRect = this.table?.getBoundingClientRect();
|
||||
const cellRect = cellDom?.getBoundingClientRect();
|
||||
|
||||
if (this.columnsControl) {
|
||||
this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`;
|
||||
|
@ -107,10 +107,9 @@ export const Table = Node.create({
|
||||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
|
||||
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
|
||||
({ tr, dispatch, editor }) => {
|
||||
const node = createTable(editor.schema, rows, cols, withHeaderRow);
|
||||
|
||||
if (dispatch) {
|
||||
const offset = tr.selection.anchor + 1;
|
||||
|
||||
|
@ -42,15 +42,6 @@ export function CoreEditorProps(
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view, event, _slice, moved) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const selection: any = window?.getSelection();
|
||||
if (selection.rangeCount !== 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/document-editor",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"description": "Package that powers Plane's Pages Editor",
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
|
@ -48,34 +48,12 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||
function getComplexItems(): BubbleMenuItem[] {
|
||||
const items: BubbleMenuItem[] = [TableItem(editor)];
|
||||
|
||||
if (shouldShowImageItem()) {
|
||||
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
|
||||
}
|
||||
|
||||
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
|
||||
return items;
|
||||
}
|
||||
|
||||
const complexItems: BubbleMenuItem[] = getComplexItems();
|
||||
|
||||
function shouldShowImageItem(): boolean {
|
||||
if (typeof window !== "undefined") {
|
||||
const selectionRange: any = window?.getSelection();
|
||||
const { selection } = props.editor.state;
|
||||
|
||||
if (selectionRange.rangeCount !== 0) {
|
||||
const range = selectionRange.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
return false;
|
||||
}
|
||||
if (isCellSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
||||
<div className="flex items-center gap-0.5 pr-2">
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor-extensions",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"description": "Package that powers Plane's Editor with extensions",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
@ -35,7 +35,7 @@ export interface DragHandleOptions {
|
||||
}
|
||||
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
const data = node?.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top,
|
||||
@ -65,7 +65,7 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
||||
}
|
||||
|
||||
function nodePosAtDOM(node: Element, view: EditorView) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
const boundingRect = node?.getBoundingClientRect();
|
||||
|
||||
if (node.nodeName === "IMG") {
|
||||
return view.posAtCoords({
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/lite-text-editor",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"description": "Package that powers Plane's Comment Editor",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
@ -60,34 +60,13 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||
function getComplexItems(): BubbleMenuItem[] {
|
||||
const items: BubbleMenuItem[] = [TableItem(props.editor)];
|
||||
|
||||
if (shouldShowImageItem()) {
|
||||
items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
|
||||
}
|
||||
items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const complexItems: BubbleMenuItem[] = getComplexItems();
|
||||
|
||||
function shouldShowImageItem(): boolean {
|
||||
if (typeof window !== "undefined") {
|
||||
const selectionRange: any = window?.getSelection();
|
||||
const { selection } = props.editor.state;
|
||||
|
||||
if (selectionRange.rangeCount !== 0) {
|
||||
const range = selectionRange.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
return false;
|
||||
}
|
||||
if (isCellSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const handleAccessChange = (accessKey: string) => {
|
||||
props.commentAccessSpecifier?.onAccessChange(accessKey);
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/rich-text-editor",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"description": "Rich Text Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eslint-config-custom",
|
||||
"private": true,
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tailwind-config-custom",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tsconfig",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/types",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"main": "./src/index.d.ts"
|
||||
}
|
||||
|
2
packages/types/src/cycles.d.ts
vendored
2
packages/types/src/cycles.d.ts
vendored
@ -30,7 +30,7 @@ export interface ICycle {
|
||||
is_favorite: boolean;
|
||||
issue: string;
|
||||
name: string;
|
||||
owned_by: string;
|
||||
owned_by_id: string;
|
||||
progress_snapshot: TProgressSnapshot;
|
||||
project_id: string;
|
||||
status: TCycleGroups;
|
||||
|
2
packages/types/src/issues.d.ts
vendored
2
packages/types/src/issues.d.ts
vendored
@ -203,6 +203,8 @@ export interface ViewFlags {
|
||||
|
||||
export type GroupByColumnTypes =
|
||||
| "project"
|
||||
| "cycle"
|
||||
| "module"
|
||||
| "state"
|
||||
| "state_detail.group"
|
||||
| "priority"
|
||||
|
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 {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
archived_at: string | null;
|
||||
created_at: string;
|
||||
created_by: null;
|
||||
data: Data;
|
||||
entity_identifier: string;
|
||||
entity_name: string;
|
||||
title: string;
|
||||
id: string;
|
||||
message: null;
|
||||
message_html: string;
|
||||
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;
|
||||
read_at: Date | null;
|
||||
receiver: string;
|
||||
sender: string;
|
||||
snoozed_till: Date | null;
|
||||
title: string;
|
||||
triggered_by: string;
|
||||
triggered_by_details: IUserLite;
|
||||
receiver: string;
|
||||
updated_at: Date;
|
||||
updated_by: null;
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
|
6
packages/types/src/view-props.d.ts
vendored
6
packages/types/src/view-props.d.ts
vendored
@ -14,6 +14,8 @@ export type TIssueGroupByOptions =
|
||||
| "project"
|
||||
| "assignees"
|
||||
| "mentions"
|
||||
| "cycle"
|
||||
| "module"
|
||||
| null;
|
||||
|
||||
export type TIssueOrderByOptions =
|
||||
@ -60,6 +62,8 @@ export type TIssueParams =
|
||||
| "created_by"
|
||||
| "subscriber"
|
||||
| "labels"
|
||||
| "cycle"
|
||||
| "module"
|
||||
| "start_date"
|
||||
| "target_date"
|
||||
| "project"
|
||||
@ -79,6 +83,8 @@ export interface IIssueFilterOptions {
|
||||
labels?: string[] | null;
|
||||
priority?: string[] | null;
|
||||
project?: string[] | null;
|
||||
cycle?: string[] | null;
|
||||
module?: string[] | null;
|
||||
start_date?: string[] | null;
|
||||
state?: string[] | null;
|
||||
state_group?: string[] | null;
|
||||
|
@ -2,7 +2,7 @@
|
||||
"name": "@plane/ui",
|
||||
"description": "UI components shared across multiple apps internally",
|
||||
"private": true,
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
@ -5,10 +5,11 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
target?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const ControlLink: React.FC<TControlLink> = (props) => {
|
||||
const { href, onClick, children, target = "_self", ...rest } = props;
|
||||
const { href, onClick, children, target = "_self", disabled = false, ...rest } = props;
|
||||
const LEFT_CLICK_EVENT_CODE = 0;
|
||||
|
||||
const _onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
@ -19,6 +20,8 @@ export const ControlLink: React.FC<TControlLink> = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (disabled) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<a href={href} target={target} onClick={_onClick} {...rest}>
|
||||
{children}
|
||||
|
@ -177,17 +177,18 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
};
|
||||
|
||||
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||
const { children, onClick, className = "" } = props;
|
||||
const { children, disabled = false, onClick, className } = props;
|
||||
|
||||
return (
|
||||
<Menu.Item as="div">
|
||||
<Menu.Item as="div" disabled={disabled}>
|
||||
{({ active, close }) => (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"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
|
||||
)}
|
||||
@ -195,6 +196,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||
close();
|
||||
onClick && onClick(e);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
@ -64,6 +64,7 @@ export type ICustomSearchSelectProps = IDropdownProps &
|
||||
|
||||
export interface ICustomMenuItemProps {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: (args?: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
@ -20,7 +20,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
|
||||
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
|
||||
const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;
|
||||
|
||||
return (
|
||||
|
@ -48,7 +48,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
|
||||
<div className="">
|
||||
<h4 className="text-sm font-medium">Auto-archive closed issues</h4>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -73,7 +73,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
|
||||
<CustomSelect
|
||||
value={currentProjectDetails?.archive_in}
|
||||
label={`${currentProjectDetails?.archive_in} ${
|
||||
currentProjectDetails?.archive_in === 1 ? "Month" : "Months"
|
||||
currentProjectDetails?.archive_in === 1 ? "month" : "months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
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"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customise Time Range
|
||||
Customize time range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
|
@ -74,7 +74,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
|
||||
<div className="">
|
||||
<h4 className="text-sm font-medium">Auto-close issues</h4>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -100,7 +100,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
|
||||
<CustomSelect
|
||||
value={currentProjectDetails?.close_in}
|
||||
label={`${currentProjectDetails?.close_in} ${
|
||||
currentProjectDetails?.close_in === 1 ? "Month" : "Months"
|
||||
currentProjectDetails?.close_in === 1 ? "month" : "months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
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"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customize Time Range
|
||||
Customize time range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
|
@ -72,7 +72,7 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
Customise Time Range
|
||||
Customize time range
|
||||
</Dialog.Title>
|
||||
<div className="mt-8 flex items-center gap-2">
|
||||
<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" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-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 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">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// when search is empty and page is undefined
|
||||
// when user tries to close the modal with esc
|
||||
if (e.key === "Escape" && !page && !searchTerm) closePalette();
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className="flex items-center justify-center p-4 sm:p-6 md:p-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 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">
|
||||
<div className="w-full max-w-2xl">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// when search is empty and page is undefined
|
||||
// when user tries to close the modal with esc
|
||||
if (e.key === "Escape" && !page && !searchTerm) closePalette();
|
||||
|
||||
// Escape goes to previous page
|
||||
// Backspace goes to previous page when search is empty
|
||||
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
|
||||
e.preventDefault();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
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"
|
||||
}`}
|
||||
// Escape goes to previous page
|
||||
// Backspace goes to previous page when search is empty
|
||||
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
|
||||
e.preventDefault();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
setPlaceholder("Type a command or search...");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{issueDetails && (
|
||||
<div className="overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
|
||||
{projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name}
|
||||
</div>
|
||||
)}
|
||||
{projectId && (
|
||||
<Tooltip tooltipContent="Toggle workspace level search">
|
||||
<div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Workspace Level
|
||||
</button>
|
||||
<ToggleSwitch
|
||||
value={isWorkspaceLevel}
|
||||
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
/>
|
||||
<div
|
||||
className={`flex gap-4 p-3 pb-0 sm:items-center ${
|
||||
issueDetails ? "flex-col justify-between sm:flex-row" : "justify-end"
|
||||
}`}
|
||||
>
|
||||
{issueDetails && (
|
||||
<div className="overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
|
||||
{projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{projectId && (
|
||||
<Tooltip tooltipContent="Toggle workspace level search">
|
||||
<div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Workspace Level
|
||||
</button>
|
||||
<ToggleSwitch
|
||||
value={isWorkspaceLevel}
|
||||
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</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">
|
||||
{searchTerm !== "" && (
|
||||
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
|
||||
Search results for{" "}
|
||||
<span className="font-medium">
|
||||
{'"'}
|
||||
{searchTerm}
|
||||
{'"'}
|
||||
</span>{" "}
|
||||
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
|
||||
</h5>
|
||||
)}
|
||||
<Command.List className="max-h-96 overflow-scroll p-2 vertical-scrollbar scrollbar-sm">
|
||||
{searchTerm !== "" && (
|
||||
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
|
||||
Search results for{" "}
|
||||
<span className="font-medium">
|
||||
{'"'}
|
||||
{searchTerm}
|
||||
{'"'}
|
||||
</span>{" "}
|
||||
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
|
||||
</h5>
|
||||
)}
|
||||
|
||||
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
|
||||
<div className="my-4 text-center text-sm text-custom-text-200">No results found.</div>
|
||||
)}
|
||||
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
|
||||
<div className="my-4 text-center text-sm text-custom-text-200">No results found.</div>
|
||||
)}
|
||||
|
||||
{(isLoading || isSearching) && (
|
||||
<Command.Loading>
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
</Command.Loading>
|
||||
)}
|
||||
{(isLoading || isSearching) && (
|
||||
<Command.Loading>
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
</Command.Loading>
|
||||
)}
|
||||
|
||||
{debouncedSearchTerm !== "" && (
|
||||
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
|
||||
)}
|
||||
{debouncedSearchTerm !== "" && (
|
||||
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
|
||||
)}
|
||||
|
||||
{!page && (
|
||||
<>
|
||||
{/* issue actions */}
|
||||
{issueId && (
|
||||
<CommandPaletteIssueActions
|
||||
closePalette={closePalette}
|
||||
issueDetails={issueDetails}
|
||||
pages={pages}
|
||||
setPages={(newPages) => setPages(newPages)}
|
||||
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
|
||||
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
|
||||
/>
|
||||
)}
|
||||
<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">
|
||||
{!page && (
|
||||
<>
|
||||
{/* issue actions */}
|
||||
{issueId && (
|
||||
<CommandPaletteIssueActions
|
||||
closePalette={closePalette}
|
||||
issueDetails={issueDetails}
|
||||
pages={pages}
|
||||
setPages={(newPages) => setPages(newPages)}
|
||||
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
|
||||
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
|
||||
/>
|
||||
)}
|
||||
<Command.Group heading="Issue">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("Command palette");
|
||||
toggleCreateProjectModal(true);
|
||||
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
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
Create new project
|
||||
<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>
|
||||
<kbd>P</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{/* project actions */}
|
||||
{projectId && <CommandPaletteProjectActions closePalette={closePalette} />}
|
||||
{/* help options */}
|
||||
<CommandPaletteHelpActions closePalette={closePalette} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Command.Group heading="Workspace Settings">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
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>
|
||||
{/* workspace settings actions */}
|
||||
{page === "settings" && workspaceSlug && (
|
||||
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
|
||||
)}
|
||||
|
||||
{/* help options */}
|
||||
<CommandPaletteHelpActions closePalette={closePalette} />
|
||||
</>
|
||||
)}
|
||||
{/* issue details page actions */}
|
||||
{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 */}
|
||||
{page === "settings" && workspaceSlug && (
|
||||
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
|
||||
)}
|
||||
|
||||
{/* issue details page actions */}
|
||||
{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} />
|
||||
)}
|
||||
|
||||
{/* theme actions */}
|
||||
{page === "change-interface-theme" && (
|
||||
<CommandPaletteThemeActions
|
||||
closePalette={() => {
|
||||
closePalette();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
{/* 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>
|
||||
</Transition.Root>
|
||||
|
@ -49,8 +49,10 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
|
||||
const [query, setQuery] = useState("");
|
||||
// fetching project issues.
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
||||
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
|
||||
workspaceSlug && projectId && isOpen ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
||||
workspaceSlug && projectId && isOpen
|
||||
? () => issueService.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
@ -5,17 +5,17 @@ import { observer } from "mobx-react";
|
||||
|
||||
type Props = {
|
||||
onClick?: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
export const SidebarHamburgerToggle: FC<Props> = observer((props) => {
|
||||
const { onClick } = props
|
||||
const { onClick } = props;
|
||||
const { theme: themeStore } = useApplication();
|
||||
return (
|
||||
<div
|
||||
className="w-7 h-7 flex-shrink-0 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
|
||||
onClick={() => {
|
||||
if (onClick) onClick()
|
||||
else themeStore.toggleMobileSidebar()
|
||||
if (onClick) onClick();
|
||||
else themeStore.toggleSidebar();
|
||||
}}
|
||||
>
|
||||
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
|
||||
|
@ -69,7 +69,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
);
|
||||
|
||||
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
|
||||
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by) : undefined;
|
||||
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
|
||||
|
||||
const { data: activeCycleIssues } = useSWR(
|
||||
workspaceSlug && projectId && currentProjectActiveCycleId
|
||||
|
@ -152,6 +152,7 @@ export const CycleMobileHeader = () => {
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
ignoreGroupedFilters={["cycle"]}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
|
@ -59,7 +59,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
|
||||
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
|
@ -67,6 +67,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
|
||||
const filterParams = getRedirectionFilters(selectedTab);
|
||||
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} />;
|
||||
|
||||
@ -84,30 +85,25 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
onChange={(val) => {
|
||||
if (val === selectedDurationFilter) return;
|
||||
|
||||
let newTab = selectedTab;
|
||||
// switch to pending tab if target date is changed to none
|
||||
if (val === "none" && selectedTab !== "completed") {
|
||||
handleUpdateFilters({ duration: val, tab: "pending" });
|
||||
return;
|
||||
}
|
||||
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
||||
// switch to upcoming tab if target date is changed to other than none
|
||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
tab: "upcoming",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming";
|
||||
|
||||
handleUpdateFilters({ duration: val });
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
tab: newTab,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Tab.Group
|
||||
as="div"
|
||||
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)}
|
||||
selectedIndex={selectedTabIndex}
|
||||
onChange={(i) => {
|
||||
const selectedTab = tabsList[i];
|
||||
handleUpdateFilters({ tab: selectedTab?.key ?? "pending" });
|
||||
const newSelectedTab = tabsList[i];
|
||||
handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" });
|
||||
}}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
@ -115,18 +111,21 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||
</div>
|
||||
<Tab.Panels as="div" className="h-full">
|
||||
{tabsList.map((tab) => (
|
||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
|
||||
<WidgetIssuesList
|
||||
issues={widgetStats.issues}
|
||||
tab={tab.key}
|
||||
totalIssues={widgetStats.count}
|
||||
type="assigned"
|
||||
workspaceSlug={workspaceSlug}
|
||||
isLoading={fetching}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
{tabsList.map((tab) => {
|
||||
if (tab.key !== selectedTab) return null;
|
||||
|
||||
return (
|
||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
||||
<WidgetIssuesList
|
||||
tab={tab.key}
|
||||
type="assigned"
|
||||
workspaceSlug={workspaceSlug}
|
||||
widgetStats={widgetStats}
|
||||
isLoading={fetching}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
|
@ -64,6 +64,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
|
||||
const filterParams = getRedirectionFilters(selectedTab);
|
||||
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} />;
|
||||
|
||||
@ -81,30 +82,25 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
onChange={(val) => {
|
||||
if (val === selectedDurationFilter) return;
|
||||
|
||||
let newTab = selectedTab;
|
||||
// switch to pending tab if target date is changed to none
|
||||
if (val === "none" && selectedTab !== "completed") {
|
||||
handleUpdateFilters({ duration: val, tab: "pending" });
|
||||
return;
|
||||
}
|
||||
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
||||
// switch to upcoming tab if target date is changed to other than none
|
||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
tab: "upcoming",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming";
|
||||
|
||||
handleUpdateFilters({ duration: val });
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
tab: newTab,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Tab.Group
|
||||
as="div"
|
||||
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)}
|
||||
selectedIndex={selectedTabIndex}
|
||||
onChange={(i) => {
|
||||
const selectedTab = tabsList[i];
|
||||
handleUpdateFilters({ tab: selectedTab.key ?? "pending" });
|
||||
const newSelectedTab = tabsList[i];
|
||||
handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" });
|
||||
}}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
@ -112,18 +108,21 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||
</div>
|
||||
<Tab.Panels as="div" className="h-full">
|
||||
{tabsList.map((tab) => (
|
||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
|
||||
<WidgetIssuesList
|
||||
issues={widgetStats.issues}
|
||||
tab={tab.key}
|
||||
totalIssues={widgetStats.count}
|
||||
type="created"
|
||||
workspaceSlug={workspaceSlug}
|
||||
isLoading={fetching}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
{tabsList.map((tab) => {
|
||||
if (tab.key !== selectedTab) return null;
|
||||
|
||||
return (
|
||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
||||
<WidgetIssuesList
|
||||
tab={tab.key}
|
||||
type="created"
|
||||
workspaceSlug={workspaceSlug}
|
||||
widgetStats={widgetStats}
|
||||
isLoading={fetching}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
|
@ -14,24 +14,23 @@ import {
|
||||
IssueListItemProps,
|
||||
} from "components/dashboard/widgets";
|
||||
// ui
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
import { Loader, getButtonStyling } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { getRedirectionFilters } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TIssue, TIssuesListTypes } from "@plane/types";
|
||||
import { TAssignedIssuesWidgetResponse, TCreatedIssuesWidgetResponse, TIssue, TIssuesListTypes } from "@plane/types";
|
||||
|
||||
export type WidgetIssuesListProps = {
|
||||
isLoading: boolean;
|
||||
issues: TIssue[];
|
||||
tab: TIssuesListTypes;
|
||||
totalIssues: number;
|
||||
type: "assigned" | "created";
|
||||
widgetStats: TAssignedIssuesWidgetResponse | TCreatedIssuesWidgetResponse;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
const { isLoading, issues, tab, totalIssues, type, workspaceSlug } = props;
|
||||
const { isLoading, tab, type, widgetStats, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
|
||||
@ -59,12 +58,19 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
},
|
||||
};
|
||||
|
||||
const issuesList = widgetStats.issues;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full">
|
||||
{isLoading ? (
|
||||
<></>
|
||||
) : issues.length > 0 ? (
|
||||
<Loader className="space-y-4 mx-6 mt-7">
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
</Loader>
|
||||
) : 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">
|
||||
<h6
|
||||
@ -75,7 +81,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
>
|
||||
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">
|
||||
{totalIssues}
|
||||
{widgetStats.count}
|
||||
</span>
|
||||
</h6>
|
||||
{["upcoming", "pending"].includes(tab) && <h6 className="text-center">Due date</h6>}
|
||||
@ -84,7 +90,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
{type === "created" && <h6 className="text-center">Assigned to</h6>}
|
||||
</div>
|
||||
<div className="px-4 pb-3 mt-2">
|
||||
{issues.map((issue) => {
|
||||
{issuesList.map((issue) => {
|
||||
const IssueListItem = ISSUE_LIST_ITEM[type][tab];
|
||||
|
||||
if (!IssueListItem) return null;
|
||||
@ -107,7 +113,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{issues.length > 0 && (
|
||||
{!isLoading && issuesList.length > 0 && (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`}
|
||||
className={cn(
|
||||
|
@ -37,7 +37,7 @@ export const OverviewStatsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
key: "overdue",
|
||||
title: "Issues overdue",
|
||||
count: widgetStats?.pending_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/assigned/?target_date=${today};before`,
|
||||
link: `/${workspaceSlug}/workspace-views/assigned/?state_group=backlog,unstarted,started&target_date=${today};before`,
|
||||
},
|
||||
{
|
||||
key: "created",
|
||||
|
@ -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">{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>
|
||||
);
|
||||
});
|
150
web/components/dropdowns/cycle/index.tsx
Normal file
150
web/components/dropdowns/cycle/index.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
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);
|
||||
if (isOpen) onClose && onClose();
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
if (!isOpen) onOpen();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
if (isOpen) onClose && onClose();
|
||||
};
|
||||
|
||||
const dropdownOnChange = (val: Date | null) => {
|
||||
@ -146,7 +147,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span>
|
||||
)}
|
||||
{isClearable && isDateSelected && (
|
||||
{isClearable && !disabled && isDateSelected && (
|
||||
<X
|
||||
className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)}
|
||||
onClick={(e) => {
|
||||
|
@ -122,6 +122,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
if (isOpen) onClose && onClose();
|
||||
};
|
||||
|
||||
const dropdownOnChange = (val: number | null) => {
|
||||
@ -137,6 +138,13 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
@ -217,6 +225,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
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">
|
||||
|
@ -1,2 +0,0 @@
|
||||
export * from "./project-member";
|
||||
export * from "./workspace-member";
|
157
web/components/dropdowns/member/index.tsx
Normal file
157
web/components/dropdowns/member/index.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
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);
|
||||
if (isOpen) onClose && onClose();
|
||||
};
|
||||
|
||||
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 { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, ChevronDown, Search, X } from "lucide-react";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useModule } from "hooks/store";
|
||||
import { useModule } from "hooks/store";
|
||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { DropdownButton } from "./buttons";
|
||||
import { DropdownButton } from "../buttons";
|
||||
// icons
|
||||
import { DiceIcon, Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TDropdownProps } from "./types";
|
||||
import { TDropdownProps } from "../types";
|
||||
// constants
|
||||
import { BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants";
|
||||
import { BUTTON_VARIANTS_WITHOUT_TEXT } from "../constants";
|
||||
import { ModuleOptions } from "./module-options";
|
||||
|
||||
type Props = TDropdownProps & {
|
||||
button?: ReactNode;
|
||||
@ -38,14 +38,6 @@ type Props = TDropdownProps & {
|
||||
}
|
||||
);
|
||||
|
||||
type DropdownOptions =
|
||||
| {
|
||||
value: string | null;
|
||||
query: string;
|
||||
content: JSX.Element;
|
||||
}[]
|
||||
| undefined;
|
||||
|
||||
type ButtonContentProps = {
|
||||
disabled: boolean;
|
||||
dropdownArrow: boolean;
|
||||
@ -166,64 +158,14 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
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 { getProjectModuleIds, fetchModules, getModuleById } = useModule();
|
||||
const moduleIds = getProjectModuleIds(projectId);
|
||||
|
||||
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()));
|
||||
|
||||
const onOpen = () => {
|
||||
if (!moduleIds && workspaceSlug) fetchModules(workspaceSlug, projectId);
|
||||
};
|
||||
const { getModuleNameById } = useModule();
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
@ -232,8 +174,8 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
if (isOpen) onClose && onClose();
|
||||
};
|
||||
|
||||
const dropdownOnChange = (val: string & string[]) => {
|
||||
@ -307,7 +249,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
tooltipContent={
|
||||
Array.isArray(value)
|
||||
? `${value
|
||||
.map((moduleId) => getModuleById(moduleId)?.name)
|
||||
.map((moduleId) => getModuleNameById(moduleId))
|
||||
.toString()
|
||||
.replaceAll(",", ", ")}`
|
||||
: ""
|
||||
@ -332,60 +274,13 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
</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 }) =>
|
||||
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>
|
||||
<ModuleOptions
|
||||
isOpen={isOpen}
|
||||
projectId={projectId}
|
||||
placement={placement}
|
||||
referenceElement={referenceElement}
|
||||
multiple={multiple}
|
||||
/>
|
||||
)}
|
||||
</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 = () => {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
if (isOpen) onClose && onClose();
|
||||
};
|
||||
|
||||
const dropdownOnChange = (val: TIssuePriorities) => {
|
||||
@ -329,6 +330,13 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant)
|
||||
@ -417,6 +425,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
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">
|
||||
|
@ -104,6 +104,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
if (isOpen) onClose && onClose();
|
||||
};
|
||||
|
||||
const dropdownOnChange = (val: string) => {
|
||||
@ -119,6 +120,13 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
@ -205,6 +213,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
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">
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user