mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of gurusainath:makeplane/plane into feat/mobx-global-views
This commit is contained in:
commit
76e55bee95
152
.github/workflows/build-branch.yml
vendored
152
.github/workflows/build-branch.yml
vendored
@ -2,11 +2,6 @@ name: Branch Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch_name:
|
||||
description: "Branch Name"
|
||||
required: true
|
||||
default: "preview"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@ -16,49 +11,71 @@ on:
|
||||
types: [released, prereleased]
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }}
|
||||
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
|
||||
|
||||
jobs:
|
||||
branch_build_setup:
|
||||
name: Build-Push Web/Space/API/Proxy Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.3.0
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
gh_branch_name: ${{ env.TARGET_BRANCH }}
|
||||
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
|
||||
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
|
||||
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
|
||||
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
||||
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
|
||||
|
||||
steps:
|
||||
- id: set_env_variables
|
||||
name: Set Environment Variables
|
||||
run: |
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_ENDPOINT=local" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
|
||||
|
||||
branch_build_push_frontend:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Set Frontend Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
|
||||
else
|
||||
TAG=${{ env.FRONTEND_TAG }}
|
||||
fi
|
||||
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
@ -67,7 +84,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./web/Dockerfile.web
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.FRONTEND_TAG }}
|
||||
push: true
|
||||
env:
|
||||
@ -80,33 +97,36 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Set Space Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
|
||||
else
|
||||
TAG=${{ env.SPACE_TAG }}
|
||||
fi
|
||||
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
@ -115,7 +135,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./space/Dockerfile.space
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.SPACE_TAG }}
|
||||
push: true
|
||||
env:
|
||||
@ -128,33 +148,36 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Set Backend Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
|
||||
else
|
||||
TAG=${{ env.BACKEND_TAG }}
|
||||
fi
|
||||
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
@ -163,7 +186,7 @@ jobs:
|
||||
with:
|
||||
context: ./apiserver
|
||||
file: ./apiserver/Dockerfile.api
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
push: true
|
||||
tags: ${{ env.BACKEND_TAG }}
|
||||
env:
|
||||
@ -171,38 +194,42 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
branch_build_push_proxy:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Set Proxy Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
|
||||
else
|
||||
TAG=${{ env.PROXY_TAG }}
|
||||
fi
|
||||
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
@ -211,10 +238,11 @@ jobs:
|
||||
with:
|
||||
context: ./nginx
|
||||
file: ./nginx/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.PROXY_TAG }}
|
||||
push: true
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
# Python imports
|
||||
import zoneinfo
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
@ -51,6 +53,11 @@ class WebhookMixin:
|
||||
and self.request.method in ["POST", "PATCH", "DELETE"]
|
||||
and response.status_code in [200, 201, 204]
|
||||
):
|
||||
url = request.build_absolute_uri()
|
||||
parsed_url = urlparse(url)
|
||||
# Extract the scheme and netloc
|
||||
scheme = parsed_url.scheme
|
||||
netloc = parsed_url.netloc
|
||||
# Push the object to delay
|
||||
send_webhook.delay(
|
||||
event=self.webhook_event,
|
||||
@ -59,6 +66,7 @@ class WebhookMixin:
|
||||
action=self.request.method,
|
||||
slug=self.workspace_slug,
|
||||
bulk=self.bulk,
|
||||
current_site=f"{scheme}://{netloc}",
|
||||
)
|
||||
|
||||
return response
|
||||
|
@ -64,6 +64,7 @@ class WebhookMixin:
|
||||
action=self.request.method,
|
||||
slug=self.workspace_slug,
|
||||
bulk=self.bulk,
|
||||
current_site=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return response
|
||||
|
@ -1668,15 +1668,9 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
Issue.objects.filter(
|
||||
project_id=self.kwargs.get("project_id")
|
||||
)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(is_draft=True)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
@ -1710,7 +1704,7 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
).distinct()
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id):
|
||||
@ -1832,7 +1826,10 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
issue = (
|
||||
self.get_queryset().filter(pk=serializer.data["id"]).first()
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
@ -1868,10 +1865,13 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
return Response(
|
||||
IssueSerializer(
|
||||
issue, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
|
@ -334,7 +334,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
issue_module__module_id=self.kwargs.get("module_id")
|
||||
|
@ -1,5 +1,6 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@ -9,7 +10,6 @@ from django.utils import timezone
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EmailNotificationLog, User, Issue
|
||||
@ -40,7 +40,7 @@ def stack_email_notification():
|
||||
processed_notifications = []
|
||||
# Loop through all the issues to create the emails
|
||||
for receiver_id in receivers:
|
||||
# Notifcation triggered for the receiver
|
||||
# Notification triggered for the receiver
|
||||
receiver_notifications = [
|
||||
notification
|
||||
for notification in email_notifications
|
||||
@ -124,119 +124,153 @@ def create_payload(notification_data):
|
||||
|
||||
return data
|
||||
|
||||
def process_mention(mention_component):
|
||||
soup = BeautifulSoup(mention_component, 'html.parser')
|
||||
mentions = soup.find_all('mention-component')
|
||||
for mention in mentions:
|
||||
user_id = mention['id']
|
||||
user = User.objects.get(pk=user_id)
|
||||
user_name = user.display_name
|
||||
highlighted_name = f"@{user_name}"
|
||||
mention.replace_with(highlighted_name)
|
||||
return str(soup)
|
||||
|
||||
def process_html_content(content):
|
||||
processed_content_list = []
|
||||
for html_content in content:
|
||||
processed_content = process_mention(html_content)
|
||||
processed_content_list.append(processed_content)
|
||||
return processed_content_list
|
||||
|
||||
@shared_task
|
||||
def send_email_notification(
|
||||
issue_id, notification_data, receiver_id, email_notification_ids
|
||||
):
|
||||
ri = redis_instance()
|
||||
base_api = (ri.get(str(issue_id)).decode())
|
||||
data = create_payload(notification_data=notification_data)
|
||||
|
||||
# Get email configurations
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
receiver = User.objects.get(pk=receiver_id)
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
template_data = []
|
||||
total_changes = 0
|
||||
comments = []
|
||||
actors_involved = []
|
||||
for actor_id, changes in data.items():
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
total_changes = total_changes + len(changes)
|
||||
comment = changes.pop("comment", False)
|
||||
actors_involved.append(actor_id)
|
||||
if comment:
|
||||
comments.append(
|
||||
{
|
||||
"actor_comments": comment,
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
activity_time = changes.pop("activity_time")
|
||||
# Parse the input string into a datetime object
|
||||
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
|
||||
|
||||
if changes:
|
||||
template_data.append(
|
||||
{
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
"changes": changes,
|
||||
"issue_details": {
|
||||
"name": issue.name,
|
||||
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
|
||||
},
|
||||
"activity_time": str(formatted_time),
|
||||
}
|
||||
)
|
||||
|
||||
summary = "Updates were made to the issue by"
|
||||
|
||||
# Send the mail
|
||||
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
|
||||
context = {
|
||||
"data": template_data,
|
||||
"summary": summary,
|
||||
"actors_involved": len(set(actors_involved)),
|
||||
"issue": {
|
||||
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
|
||||
"name": issue.name,
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||
},
|
||||
"receiver": {
|
||||
"email": receiver.email,
|
||||
},
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
|
||||
"workspace":str(issue.project.workspace.slug),
|
||||
"project": str(issue.project.name),
|
||||
"user_preference": f"{base_api}/profile/preferences/email",
|
||||
"comments": comments,
|
||||
}
|
||||
html_content = render_to_string(
|
||||
"emails/notifications/issue-updates.html", context
|
||||
)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
try:
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
ri = redis_instance()
|
||||
base_api = (ri.get(str(issue_id)).decode())
|
||||
data = create_payload(notification_data=notification_data)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[receiver.email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
# Get email configurations
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
EmailNotificationLog.objects.filter(
|
||||
pk__in=email_notification_ids
|
||||
).update(sent_at=timezone.now())
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
receiver = User.objects.get(pk=receiver_id)
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
template_data = []
|
||||
total_changes = 0
|
||||
comments = []
|
||||
actors_involved = []
|
||||
for actor_id, changes in data.items():
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
total_changes = total_changes + len(changes)
|
||||
comment = changes.pop("comment", False)
|
||||
mention = changes.pop("mention", False)
|
||||
actors_involved.append(actor_id)
|
||||
if comment:
|
||||
comments.append(
|
||||
{
|
||||
"actor_comments": comment,
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
if mention:
|
||||
mention["new_value"] = process_html_content(mention.get("new_value"))
|
||||
mention["old_value"] = process_html_content(mention.get("old_value"))
|
||||
comments.append(
|
||||
{
|
||||
"actor_comments": mention,
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
activity_time = changes.pop("activity_time")
|
||||
# Parse the input string into a datetime object
|
||||
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
|
||||
|
||||
if changes:
|
||||
template_data.append(
|
||||
{
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
"changes": changes,
|
||||
"issue_details": {
|
||||
"name": issue.name,
|
||||
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
|
||||
},
|
||||
"activity_time": str(formatted_time),
|
||||
}
|
||||
)
|
||||
|
||||
summary = "Updates were made to the issue by"
|
||||
|
||||
# Send the mail
|
||||
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
|
||||
context = {
|
||||
"data": template_data,
|
||||
"summary": summary,
|
||||
"actors_involved": len(set(actors_involved)),
|
||||
"issue": {
|
||||
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
|
||||
"name": issue.name,
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||
},
|
||||
"receiver": {
|
||||
"email": receiver.email,
|
||||
},
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
|
||||
"workspace":str(issue.project.workspace.slug),
|
||||
"project": str(issue.project.name),
|
||||
"user_preference": f"{base_api}/profile/preferences/email",
|
||||
"comments": comments,
|
||||
}
|
||||
html_content = render_to_string(
|
||||
"emails/notifications/issue-updates.html", context
|
||||
)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
try:
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[receiver.email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
EmailNotificationLog.objects.filter(
|
||||
pk__in=email_notification_ids
|
||||
).update(sent_at=timezone.now())
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return
|
||||
except Issue.DoesNotExist:
|
||||
return
|
||||
|
@ -515,7 +515,7 @@ def notifications(
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
@ -552,6 +552,7 @@ def notifications(
|
||||
"old_value": str(
|
||||
issue_activity.get("old_value")
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
@ -639,6 +640,7 @@ def notifications(
|
||||
"old_value": str(
|
||||
last_activity.old_value
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
@ -695,6 +697,7 @@ def notifications(
|
||||
"old_value"
|
||||
)
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
@ -7,6 +7,9 @@ import hmac
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@ -22,10 +25,10 @@ from plane.db.models import (
|
||||
ModuleIssue,
|
||||
CycleIssue,
|
||||
IssueComment,
|
||||
User,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
ProjectSerializer,
|
||||
IssueSerializer,
|
||||
CycleSerializer,
|
||||
ModuleSerializer,
|
||||
CycleIssueSerializer,
|
||||
@ -34,6 +37,9 @@ from plane.api.serializers import (
|
||||
IssueExpandSerializer,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
|
||||
SERIALIZER_MAPPER = {
|
||||
"project": ProjectSerializer,
|
||||
"issue": IssueExpandSerializer,
|
||||
@ -72,7 +78,7 @@ def get_model_data(event, event_id, many=False):
|
||||
max_retries=5,
|
||||
retry_jitter=True,
|
||||
)
|
||||
def webhook_task(self, webhook, slug, event, event_data, action):
|
||||
def webhook_task(self, webhook, slug, event, event_data, action, current_site):
|
||||
try:
|
||||
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
|
||||
|
||||
@ -151,7 +157,18 @@ def webhook_task(self, webhook, slug, event, event_data, action):
|
||||
response_body=str(e),
|
||||
retry_count=str(self.request.retries),
|
||||
)
|
||||
|
||||
# Retry logic
|
||||
if self.request.retries >= self.max_retries:
|
||||
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
|
||||
if webhook:
|
||||
# send email for the deactivation of the webhook
|
||||
send_webhook_deactivation_email(
|
||||
webhook_id=webhook.id,
|
||||
receiver_id=webhook.created_by_id,
|
||||
reason=str(e),
|
||||
current_site=current_site,
|
||||
)
|
||||
return
|
||||
raise requests.RequestException()
|
||||
|
||||
except Exception as e:
|
||||
@ -162,7 +179,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
|
||||
|
||||
|
||||
@shared_task()
|
||||
def send_webhook(event, payload, kw, action, slug, bulk):
|
||||
def send_webhook(event, payload, kw, action, slug, bulk, current_site):
|
||||
try:
|
||||
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
|
||||
|
||||
@ -216,6 +233,7 @@ def send_webhook(event, payload, kw, action, slug, bulk):
|
||||
event=event,
|
||||
event_data=data,
|
||||
action=action,
|
||||
current_site=current_site,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@ -223,3 +241,56 @@ def send_webhook(event, payload, kw, action, slug, bulk):
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason):
|
||||
# Get email configurations
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
receiver = User.objects.get(pk=receiver_id)
|
||||
webhook = Webhook.objects.get(pk=webhook_id)
|
||||
subject="Webhook Deactivated"
|
||||
message=f"Webhook {webhook.url} has been deactivated due to failed requests."
|
||||
|
||||
# Send the mail
|
||||
context = {
|
||||
"email": receiver.email,
|
||||
"message": message,
|
||||
"webhook_url":f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
|
||||
}
|
||||
html_content = render_to_string(
|
||||
"emails/notifications/webhook-deactivate.html", context
|
||||
)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
try:
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[receiver.email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return
|
||||
|
@ -66,7 +66,7 @@
|
||||
style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px"
|
||||
>
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.png"
|
||||
width="130"
|
||||
height="40"
|
||||
border="0"
|
||||
@ -108,25 +108,33 @@
|
||||
margin-bottom: 15px;
|
||||
"
|
||||
/>
|
||||
{% if actors_involved == 1 %}
|
||||
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
|
||||
{{summary}}
|
||||
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
|
||||
{{ data.0.actor_detail.first_name}}
|
||||
{{data.0.actor_detail.last_name}}
|
||||
</span>.
|
||||
</p>
|
||||
{% else %}
|
||||
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
|
||||
{{summary}}
|
||||
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
|
||||
{{ data.0.actor_detail.first_name}}
|
||||
{{data.0.actor_detail.last_name }}
|
||||
</span>and others.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if actors_involved == 1 %}
|
||||
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
|
||||
{{summary}}
|
||||
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
|
||||
{% if data|length > 0 %}
|
||||
{{ data.0.actor_detail.first_name}}
|
||||
{{data.0.actor_detail.last_name}}
|
||||
{% else %}
|
||||
{{ comments.0.actor_detail.first_name}}
|
||||
{{comments.0.actor_detail.last_name}}
|
||||
{% endif %}
|
||||
</span>.
|
||||
</p>
|
||||
{% else %}
|
||||
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
|
||||
{{summary}}
|
||||
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
|
||||
{% if data|length > 0 %}
|
||||
{{ data.0.actor_detail.first_name}}
|
||||
{{data.0.actor_detail.last_name}}
|
||||
{% else %}
|
||||
{{ comments.0.actor_detail.first_name}}
|
||||
{{comments.0.actor_detail.last_name}}
|
||||
{% endif %}
|
||||
</span>and others.
|
||||
</p>
|
||||
{% endif %}
|
||||
<!-- {% if actors_involved == 1 %}
|
||||
{% if data|length > 0 and comments|length == 0 %}
|
||||
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
|
||||
@ -272,7 +280,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/due-date.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/due-date.png"
|
||||
width="12"
|
||||
height="12"
|
||||
border="0"
|
||||
@ -333,7 +341,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/duplicate.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/duplicate.png"
|
||||
width="12"
|
||||
height="12"
|
||||
border="0"
|
||||
@ -428,7 +436,7 @@
|
||||
<tr>
|
||||
<td valign="top" style="white-space: nowrap; padding: 0px;">
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/assignee.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/assignee.png"
|
||||
width="12"
|
||||
height="12"
|
||||
border="0"
|
||||
@ -524,7 +532,7 @@
|
||||
<tr>
|
||||
<td valign="top" style="white-space: nowrap; padding: 0px;">
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/labels.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/labels.png"
|
||||
width="12"
|
||||
height="12"
|
||||
border="0"
|
||||
@ -621,7 +629,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/state.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/state.png"
|
||||
width="12"
|
||||
height="12"
|
||||
border="0"
|
||||
@ -639,15 +647,17 @@
|
||||
State:
|
||||
</p>
|
||||
</td>
|
||||
<td >
|
||||
{% if update.changes.state.old_value.0 == 'Backlog' or update.changes.state.old_value.0 == 'In Progress' or update.changes.state.old_value.0 == 'Done' or update.changes.state.old_value.0 == 'Cancelled' %}
|
||||
<td>
|
||||
<img
|
||||
src="{% if update.changes.state.old_value.0 == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% endif %}{% if update.changes.state.old_value.0 == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% endif %}{% if update.changes.state.old_value.0 == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% endif %}{% if update.changes.state.old_value.0 == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}"
|
||||
src="{% if update.changes.state.old_value.0 == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.png{% endif %}{% if update.changes.state.old_value.0 == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.png{% endif %}{% if update.changes.state.old_value.0 == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.png{% endif %}{% if update.changes.state.old_value.0 == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.png{% endif %}"
|
||||
width="12"
|
||||
height="12"
|
||||
border="0"
|
||||
style="display: block; margin-left: 5px;"
|
||||
/>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<p
|
||||
style="
|
||||
@ -661,22 +671,24 @@
|
||||
</td>
|
||||
<td style="padding-left: 10px; padding-right: 10px;">
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.png"
|
||||
width="16"
|
||||
height="16"
|
||||
border="0"
|
||||
style="display: block;"
|
||||
/>
|
||||
</td>
|
||||
<td >
|
||||
{% if update.changes.state.new_value|last == 'Backlog' or update.changes.state.new_value|last == 'In Progress' or update.changes.state.new_value|last == 'Done' or update.changes.state.new_value|last == 'Cancelled' %}
|
||||
<td>
|
||||
<img
|
||||
src="{% if update.changes.state.new_value|last == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% elif update.changes.state.new_value|last == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% elif update.changes.state.new_value|last == 'Todo' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/todo.webp{% elif update.changes.state.new_value|last == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% elif update.changes.state.new_value|last == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}"
|
||||
src="{% if update.changes.state.new_value|last == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.png{% elif update.changes.state.new_value|last == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.png{% elif update.changes.state.new_value|last == 'Todo' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/todo.png{% elif update.changes.state.new_value|last == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.png{% elif update.changes.state.new_value|last == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.png{% endif %}"
|
||||
width="12"
|
||||
height="12"
|
||||
border="0"
|
||||
style="display: block;"
|
||||
/>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<p
|
||||
style="
|
||||
@ -699,7 +711,7 @@
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/link.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/link.png"
|
||||
width="12"
|
||||
height="12"
|
||||
border="0"
|
||||
@ -760,7 +772,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/priority.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/priority.png"
|
||||
width="12"
|
||||
height="12"
|
||||
border="0"
|
||||
@ -800,7 +812,7 @@
|
||||
</td>
|
||||
<td style="padding-left: 10px; padding-right: 10px;">
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.png"
|
||||
width="16"
|
||||
height="16"
|
||||
border="0"
|
||||
@ -838,7 +850,7 @@
|
||||
<tr style="overflow-wrap: break-word;">
|
||||
<td>
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/blocking.webp"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/blocking.png"
|
||||
width="12"
|
||||
height="12"
|
||||
border="0"
|
||||
|
1544
apiserver/templates/emails/notifications/webhook-deactivate.html
Normal file
1544
apiserver/templates/emails/notifications/webhook-deactivate.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if the user has sudo access
|
||||
if command -v curl &> /dev/null; then
|
||||
sudo curl -sSL \
|
||||
-o /usr/local/bin/plane-app \
|
||||
@ -11,6 +12,6 @@ else
|
||||
fi
|
||||
|
||||
sudo chmod +x /usr/local/bin/plane-app
|
||||
sudo sed -i 's/export BRANCH=${BRANCH:-master}/export BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
|
||||
sudo sed -i 's/export DEPLOY_BRANCH=${BRANCH:-master}/export DEPLOY_BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
|
||||
|
||||
sudo plane-app --help
|
||||
plane-app --help
|
||||
|
@ -17,7 +17,7 @@ Project management tool from the future
|
||||
|
||||
EOF
|
||||
}
|
||||
function update_env_files() {
|
||||
function update_env_file() {
|
||||
config_file=$1
|
||||
key=$2
|
||||
value=$3
|
||||
@ -25,14 +25,16 @@ function update_env_files() {
|
||||
# Check if the config file exists
|
||||
if [ ! -f "$config_file" ]; then
|
||||
echo "Config file not found. Creating a new one..." >&2
|
||||
touch "$config_file"
|
||||
sudo touch "$config_file"
|
||||
fi
|
||||
|
||||
# Check if the key already exists in the config file
|
||||
if grep -q "^$key=" "$config_file"; then
|
||||
awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
|
||||
if sudo grep "^$key=" "$config_file"; then
|
||||
sudo awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" | sudo tee "$config_file.tmp" > /dev/null
|
||||
sudo mv "$config_file.tmp" "$config_file" &> /dev/null
|
||||
else
|
||||
echo "$key=$value" >> "$config_file"
|
||||
# sudo echo "$key=$value" >> "$config_file"
|
||||
echo -e "$key=$value" | sudo tee -a "$config_file" > /dev/null
|
||||
fi
|
||||
}
|
||||
function read_env_file() {
|
||||
@ -42,12 +44,12 @@ function read_env_file() {
|
||||
# Check if the config file exists
|
||||
if [ ! -f "$config_file" ]; then
|
||||
echo "Config file not found. Creating a new one..." >&2
|
||||
touch "$config_file"
|
||||
sudo touch "$config_file"
|
||||
fi
|
||||
|
||||
# Check if the key already exists in the config file
|
||||
if grep -q "^$key=" "$config_file"; then
|
||||
value=$(awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
|
||||
if sudo grep -q "^$key=" "$config_file"; then
|
||||
value=$(sudo awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
|
||||
echo "$value"
|
||||
else
|
||||
echo ""
|
||||
@ -55,19 +57,19 @@ function read_env_file() {
|
||||
}
|
||||
function update_config() {
|
||||
config_file="$PLANE_INSTALL_DIR/config.env"
|
||||
update_env_files "$config_file" "$1" "$2"
|
||||
update_env_file $config_file $1 $2
|
||||
}
|
||||
function read_config() {
|
||||
config_file="$PLANE_INSTALL_DIR/config.env"
|
||||
read_env_file "$config_file" "$1"
|
||||
read_env_file $config_file $1
|
||||
}
|
||||
function update_env() {
|
||||
config_file="$PLANE_INSTALL_DIR/.env"
|
||||
update_env_files "$config_file" "$1" "$2"
|
||||
update_env_file $config_file $1 $2
|
||||
}
|
||||
function read_env() {
|
||||
config_file="$PLANE_INSTALL_DIR/.env"
|
||||
read_env_file "$config_file" "$1"
|
||||
read_env_file $config_file $1
|
||||
}
|
||||
function show_message() {
|
||||
print_header
|
||||
@ -87,14 +89,14 @@ function prepare_environment() {
|
||||
show_message "Prepare Environment..." >&2
|
||||
|
||||
show_message "- Updating OS with required tools ✋" >&2
|
||||
sudo apt-get update -y &> /dev/null
|
||||
sudo apt-get upgrade -y &> /dev/null
|
||||
sudo "$PACKAGE_MANAGER" update -y
|
||||
sudo "$PACKAGE_MANAGER" upgrade -y
|
||||
|
||||
required_tools=("curl" "awk" "wget" "nano" "dialog" "git")
|
||||
local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap")
|
||||
|
||||
for tool in "${required_tools[@]}"; do
|
||||
if ! command -v $tool &> /dev/null; then
|
||||
sudo apt install -y $tool &> /dev/null
|
||||
sudo "$PACKAGE_MANAGER" install -y $tool
|
||||
fi
|
||||
done
|
||||
|
||||
@ -103,11 +105,30 @@ function prepare_environment() {
|
||||
# Install Docker if not installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
show_message "- Installing Docker ✋" >&2
|
||||
sudo curl -o- https://get.docker.com | bash -
|
||||
# curl -o- https://get.docker.com | bash -
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
dockerd-rootless-setuptool.sh install &> /dev/null
|
||||
if [ "$PACKAGE_MANAGER" == "yum" ]; then
|
||||
sudo $PACKAGE_MANAGER install -y yum-utils
|
||||
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo &> /dev/null
|
||||
elif [ "$PACKAGE_MANAGER" == "apt-get" ]; then
|
||||
# Add Docker's official GPG key:
|
||||
sudo $PACKAGE_MANAGER update
|
||||
sudo $PACKAGE_MANAGER install ca-certificates curl &> /dev/null
|
||||
sudo install -m 0755 -d /etc/apt/keyrings &> /dev/null
|
||||
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc &> /dev/null
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc &> /dev/null
|
||||
|
||||
# Add the repository to Apt sources:
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
sudo $PACKAGE_MANAGER update
|
||||
fi
|
||||
|
||||
sudo $PACKAGE_MANAGER install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
|
||||
|
||||
show_message "- Docker Installed ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "- Docker is already installed ✅" >&2
|
||||
@ -127,17 +148,17 @@ function prepare_environment() {
|
||||
function download_plane() {
|
||||
# Download Docker Compose File from github url
|
||||
show_message "Downloading Plane Setup Files ✋" >&2
|
||||
curl -H 'Cache-Control: no-cache, no-store' \
|
||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
|
||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
|
||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s)
|
||||
|
||||
curl -H 'Cache-Control: no-cache, no-store' \
|
||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
|
||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
|
||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s)
|
||||
|
||||
# if .env does not exists rename variables-upgrade.env to .env
|
||||
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
|
||||
mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
|
||||
sudo mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
|
||||
fi
|
||||
|
||||
show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2
|
||||
@ -186,7 +207,7 @@ function build_local_image() {
|
||||
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
|
||||
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
|
||||
|
||||
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch -q > /dev/null
|
||||
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $DEPLOY_BRANCH --single-branch -q > /dev/null
|
||||
|
||||
sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml
|
||||
|
||||
@ -199,25 +220,26 @@ function check_for_docker_images() {
|
||||
show_message "" >&2
|
||||
# show_message "Building Plane Images" >&2
|
||||
|
||||
update_env "DOCKERHUB_USER" "makeplane"
|
||||
update_env "PULL_POLICY" "always"
|
||||
CURR_DIR=$(pwd)
|
||||
|
||||
if [ "$BRANCH" == "master" ]; then
|
||||
if [ "$DEPLOY_BRANCH" == "master" ]; then
|
||||
update_env "APP_RELEASE" "latest"
|
||||
export APP_RELEASE=latest
|
||||
else
|
||||
update_env "APP_RELEASE" "$BRANCH"
|
||||
export APP_RELEASE=$BRANCH
|
||||
update_env "APP_RELEASE" "$DEPLOY_BRANCH"
|
||||
export APP_RELEASE=$DEPLOY_BRANCH
|
||||
fi
|
||||
|
||||
if [ $CPU_ARCH == "amd64" ] || [ $CPU_ARCH == "x86_64" ]; then
|
||||
if [ $USE_GLOBAL_IMAGES == 1 ]; then
|
||||
# show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2
|
||||
export DOCKERHUB_USER=makeplane
|
||||
update_env "DOCKERHUB_USER" "$DOCKERHUB_USER"
|
||||
update_env "PULL_POLICY" "always"
|
||||
echo "Building Plane Images for $CPU_ARCH is not required. Skipping..."
|
||||
else
|
||||
export DOCKERHUB_USER=myplane
|
||||
show_message "Building Plane Images for $CPU_ARCH " >&2
|
||||
update_env "DOCKERHUB_USER" "myplane"
|
||||
update_env "DOCKERHUB_USER" "$DOCKERHUB_USER"
|
||||
update_env "PULL_POLICY" "never"
|
||||
|
||||
build_local_image
|
||||
@ -233,7 +255,7 @@ function check_for_docker_images() {
|
||||
sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
|
||||
show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2
|
||||
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
|
||||
sudo docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
|
||||
show_message "Plane Images Downloaded ✅" "replace_last_line" >&2
|
||||
}
|
||||
function configure_plane() {
|
||||
@ -453,9 +475,11 @@ function install() {
|
||||
show_message ""
|
||||
if [ "$(uname)" == "Linux" ]; then
|
||||
OS="linux"
|
||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
# check the OS
|
||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
||||
OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
|
||||
print_header
|
||||
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
|
||||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
|
||||
OS_SUPPORTED=true
|
||||
show_message "******** Installing Plane ********"
|
||||
show_message ""
|
||||
@ -488,7 +512,8 @@ function install() {
|
||||
fi
|
||||
|
||||
else
|
||||
PROGRESS_MSG="❌❌❌ Unsupported OS Detected ❌❌❌"
|
||||
OS_SUPPORTED=false
|
||||
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
@ -499,12 +524,17 @@ function install() {
|
||||
fi
|
||||
}
|
||||
function upgrade() {
|
||||
print_header
|
||||
if [ "$(uname)" == "Linux" ]; then
|
||||
OS="linux"
|
||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
# check the OS
|
||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
||||
OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
|
||||
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
|
||||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
|
||||
|
||||
OS_SUPPORTED=true
|
||||
show_message "******** Upgrading Plane ********"
|
||||
show_message ""
|
||||
|
||||
prepare_environment
|
||||
|
||||
@ -528,53 +558,49 @@ function upgrade() {
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected"
|
||||
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected : $(uname)"
|
||||
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
function uninstall() {
|
||||
print_header
|
||||
if [ "$(uname)" == "Linux" ]; then
|
||||
OS="linux"
|
||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
# check the OS
|
||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
||||
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
|
||||
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
|
||||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
|
||||
|
||||
OS_SUPPORTED=true
|
||||
show_message "******** Uninstalling Plane ********"
|
||||
show_message ""
|
||||
|
||||
stop_server
|
||||
# CHECK IF PLANE SERVICE EXISTS
|
||||
# if [ -f "/etc/systemd/system/plane.service" ]; then
|
||||
# sudo systemctl stop plane.service &> /dev/null
|
||||
# sudo systemctl disable plane.service &> /dev/null
|
||||
# sudo rm /etc/systemd/system/plane.service &> /dev/null
|
||||
# sudo systemctl daemon-reload &> /dev/null
|
||||
# fi
|
||||
# show_message "- Plane Service removed ✅"
|
||||
|
||||
if ! [ -x "$(command -v docker)" ]; then
|
||||
echo "DOCKER_NOT_INSTALLED" &> /dev/null
|
||||
else
|
||||
# Ask of user input to confirm uninstall docker ?
|
||||
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
|
||||
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --defaultno --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
|
||||
if [ $? -eq 0 ]; then
|
||||
show_message "- Uninstalling Docker ✋"
|
||||
sudo apt-get purge -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
|
||||
sudo apt-get autoremove -y --purge docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
|
||||
sudo docker images -q | xargs -r sudo docker rmi -f &> /dev/null
|
||||
sudo "$PACKAGE_MANAGER" remove -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
|
||||
sudo "$PACKAGE_MANAGER" autoremove -y docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
|
||||
show_message "- Docker Uninstalled ✅" "replace_last_line" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
rm $PLANE_INSTALL_DIR/.env &> /dev/null
|
||||
rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
|
||||
rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
||||
rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/.env &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
||||
|
||||
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
|
||||
show_message "- Configuration Cleaned ✅"
|
||||
@ -593,12 +619,12 @@ function uninstall() {
|
||||
show_message ""
|
||||
show_message ""
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
|
||||
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
|
||||
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
@ -608,15 +634,15 @@ function start_server() {
|
||||
env_file="$PLANE_INSTALL_DIR/.env"
|
||||
# check if both the files exits
|
||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||
show_message "Starting Plane Server ✋"
|
||||
docker compose -f $docker_compose_file --env-file=$env_file up -d
|
||||
show_message "Starting Plane Server ($APP_RELEASE) ✋"
|
||||
sudo docker compose -f $docker_compose_file --env-file=$env_file up -d
|
||||
|
||||
# Wait for containers to be running
|
||||
echo "Waiting for containers to start..."
|
||||
while ! docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
|
||||
while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
|
||||
sleep 1
|
||||
done
|
||||
show_message "Plane Server Started ✅" "replace_last_line" >&2
|
||||
show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||
fi
|
||||
@ -626,11 +652,11 @@ function stop_server() {
|
||||
env_file="$PLANE_INSTALL_DIR/.env"
|
||||
# check if both the files exits
|
||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||
show_message "Stopping Plane Server ✋"
|
||||
docker compose -f $docker_compose_file --env-file=$env_file down
|
||||
show_message "Plane Server Stopped ✅" "replace_last_line" >&2
|
||||
show_message "Stopping Plane Server ($APP_RELEASE) ✋"
|
||||
sudo docker compose -f $docker_compose_file --env-file=$env_file down
|
||||
show_message "Plane Server Stopped ($APP_RELEASE) ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||
show_message "Plane Server not installed [Skipping] ✅" "replace_last_line" >&2
|
||||
fi
|
||||
}
|
||||
function restart_server() {
|
||||
@ -638,9 +664,9 @@ function restart_server() {
|
||||
env_file="$PLANE_INSTALL_DIR/.env"
|
||||
# check if both the files exits
|
||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||
show_message "Restarting Plane Server ✋"
|
||||
docker compose -f $docker_compose_file --env-file=$env_file restart
|
||||
show_message "Plane Server Restarted ✅" "replace_last_line" >&2
|
||||
show_message "Restarting Plane Server ($APP_RELEASE) ✋"
|
||||
sudo docker compose -f $docker_compose_file --env-file=$env_file restart
|
||||
show_message "Plane Server Restarted ($APP_RELEASE) ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||
fi
|
||||
@ -666,28 +692,45 @@ function show_help() {
|
||||
}
|
||||
function update_installer() {
|
||||
show_message "Updating Plane Installer ✋" >&2
|
||||
curl -H 'Cache-Control: no-cache, no-store' \
|
||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||
-s -o /usr/local/bin/plane-app \
|
||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/1-click/install.sh?token=$(date +%s)
|
||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s)
|
||||
|
||||
chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
|
||||
sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
|
||||
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
|
||||
}
|
||||
|
||||
export BRANCH=${BRANCH:-master}
|
||||
export APP_RELEASE=$BRANCH
|
||||
export DEPLOY_BRANCH=${BRANCH:-master}
|
||||
export APP_RELEASE=$DEPLOY_BRANCH
|
||||
export DOCKERHUB_USER=makeplane
|
||||
export PULL_POLICY=always
|
||||
|
||||
if [ "$DEPLOY_BRANCH" == "master" ]; then
|
||||
export APP_RELEASE=latest
|
||||
fi
|
||||
|
||||
PLANE_INSTALL_DIR=/opt/plane
|
||||
DATA_DIR=$PLANE_INSTALL_DIR/data
|
||||
LOG_DIR=$PLANE_INSTALL_DIR/log
|
||||
OS_SUPPORTED=false
|
||||
CPU_ARCH=$(uname -m)
|
||||
PROGRESS_MSG=""
|
||||
USE_GLOBAL_IMAGES=1
|
||||
USE_GLOBAL_IMAGES=0
|
||||
PACKAGE_MANAGER=""
|
||||
|
||||
mkdir -p $PLANE_INSTALL_DIR/{data,log}
|
||||
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then
|
||||
USE_GLOBAL_IMAGES=1
|
||||
fi
|
||||
|
||||
sudo mkdir -p $PLANE_INSTALL_DIR/{data,log}
|
||||
|
||||
if command -v apt-get &> /dev/null; then
|
||||
PACKAGE_MANAGER="apt-get"
|
||||
elif command -v yum &> /dev/null; then
|
||||
PACKAGE_MANAGER="yum"
|
||||
elif command -v apk &> /dev/null; then
|
||||
PACKAGE_MANAGER="apk"
|
||||
fi
|
||||
|
||||
if [ "$1" == "start" ]; then
|
||||
start_server
|
||||
@ -704,7 +747,7 @@ elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then
|
||||
upgrade
|
||||
elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then
|
||||
uninstall
|
||||
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ] ; then
|
||||
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ]; then
|
||||
update_installer
|
||||
elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_help
|
||||
|
@ -38,10 +38,6 @@ x-app-env : &app-env
|
||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
|
||||
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
|
||||
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
|
||||
# OPENAI SETTINGS - Deprecated can be configured through admin panel
|
||||
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-""}
|
||||
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"}
|
||||
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
|
||||
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
||||
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
|
||||
|
@ -20,8 +20,8 @@ function buildLocalImage() {
|
||||
DO_BUILD="2"
|
||||
else
|
||||
printf "\n" >&2
|
||||
printf "${YELLOW}You are on ${ARCH} cpu architecture. ${NC}\n" >&2
|
||||
printf "${YELLOW}Since the prebuilt ${ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2
|
||||
printf "${YELLOW}You are on ${CPU_ARCH} cpu architecture. ${NC}\n" >&2
|
||||
printf "${YELLOW}Since the prebuilt ${CPU_ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2
|
||||
printf "${YELLOW}This might take ${YELLOW}5-30 min based on your system's hardware configuration. \n ${NC} \n" >&2
|
||||
printf "\n" >&2
|
||||
printf "${GREEN}Select an option to proceed: ${NC}\n" >&2
|
||||
@ -149,7 +149,7 @@ function upgrade() {
|
||||
function askForAction() {
|
||||
echo
|
||||
echo "Select a Action you want to perform:"
|
||||
echo " 1) Install (${ARCH})"
|
||||
echo " 1) Install (${CPU_ARCH})"
|
||||
echo " 2) Start"
|
||||
echo " 3) Stop"
|
||||
echo " 4) Restart"
|
||||
@ -193,8 +193,8 @@ function askForAction() {
|
||||
}
|
||||
|
||||
# CPU ARCHITECHTURE BASED SETTINGS
|
||||
ARCH=$(uname -m)
|
||||
if [ $ARCH == "amd64" ] || [ $ARCH == "x86_64" ];
|
||||
CPU_ARCH=$(uname -m)
|
||||
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]];
|
||||
then
|
||||
USE_GLOBAL_IMAGES=1
|
||||
DOCKERHUB_USER=makeplane
|
||||
|
@ -8,13 +8,13 @@ NGINX_PORT=80
|
||||
WEB_URL=http://localhost
|
||||
DEBUG=0
|
||||
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
||||
SENTRY_DSN=""
|
||||
SENTRY_ENVIRONMENT="production"
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=production
|
||||
GOOGLE_CLIENT_ID=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
DOCKERIZED=1 # deprecated
|
||||
CORS_ALLOWED_ORIGINS="http://localhost"
|
||||
CORS_ALLOWED_ORIGINS=http://localhost
|
||||
|
||||
#DB SETTINGS
|
||||
PGHOST=plane-db
|
||||
@ -31,19 +31,14 @@ REDIS_PORT=6379
|
||||
REDIS_URL=redis://${REDIS_HOST}:6379/
|
||||
|
||||
# EMAIL SETTINGS
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
EMAIL_HOST=
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
EMAIL_PORT=587
|
||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
||||
EMAIL_FROM=Team Plane <team@mailer.plane.so>
|
||||
EMAIL_USE_TLS=1
|
||||
EMAIL_USE_SSL=0
|
||||
|
||||
# OPENAI SETTINGS
|
||||
OPENAI_API_BASE=https://api.openai.com/v1 # deprecated
|
||||
OPENAI_API_KEY="sk-" # deprecated
|
||||
GPT_ENGINE="gpt-3.5-turbo" # deprecated
|
||||
|
||||
# LOGIN/SIGNUP SETTINGS
|
||||
ENABLE_SIGNUP=1
|
||||
ENABLE_EMAIL_PASSWORD=1
|
||||
@ -52,13 +47,13 @@ SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
|
||||
|
||||
# DATA STORE SETTINGS
|
||||
USE_MINIO=1
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
AWS_SECRET_ACCESS_KEY="secret-key"
|
||||
AWS_REGION=
|
||||
AWS_ACCESS_KEY_ID=access-key
|
||||
AWS_SECRET_ACCESS_KEY=secret-key
|
||||
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
||||
AWS_S3_BUCKET_NAME=uploads
|
||||
MINIO_ROOT_USER="access-key"
|
||||
MINIO_ROOT_PASSWORD="secret-key"
|
||||
MINIO_ROOT_USER=access-key
|
||||
MINIO_ROOT_PASSWORD=secret-key
|
||||
BUCKET_NAME=uploads
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
|
@ -15,6 +15,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
const {
|
||||
buttonClassName = "",
|
||||
customButtonClassName = "",
|
||||
customButtonTabIndex = 0,
|
||||
placement,
|
||||
children,
|
||||
className = "",
|
||||
@ -29,6 +30,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
verticalEllipsis = false,
|
||||
portalElement,
|
||||
menuButtonOnClick,
|
||||
onMenuClose,
|
||||
tabIndex,
|
||||
closeOnSelect,
|
||||
} = props;
|
||||
@ -47,18 +49,27 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
setIsOpen(true);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
const closeDropdown = () => setIsOpen(false);
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||
const closeDropdown = () => {
|
||||
isOpen && onMenuClose && onMenuClose();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleOnChange = () => {
|
||||
if (closeOnSelect) closeDropdown();
|
||||
};
|
||||
|
||||
const selectActiveItem = () => {
|
||||
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
|
||||
`[data-headlessui-state="active"] button`
|
||||
);
|
||||
activeItem?.click();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem);
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||
|
||||
let menuItems = (
|
||||
<Menu.Items
|
||||
className="fixed z-10"
|
||||
onClick={() => {
|
||||
if (closeOnSelect) closeDropdown();
|
||||
}}
|
||||
static
|
||||
>
|
||||
<Menu.Items className="fixed z-10" static>
|
||||
<div
|
||||
className={cn(
|
||||
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
|
||||
@ -89,7 +100,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("relative w-min text-left", className)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
onChange={handleOnChange}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
@ -103,6 +115,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
}}
|
||||
className={customButtonClassName}
|
||||
tabIndex={customButtonTabIndex}
|
||||
>
|
||||
{customButton}
|
||||
</button>
|
||||
@ -122,6 +135,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
tabIndex={customButtonTabIndex}
|
||||
>
|
||||
<MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
@ -142,6 +156,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
openDropdown();
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
}}
|
||||
tabIndex={customButtonTabIndex}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
|
||||
@ -159,6 +174,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
|
||||
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||
const { children, onClick, className = "" } = props;
|
||||
|
||||
return (
|
||||
<Menu.Item as="div">
|
||||
{({ active, close }) => (
|
||||
|
@ -3,6 +3,7 @@ import { Placement } from "@blueprintjs/popover2";
|
||||
|
||||
export interface IDropdownProps {
|
||||
customButtonClassName?: string;
|
||||
customButtonTabIndex?: number;
|
||||
buttonClassName?: string;
|
||||
className?: string;
|
||||
customButton?: JSX.Element;
|
||||
@ -23,6 +24,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps {
|
||||
noBorder?: boolean;
|
||||
verticalEllipsis?: boolean;
|
||||
menuButtonOnClick?: (...args: any) => void;
|
||||
onMenuClose?: () => void;
|
||||
closeOnSelect?: boolean;
|
||||
portalElement?: Element | null;
|
||||
}
|
||||
|
@ -1,16 +1,23 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
type TUseDropdownKeyDown = {
|
||||
(onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||
(
|
||||
onOpen: () => void,
|
||||
onClose: () => void,
|
||||
isOpen: boolean,
|
||||
selectActiveItem?: () => void
|
||||
): (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => {
|
||||
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen, selectActiveItem?) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.stopPropagation();
|
||||
if (!isOpen) {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
} else {
|
||||
selectActiveItem && selectActiveItem();
|
||||
}
|
||||
} else if (event.key === "Escape" && isOpen) {
|
||||
event.stopPropagation();
|
||||
|
@ -38,14 +38,12 @@ export const ProjectAnalyticsModal: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<Dialog.Panel className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
|
||||
<div
|
||||
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
|
||||
fullScreen ? "w-full p-2" : "w-1/2"
|
||||
}`}
|
||||
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${fullScreen ? "w-full p-2" : "w-full sm:w-full md:w-1/2"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
|
||||
fullScreen ? "rounded-lg border" : "border-l"
|
||||
}`}
|
||||
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${fullScreen ? "rounded-lg border" : "border-l"
|
||||
}`}
|
||||
>
|
||||
<ProjectAnalyticsModalHeader
|
||||
fullScreen={fullScreen}
|
||||
|
@ -163,6 +163,8 @@ export const CommandPalette: FC = observer(() => {
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
@ -217,6 +219,7 @@ export const CommandPalette: FC = observer(() => {
|
||||
onClose={() => toggleCreateIssueModal(false)}
|
||||
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
|
||||
storeType={createIssueStoreType}
|
||||
isDraft={isDraftIssue}
|
||||
/>
|
||||
|
||||
{workspaceSlug && projectId && issueId && issueDetails && (
|
||||
|
@ -47,7 +47,7 @@ export const ShortcutsModal: FC<Props> = (props) => {
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="h-full w-full">
|
||||
<div className="grid h-full w-full place-items-center p-5">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
||||
<div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-custom-background-100 p-5 shadow-custom-shadow-md transition-all sm:w-[28rem]">
|
||||
<Dialog.Title as="h3" className="flex justify-between">
|
||||
<span className="text-lg font-medium">Keyboard shortcuts</span>
|
||||
|
@ -11,7 +11,7 @@ export const BreadcrumbLink: React.FC<Props> = (props) => {
|
||||
const { href, label, icon } = props;
|
||||
return (
|
||||
<Tooltip tooltipContent={label} position="bottom">
|
||||
<li className="flex items-center space-x-2">
|
||||
<li className="flex items-center space-x-2" tabIndex={-1}>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{href ? (
|
||||
<Link
|
||||
|
168
web/components/cycles/cycle-mobile-header.tsx
Normal file
168
web/components/cycles/cycle-mobile-header.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import router from "next/router";
|
||||
//components
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// icons
|
||||
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssues, useCycle, useProjectState, useLabel, useMember } from "hooks/store";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue";
|
||||
import { ProjectAnalyticsModal } from "components/analytics";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues";
|
||||
|
||||
export const CycleMobileHeader = () => {
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
const { getCycleById } = useCycle();
|
||||
const layouts = [
|
||||
{ key: "list", title: "List", icon: List },
|
||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||
];
|
||||
|
||||
const { workspaceSlug, projectId, cycleId } = router.query as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
};
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||
);
|
||||
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
cycleDetails={cycleDetails ?? undefined}
|
||||
/>
|
||||
<div className="flex justify-evenly py-2 border-b border-custom-border-200">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
placement="bottom-start"
|
||||
customButton={<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{layouts.map((layout, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleLayoutChange(ISSUE_LAYOUTS[index].key);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<layout.icon className="w-3 h-3" />
|
||||
<div className="text-custom-text-300">{layout.title}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-custom-text-200 text-sm">
|
||||
Filters
|
||||
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title="Display"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-custom-text-200 text-sm">
|
||||
Display
|
||||
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
|
||||
<span
|
||||
onClick={() => setAnalyticsModal(true)}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200"
|
||||
>
|
||||
Analytics
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
@ -159,10 +159,10 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
projectId={projectId}
|
||||
/>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||
<div className="group flex h-16 w-full items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90">
|
||||
<div className="flex w-full items-center gap-3 truncate">
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
<span className="flex-shrink-0">
|
||||
<div className="group flex flex-col md:flex-row w-full items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90">
|
||||
<div className="relative w-full flex items-center justify-between gap-3 overflow-hidden">
|
||||
<div className="relative w-full flex items-center gap-3 overflow-hidden">
|
||||
<div className="flex-shrink-0">
|
||||
<CircularProgressIndicator size={38} percentage={progress}>
|
||||
{isCompleted ? (
|
||||
progress === 100 ? (
|
||||
@ -176,95 +176,97 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
||||
)}
|
||||
</CircularProgressIndicator>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex-shrink-0">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div className="relative flex items-center gap-2.5 overflow-hidden">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<Tooltip tooltipContent={cycleDetails.name} position="top">
|
||||
<span className="truncate text-base font-medium">{cycleDetails.name}</span>
|
||||
<span className="truncate line-clamp-1 inline-block overflow-hidden text-base font-medium">
|
||||
{cycleDetails.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={openCycleOverview} className="z-10 hidden flex-shrink-0 group-hover:flex">
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-end gap-2.5 md:w-auto md:flex-shrink-0 ">
|
||||
<div className="flex items-center justify-center">
|
||||
{currentCycle && (
|
||||
<span
|
||||
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: currentCycle.color,
|
||||
backgroundColor: `${currentCycle.color}20`,
|
||||
}}
|
||||
>
|
||||
{currentCycle.value === "current"
|
||||
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
|
||||
: `${currentCycle.label}`}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={openCycleOverview} className="flex-shrink-0 z-10 invisible group-hover:visible">
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renderDate && (
|
||||
<span className="flex w-40 items-center justify-center gap-2 text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
|
||||
<div className="flex w-16 cursor-default items-center justify-center gap-1">
|
||||
{cycleDetails.assignees.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycleDetails.assignees.map((assignee) => (
|
||||
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
||||
<User2 className="h-4 w-4 text-custom-text-400" />
|
||||
</span>
|
||||
)}
|
||||
{currentCycle && (
|
||||
<div
|
||||
className="flex-shrink-0 relative flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: currentCycle.color,
|
||||
backgroundColor: `${currentCycle.color}20`,
|
||||
}}
|
||||
>
|
||||
{currentCycle.value === "current"
|
||||
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
|
||||
: `${currentCycle.label}`}
|
||||
</div>
|
||||
</Tooltip>
|
||||
{isEditingAllowed &&
|
||||
(cycleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 relative overflow-hidden flex w-full items-center justify-between md:justify-end gap-2.5 md:w-auto md:flex-shrink-0 ">
|
||||
<div className="text-xs text-custom-text-300">
|
||||
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
|
||||
</div>
|
||||
|
||||
<CustomMenu ellipsis>
|
||||
{!isCompleted && isEditingAllowed && (
|
||||
<div className="flex-shrink-0 relative flex items-center gap-3">
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
|
||||
<div className="flex w-10 cursor-default items-center justify-center">
|
||||
{cycleDetails.assignees.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycleDetails.assignees.map((assignee) => (
|
||||
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
||||
<User2 className="h-4 w-4 text-custom-text-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{isEditingAllowed && (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={handleEditCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>Edit cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>Delete cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
{cycleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<CustomMenu ellipsis>
|
||||
{!isCompleted && isEditingAllowed && (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={handleEditCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>Edit cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>Delete cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Copy cycle link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Copy cycle link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
@ -23,6 +23,7 @@ type Props = TDropdownProps & {
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onChange: (val: string | null) => void;
|
||||
onClose?: () => void;
|
||||
projectId: string;
|
||||
value: string | null;
|
||||
};
|
||||
@ -47,6 +48,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
dropdownArrowClassName = "",
|
||||
hideIcon = false,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Cycle",
|
||||
placement,
|
||||
projectId,
|
||||
@ -123,8 +125,10 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
@ -163,7 +167,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
|
@ -23,6 +23,7 @@ type Props = TDropdownProps & {
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
onChange: (val: Date | null) => void;
|
||||
onClose?: () => void;
|
||||
value: Date | string | null;
|
||||
closeOnSelect?: boolean;
|
||||
};
|
||||
@ -42,6 +43,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
||||
minDate,
|
||||
maxDate,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Date",
|
||||
placement,
|
||||
showTooltip = false,
|
||||
@ -74,8 +76,10 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
@ -112,7 +116,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block h-full max-w-full outline-none",
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
|
@ -22,6 +22,7 @@ type Props = TDropdownProps & {
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onChange: (val: number | null) => void;
|
||||
onClose?: () => void;
|
||||
projectId: string;
|
||||
value: number | null;
|
||||
};
|
||||
@ -46,6 +47,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
dropdownArrowClassName = "",
|
||||
hideIcon = false,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Estimate",
|
||||
placement,
|
||||
projectId,
|
||||
@ -112,8 +114,10 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
@ -152,7 +156,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
@ -162,7 +166,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block h-full max-w-full outline-none",
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
|
@ -21,6 +21,7 @@ import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
onClose?: () => void;
|
||||
} & MemberDropdownProps;
|
||||
|
||||
export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
||||
@ -36,6 +37,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
||||
hideIcon = false,
|
||||
multiple,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Members",
|
||||
placement,
|
||||
projectId,
|
||||
@ -105,8 +107,10 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
@ -144,7 +148,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
@ -154,7 +158,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block h-full max-w-full outline-none",
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
|
1
web/components/dropdowns/member/types.d.ts
vendored
1
web/components/dropdowns/member/types.d.ts
vendored
@ -5,6 +5,7 @@ export type MemberDropdownProps = TDropdownProps & {
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
placeholder?: string;
|
||||
onClose?: () => void;
|
||||
} & (
|
||||
| {
|
||||
multiple: false;
|
||||
|
@ -32,6 +32,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
|
||||
hideIcon = false,
|
||||
multiple,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Members",
|
||||
placement,
|
||||
showTooltip = false,
|
||||
@ -95,8 +96,10 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
@ -134,7 +137,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
@ -144,7 +147,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block h-full max-w-full outline-none",
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
|
@ -24,6 +24,7 @@ type Props = TDropdownProps & {
|
||||
dropdownArrowClassName?: string;
|
||||
projectId: string;
|
||||
showCount?: boolean;
|
||||
onClose?: () => void;
|
||||
} & (
|
||||
| {
|
||||
multiple: false;
|
||||
@ -151,6 +152,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
hideIcon = false,
|
||||
multiple,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Module",
|
||||
placement,
|
||||
projectId,
|
||||
@ -226,8 +228,10 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
@ -271,7 +275,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
@ -281,7 +285,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block h-full max-w-full outline-none",
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
|
@ -23,6 +23,7 @@ type Props = TDropdownProps & {
|
||||
dropdownArrowClassName?: string;
|
||||
highlightUrgent?: boolean;
|
||||
onChange: (val: TIssuePriorities) => void;
|
||||
onClose?: () => void;
|
||||
value: TIssuePriorities;
|
||||
};
|
||||
|
||||
@ -260,6 +261,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
hideIcon = false,
|
||||
highlightUrgent = true,
|
||||
onChange,
|
||||
onClose,
|
||||
placement,
|
||||
showTooltip = false,
|
||||
tabIndex,
|
||||
@ -308,8 +310,10 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
@ -360,7 +364,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
@ -370,7 +374,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block h-full max-w-full outline-none",
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
|
@ -22,6 +22,7 @@ type Props = TDropdownProps & {
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onChange: (val: string) => void;
|
||||
onClose?: () => void;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
@ -37,6 +38,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||
dropdownArrowClassName = "",
|
||||
hideIcon = false,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Project",
|
||||
placement,
|
||||
showTooltip = false,
|
||||
@ -97,7 +99,9 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
if (referenceElement) referenceElement.blur();
|
||||
};
|
||||
|
||||
@ -137,7 +141,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
@ -147,7 +151,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block h-full max-w-full outline-none",
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
|
@ -23,6 +23,7 @@ type Props = TDropdownProps & {
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onChange: (val: string) => void;
|
||||
onClose?: () => void;
|
||||
projectId: string;
|
||||
value: string;
|
||||
};
|
||||
@ -39,6 +40,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
dropdownArrowClassName = "",
|
||||
hideIcon = false,
|
||||
onChange,
|
||||
onClose,
|
||||
placement,
|
||||
projectId,
|
||||
showTooltip = false,
|
||||
@ -94,7 +96,9 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
if (referenceElement) referenceElement.blur();
|
||||
};
|
||||
|
||||
@ -134,7 +138,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
@ -144,7 +148,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block h-full max-w-full outline-none",
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
|
@ -23,7 +23,7 @@ import { BreadcrumbLink } from "components/common";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui";
|
||||
// icons
|
||||
import { ArrowRight, Plus } from "lucide-react";
|
||||
import { ArrowRight, Plus, PanelRight } from "lucide-react";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
@ -32,6 +32,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { CycleMobileHeader } from "components/cycles/cycle-mobile-header";
|
||||
|
||||
const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => {
|
||||
// router
|
||||
@ -147,117 +149,136 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
cycleDetails={cycleDetails ?? undefined}
|
||||
/>
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarHamburgerToggle />
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
icon={
|
||||
currentProjectDetails?.emoji ? (
|
||||
renderEmoji(currentProjectDetails.emoji)
|
||||
) : currentProjectDetails?.icon_prop ? (
|
||||
renderEmoji(currentProjectDetails.icon_prop)
|
||||
) : (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded bg-gray-700 uppercase text-white">
|
||||
{currentProjectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
<div className="relative z-10 w-full items-center gap-x-2 gap-y-4">
|
||||
<div className="flex justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarHamburgerToggle />
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<span>
|
||||
<span className="hidden md:block">
|
||||
<BreadcrumbLink
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
icon={
|
||||
currentProjectDetails?.emoji ? (
|
||||
renderEmoji(currentProjectDetails.emoji)
|
||||
) : currentProjectDetails?.icon_prop ? (
|
||||
renderEmoji(currentProjectDetails.icon_prop)
|
||||
) : (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded bg-gray-700 uppercase text-white">
|
||||
{currentProjectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<Link href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} className="block md:hidden pl-2 text-custom-text-300">...</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label="Cycles"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles`}
|
||||
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<CustomMenu
|
||||
label={
|
||||
<>
|
||||
<ContrastIcon className="h-3 w-3" />
|
||||
{cycleDetails?.name && truncateText(cycleDetails.name, 40)}
|
||||
</>
|
||||
}
|
||||
className="ml-1.5 flex-shrink-0"
|
||||
placement="bottom-start"
|
||||
>
|
||||
{currentProjectCycleIds?.map((cycleId) => (
|
||||
<CycleDropdownOption key={cycleId} cycleId={cycleId} />
|
||||
))}
|
||||
</CustomMenu>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 ">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label="Cycles"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles`}
|
||||
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<CustomMenu
|
||||
label={
|
||||
<>
|
||||
<ContrastIcon className="h-3 w-3" />
|
||||
{cycleDetails?.name && truncateText(cycleDetails.name, 40)}
|
||||
</>
|
||||
}
|
||||
className="ml-1.5 flex-shrink-0"
|
||||
placement="bottom-start"
|
||||
>
|
||||
{currentProjectCycleIds?.map((cycleId) => (
|
||||
<CycleDropdownOption key={cycleId} cycleId={cycleId} />
|
||||
))}
|
||||
</CustomMenu>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Display" placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Display" placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
|
||||
Analytics
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTrackElement("Cycle issues page");
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE);
|
||||
}}
|
||||
size="sm"
|
||||
prependIcon={<Plus />}
|
||||
>
|
||||
Add Issue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
|
||||
Analytics
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTrackElement("Cycle issues page");
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE);
|
||||
}}
|
||||
size="sm"
|
||||
prependIcon={<Plus />}
|
||||
>
|
||||
Add Issue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<ArrowRight className={`h-4 w-4 duration-300 ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
|
||||
className="grid md:hidden h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<ArrowRight className={`h-4 w-4 duration-300 ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
|
||||
<PanelRight className={cn("w-4 h-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="block sm:block md:hidden">
|
||||
<CycleMobileHeader />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
@ -1,22 +1,24 @@
|
||||
import { FC } from "react";
|
||||
import { FC, useCallback } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Plus } from "lucide-react";
|
||||
import { List, Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
// components
|
||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||
import { BreadcrumbLink } from "components/common";
|
||||
import { TCycleLayout } from "@plane/types";
|
||||
import { CYCLE_VIEW_LAYOUTS } from "constants/cycle";
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
|
||||
export const CyclesHeader: FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
// store hooks
|
||||
const {
|
||||
commandPalette: { toggleCreateCycleModal },
|
||||
@ -30,54 +32,96 @@ export const CyclesHeader: FC = observer(() => {
|
||||
const canUserCreateCycle =
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
|
||||
const { workspaceSlug } = router.query as {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list");
|
||||
|
||||
const handleCurrentLayout = useCallback(
|
||||
(_layout: TCycleLayout) => {
|
||||
setCycleLayout(_layout);
|
||||
},
|
||||
[setCycleLayout]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<SidebarHamburgerToggle />
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
icon={
|
||||
currentProjectDetails?.emoji ? (
|
||||
renderEmoji(currentProjectDetails.emoji)
|
||||
) : currentProjectDetails?.icon_prop ? (
|
||||
renderEmoji(currentProjectDetails.icon_prop)
|
||||
) : (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded bg-gray-700 uppercase text-white">
|
||||
{currentProjectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<div className="relative z-10 items-center justify-between gap-x-2 gap-y-4">
|
||||
<div className="flex border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<SidebarHamburgerToggle />
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
icon={
|
||||
currentProjectDetails?.emoji ? (
|
||||
renderEmoji(currentProjectDetails.emoji)
|
||||
) : currentProjectDetails?.icon_prop ? (
|
||||
renderEmoji(currentProjectDetails.icon_prop)
|
||||
) : (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded bg-gray-700 uppercase text-white">
|
||||
{currentProjectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
{canUserCreateCycle && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
prependIcon={<Plus />}
|
||||
onClick={() => {
|
||||
setTrackElement("Cycles page");
|
||||
toggleCreateCycleModal(true);
|
||||
}}
|
||||
>
|
||||
Add Cycle
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-center sm:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm py-2 border-b border-custom-border-200 bg-custom-sidebar-background-100"
|
||||
// placement="bottom-start"
|
||||
customButton={
|
||||
<span className="flex items-center gap-2">
|
||||
<List className="h-4 w-4" />
|
||||
<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>
|
||||
</span>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{CYCLE_VIEW_LAYOUTS.map((layout) => (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
// handleLayoutChange(ISSUE_LAYOUTS[index].key);
|
||||
handleCurrentLayout(layout.key as TCycleLayout);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<layout.icon className="w-3 h-3" />
|
||||
<div className="text-custom-text-300">{layout.title}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
{canUserCreateCycle && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
prependIcon={<Plus />}
|
||||
onClick={() => {
|
||||
setTrackElement("Cycles page");
|
||||
toggleCreateCycleModal(true);
|
||||
}}
|
||||
>
|
||||
Add Cycle
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -23,7 +23,7 @@ import { BreadcrumbLink } from "components/common";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, CustomMenu, DiceIcon } from "@plane/ui";
|
||||
// icons
|
||||
import { ArrowRight, Plus } from "lucide-react";
|
||||
import { ArrowRight, PanelRight, Plus } from "lucide-react";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
@ -32,6 +32,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
|
||||
// constants
|
||||
import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { ModuleMobileHeader } from "components/modules/module-mobile-header";
|
||||
|
||||
const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => {
|
||||
// router
|
||||
@ -150,116 +152,127 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
moduleDetails={moduleDetails ?? undefined}
|
||||
/>
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarHamburgerToggle />
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
icon={
|
||||
currentProjectDetails?.emoji ? (
|
||||
renderEmoji(currentProjectDetails.emoji)
|
||||
) : currentProjectDetails?.icon_prop ? (
|
||||
renderEmoji(currentProjectDetails.icon_prop)
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{currentProjectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)
|
||||
<div className="relative z-10 items-center gap-x-2 gap-y-4">
|
||||
<div className="flex justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarHamburgerToggle />
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<span>
|
||||
<span className="hidden md:block">
|
||||
<BreadcrumbLink
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
icon={
|
||||
currentProjectDetails?.emoji ? (
|
||||
renderEmoji(currentProjectDetails.emoji)
|
||||
) : currentProjectDetails?.icon_prop ? (
|
||||
renderEmoji(currentProjectDetails.icon_prop)
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{currentProjectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<Link href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} className="block md:hidden pl-2 text-custom-text-300">...</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/modules`}
|
||||
label="Modules"
|
||||
icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<CustomMenu
|
||||
label={
|
||||
<>
|
||||
<DiceIcon className="h-3 w-3" />
|
||||
{moduleDetails?.name && truncateText(moduleDetails.name, 40)}
|
||||
</>
|
||||
}
|
||||
className="ml-1.5 flex-shrink-0"
|
||||
placement="bottom-start"
|
||||
>
|
||||
{projectModuleIds?.map((moduleId) => (
|
||||
<ModuleDropdownOption key={moduleId} moduleId={moduleId} />
|
||||
))}
|
||||
</CustomMenu>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex gap-2">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/modules`}
|
||||
label="Modules"
|
||||
icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<CustomMenu
|
||||
label={
|
||||
<>
|
||||
<DiceIcon className="h-3 w-3" />
|
||||
{moduleDetails?.name && truncateText(moduleDetails.name, 40)}
|
||||
</>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Display" placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
className="ml-1.5 flex-shrink-0"
|
||||
placement="bottom-start"
|
||||
>
|
||||
{projectModuleIds?.map((moduleId) => (
|
||||
<ModuleDropdownOption key={moduleId} moduleId={moduleId} />
|
||||
))}
|
||||
</CustomMenu>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Display" placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
|
||||
Analytics
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTrackElement("Module issues page");
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
|
||||
}}
|
||||
size="sm"
|
||||
prependIcon={<Plus />}
|
||||
>
|
||||
Add Issue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<ArrowRight className={`h-4 w-4 duration-300 ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
|
||||
</button>
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button className="hidden md:block" onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
|
||||
Analytics
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTrackElement("Module issues page");
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
|
||||
}}
|
||||
size="sm"
|
||||
prependIcon={<Plus />}
|
||||
>
|
||||
<span className="hidden md:block">Add</span> Issue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<ArrowRight className={`h-4 w-4 duration-300 hidden md:block ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
|
||||
<PanelRight className={cn("w-4 h-4 block md:hidden", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ModuleMobileHeader />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -103,7 +103,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink label="Inbox Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />
|
||||
<BreadcrumbLink label="Draft Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
@ -2,7 +2,7 @@ import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Briefcase, Circle, ExternalLink, Plus } from "lucide-react";
|
||||
import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react";
|
||||
// hooks
|
||||
import {
|
||||
useApplication,
|
||||
@ -29,6 +29,7 @@ import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } f
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { useIssues } from "hooks/store/use-issues";
|
||||
import { IssuesMobileHeader } from "components/issues/issues-mobile-header";
|
||||
|
||||
export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
// states
|
||||
@ -114,118 +115,109 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
projectDetails={currentProjectDetails ?? undefined}
|
||||
/>
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<SidebarHamburgerToggle />
|
||||
<div className="block md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft fontSize={14} strokeWidth={2} />
|
||||
</button>
|
||||
<div className=" relative z-10 items-center gap-x-2 gap-y-4">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<SidebarHamburgerToggle />
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects`}
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
icon={
|
||||
currentProjectDetails ? (
|
||||
currentProjectDetails?.emoji ? (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
{renderEmoji(currentProjectDetails.emoji)}
|
||||
</span>
|
||||
) : currentProjectDetails?.icon_prop ? (
|
||||
<div className="grid h-7 w-7 flex-shrink-0 place-items-center">
|
||||
{renderEmoji(currentProjectDetails.icon_prop)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{currentProjectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
{currentProjectDetails?.is_deployed && deployUrl && (
|
||||
<a
|
||||
href={`${deployUrl}/${workspaceSlug}/${currentProjectDetails?.id}`}
|
||||
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
|
||||
Public
|
||||
<ExternalLink className="hidden h-3 w-3 group-hover:block" strokeWidth={2} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects`}
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
icon={
|
||||
currentProjectDetails ? (
|
||||
currentProjectDetails?.emoji ? (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
{renderEmoji(currentProjectDetails.emoji)}
|
||||
</span>
|
||||
) : currentProjectDetails?.icon_prop ? (
|
||||
<div className="grid h-7 w-7 flex-shrink-0 place-items-center">
|
||||
{renderEmoji(currentProjectDetails.icon_prop)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{currentProjectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="items-center gap-2 hidden md:flex">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />}
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Display" placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
{currentProjectDetails?.is_deployed && deployUrl && (
|
||||
<a
|
||||
href={`${deployUrl}/${workspaceSlug}/${currentProjectDetails?.id}`}
|
||||
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
|
||||
Public
|
||||
<ExternalLink className="hidden h-3 w-3 group-hover:block" strokeWidth={2} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Display" placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
|
||||
{currentProjectDetails?.inbox_view && inboxDetails && (
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxDetails?.id}`}>
|
||||
<span>
|
||||
<Button variant="neutral-primary" size="sm" className="relative">
|
||||
Inbox
|
||||
{inboxDetails?.pending_issue_count > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 h-4 w-4 rounded-full border border-custom-sidebar-border-200 bg-custom-sidebar-background-80 text-custom-text-100">
|
||||
{inboxDetails?.pending_issue_count}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxDetails?.id}`}>
|
||||
<span>
|
||||
<Button variant="neutral-primary" size="sm" className="relative">
|
||||
Inbox
|
||||
{inboxDetails?.pending_issue_count > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 h-4 w-4 rounded-full border border-custom-sidebar-border-200 bg-custom-sidebar-background-80 text-custom-text-100">
|
||||
{inboxDetails?.pending_issue_count}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
|
||||
<Button className="hidden md:block" onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
|
||||
Analytics
|
||||
</Button>
|
||||
<Button
|
||||
@ -241,6 +233,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="block md:hidden">
|
||||
<IssuesMobileHeader />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -23,7 +23,7 @@ export const ProjectsHeader = observer(() => {
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<div className="flex flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<SidebarHamburgerToggle />
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
@ -34,12 +34,12 @@ export const ProjectsHeader = observer(() => {
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex w-full justify-end items-center gap-3">
|
||||
{workspaceProjectIds && workspaceProjectIds?.length > 0 && (
|
||||
<div className="flex w-full items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5 text-custom-text-400">
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<div className=" flex items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5 text-custom-text-400">
|
||||
<Search className="h-3.5" />
|
||||
<input
|
||||
className="w-full min-w-[234px] border-none bg-transparent text-sm focus:outline-none"
|
||||
className="border-none w-full bg-transparent text-sm focus:outline-none"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
@ -54,6 +54,7 @@ export const ProjectsHeader = observer(() => {
|
||||
setTrackElement("Projects page");
|
||||
commandPaletteStore.toggleCreateProjectModal(true);
|
||||
}}
|
||||
className="items-center"
|
||||
>
|
||||
Add Project
|
||||
</Button>
|
||||
|
@ -16,15 +16,6 @@ export const WorkspaceAnalyticsHeader = () => {
|
||||
>
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<SidebarHamburgerToggle />
|
||||
<div className="block md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft fontSize={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { LayoutGrid, Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
@ -6,19 +5,17 @@ import { useTheme } from "next-themes";
|
||||
import githubBlackImage from "/public/logos/github-black.png";
|
||||
import githubWhiteImage from "/public/logos/github-white.png";
|
||||
// components
|
||||
import { BreadcrumbLink, ProductUpdatesModal } from "components/common";
|
||||
import { BreadcrumbLink } from "components/common";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||
|
||||
export const WorkspaceDashboardHeader = () => {
|
||||
const [isProductUpdatesModalOpen, setIsProductUpdatesModalOpen] = useState(false);
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProductUpdatesModal isOpen={isProductUpdatesModalOpen} setIsOpen={setIsProductUpdatesModalOpen} />
|
||||
<div className="relative z-20 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="relative z-[15] flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<SidebarHamburgerToggle />
|
||||
<div>
|
||||
@ -37,13 +34,13 @@ export const WorkspaceDashboardHeader = () => {
|
||||
href="https://plane.so/changelog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 text-xs font-medium"
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5"
|
||||
>
|
||||
<Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" />
|
||||
{"What's new?"}
|
||||
<span className="text-xs hidden sm:hidden md:block font-medium">{"What's new?"}</span>
|
||||
</a>
|
||||
<a
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 text-xs font-medium"
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 "
|
||||
href="https://github.com/makeplane/plane"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@ -54,7 +51,7 @@ export const WorkspaceDashboardHeader = () => {
|
||||
width={16}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
Star us on GitHub
|
||||
<span className="text-xs font-medium hidden sm:hidden md:block">Star us on GitHub</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -13,10 +13,11 @@ type Props = {
|
||||
placement?: Placement;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
menuButton?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const FiltersDropdown: React.FC<Props> = (props) => {
|
||||
const { children, title = "Dropdown", placement, disabled = false, tabIndex } = props;
|
||||
const { children, title = "Dropdown", placement, disabled = false, tabIndex, menuButton } = props;
|
||||
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
@ -33,7 +34,9 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Popover.Button as={React.Fragment}>
|
||||
<Button
|
||||
{menuButton ? <button role="button" ref={setReferenceElement}>
|
||||
{menuButton}
|
||||
</button> : <Button
|
||||
disabled={disabled}
|
||||
ref={setReferenceElement}
|
||||
variant="neutral-primary"
|
||||
@ -46,7 +49,7 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
|
||||
<div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Button>}
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
|
@ -66,16 +66,22 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
{issue?.is_draft ? (
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
</ControlLink>
|
||||
) : (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
</ControlLink>
|
||||
)}
|
||||
|
||||
<IssueProperties
|
||||
className="flex flex-wrap items-center gap-2 whitespace-nowrap"
|
||||
|
@ -79,21 +79,14 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDraftIssue ? (
|
||||
<CreateUpdateDraftIssueModal
|
||||
isOpen={isOpen}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
prePopulateData={issuePayload}
|
||||
fieldsToShow={["all"]}
|
||||
/>
|
||||
) : (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
data={issuePayload}
|
||||
storeType={storeType}
|
||||
/>
|
||||
)}
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
data={issuePayload}
|
||||
storeType={storeType}
|
||||
isDraft={isDraftIssue}
|
||||
/>
|
||||
|
||||
{renderExistingIssueModal && (
|
||||
<ExistingIssuesListModal
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
|
@ -51,10 +51,11 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm border border-transparent border-b-custom-border-200 last:border-b-transparent",
|
||||
"relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm border border-transparent border-b-custom-border-200",
|
||||
{
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue && peekIssue.issueId === issue.id,
|
||||
"last:border-b-transparent": peekIssue?.issueId !== issue.id,
|
||||
}
|
||||
)}
|
||||
>
|
||||
@ -68,16 +69,22 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
||||
)}
|
||||
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
{issue?.is_draft ? (
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
</ControlLink>
|
||||
) : (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
</ControlLink>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
||||
{!issue?.tempId ? (
|
||||
|
@ -109,21 +109,13 @@ export const HeaderGroupByCard = observer(
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isDraftIssue ? (
|
||||
<CreateUpdateDraftIssueModal
|
||||
isOpen={isOpen}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
prePopulateData={issuePayload}
|
||||
fieldsToShow={["all"]}
|
||||
/>
|
||||
) : (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
data={issuePayload}
|
||||
storeType={storeType}
|
||||
/>
|
||||
)}
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
data={issuePayload}
|
||||
storeType={storeType}
|
||||
isDraft={isDraftIssue}
|
||||
/>
|
||||
|
||||
{renderExistingIssueModal && (
|
||||
<ExistingIssuesListModal
|
||||
|
@ -4,6 +4,7 @@ import { usePopper } from "react-popper";
|
||||
import { Check, ChevronDown, Search, Tags } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useLabel } from "hooks/store";
|
||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||
// components
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
@ -25,6 +26,7 @@ export interface IIssuePropertyLabels {
|
||||
maxRender?: number;
|
||||
noLabelBorder?: boolean;
|
||||
placeholderText?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((props) => {
|
||||
@ -33,6 +35,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
||||
value,
|
||||
defaultOptions = [],
|
||||
onChange,
|
||||
onClose,
|
||||
disabled,
|
||||
hideDropdownArrow = false,
|
||||
className,
|
||||
@ -64,6 +67,12 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(openDropDown, handleClose, false);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
@ -171,13 +180,14 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
multiple
|
||||
>
|
||||
<Combobox.Button as={Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-1 text-xs ${
|
||||
className={`clickable flex w-full items-center justify-between gap-1 text-xs ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: value.length <= maxRender
|
||||
@ -205,7 +215,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
displayValue={(assigned: any) => assigned?.name || ""}
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}>
|
||||
@ -216,10 +226,10 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ selected }) =>
|
||||
className={({ active, selected }) =>
|
||||
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${
|
||||
selected ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
|
@ -54,6 +54,8 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
|
||||
};
|
||||
delete duplicateIssuePayload.id;
|
||||
|
||||
const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteIssueModal
|
||||
@ -62,6 +64,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
onSubmit={handleDelete}
|
||||
/>
|
||||
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={createUpdateIssueModal}
|
||||
onClose={() => {
|
||||
@ -73,7 +76,9 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
|
||||
if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data });
|
||||
}}
|
||||
storeType={EIssuesStoreType.PROJECT}
|
||||
isDraft={isDraftIssue}
|
||||
/>
|
||||
|
||||
<CustomMenu
|
||||
placement="bottom-start"
|
||||
customButton={customActionButton}
|
||||
|
@ -8,6 +8,7 @@ import { useIssues } from "hooks/store";
|
||||
import { DraftIssueAppliedFiltersRoot } from "../filters/applied-filters/roots/draft-issue";
|
||||
import { DraftIssueListLayout } from "../list/roots/draft-issue-root";
|
||||
import { ProjectDraftEmptyState } from "../empty-states";
|
||||
import { IssuePeekOverview } from "components/issues/peek-overview";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
import { DraftKanBanLayout } from "../kanban/roots/draft-issue-root";
|
||||
@ -57,6 +58,8 @@ export const DraftIssueLayoutRoot: React.FC = observer(() => {
|
||||
) : activeLayout === "kanban" ? (
|
||||
<DraftKanBanLayout />
|
||||
) : null}
|
||||
{/* issue peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
@ -7,12 +7,13 @@ import { TIssue } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
onClose: () => void;
|
||||
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const SpreadsheetAssigneeColumn: React.FC<Props> = observer((props: Props) => {
|
||||
const { issue, onChange, disabled } = props;
|
||||
const { issue, onChange, disabled, onClose } = props;
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
@ -37,6 +38,7 @@ export const SpreadsheetAssigneeColumn: React.FC<Props> = observer((props: Props
|
||||
}
|
||||
buttonClassName="text-left"
|
||||
buttonContainerClassName="w-full"
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
onClose: () => void;
|
||||
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props) => {
|
||||
const { issue, onChange, disabled } = props;
|
||||
const { issue, onChange, disabled, onClose } = props;
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
@ -36,6 +37,7 @@ export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props)
|
||||
buttonVariant="transparent-with-text"
|
||||
buttonClassName="rounded-none text-left"
|
||||
buttonContainerClassName="w-full"
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -6,12 +6,13 @@ import { TIssue } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
onClose: () => void;
|
||||
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const SpreadsheetEstimateColumn: React.FC<Props> = observer((props: Props) => {
|
||||
const { issue, onChange, disabled } = props;
|
||||
const { issue, onChange, disabled, onClose } = props;
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
@ -25,6 +26,7 @@ export const SpreadsheetEstimateColumn: React.FC<Props> = observer((props: Props
|
||||
buttonVariant="transparent-with-text"
|
||||
buttonClassName="rounded-none text-left"
|
||||
buttonContainerClassName="w-full"
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -20,10 +20,11 @@ interface Props {
|
||||
property: keyof IIssueDisplayProperties;
|
||||
displayFilters: IIssueDisplayFilterOptions;
|
||||
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SpreadsheetHeaderColumn = (props: Props) => {
|
||||
const { displayFilters, handleDisplayFilterUpdate, property } = props;
|
||||
export const HeaderColumn = (props: Props) => {
|
||||
const { displayFilters, handleDisplayFilterUpdate, property, onClose } = props;
|
||||
|
||||
const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage(
|
||||
"spreadsheetViewSorting",
|
||||
@ -44,7 +45,8 @@ export const SpreadsheetHeaderColumn = (props: Props) => {
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
customButtonClassName="!w-full"
|
||||
customButtonClassName="clickable !w-full"
|
||||
customButtonTabIndex={-1}
|
||||
className="!w-full"
|
||||
customButton={
|
||||
<div className="flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm text-custom-text-200 hover:text-custom-text-100">
|
||||
@ -62,6 +64,7 @@ export const SpreadsheetHeaderColumn = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onMenuClose={onClose}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>
|
||||
|
@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
onClose: () => void;
|
||||
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const SpreadsheetLabelColumn: React.FC<Props> = observer((props: Props) => {
|
||||
const { issue, onChange, disabled } = props;
|
||||
const { issue, onChange, disabled, onClose } = props;
|
||||
// hooks
|
||||
const { labelMap } = useLabel();
|
||||
|
||||
@ -25,13 +26,14 @@ export const SpreadsheetLabelColumn: React.FC<Props> = observer((props: Props) =
|
||||
projectId={issue.project_id ?? null}
|
||||
value={issue.label_ids}
|
||||
defaultOptions={defaultLabelOptions}
|
||||
onChange={(data) => onChange(issue, { label_ids: data },{ changed_property: "labels", change_details: data })}
|
||||
onChange={(data) => onChange(issue, { label_ids: data }, { changed_property: "labels", change_details: data })}
|
||||
className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80"
|
||||
buttonClassName="px-2.5 h-full"
|
||||
hideDropdownArrow
|
||||
maxRender={1}
|
||||
disabled={disabled}
|
||||
placeholderText="Select labels"
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -7,22 +7,24 @@ import { TIssue } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
onClose: () => void;
|
||||
onChange: (issue: TIssue, data: Partial<TIssue>,updates:any) => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const SpreadsheetPriorityColumn: React.FC<Props> = observer((props: Props) => {
|
||||
const { issue, onChange, disabled } = props;
|
||||
const { issue, onChange, disabled, onClose } = props;
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
<PriorityDropdown
|
||||
value={issue.priority}
|
||||
onChange={(data) => onChange(issue, { priority: data },{changed_property:"priority",change_details:data})}
|
||||
onChange={(data) => onChange(issue, { priority: data }, { changed_property: "priority", change_details: data })}
|
||||
disabled={disabled}
|
||||
buttonVariant="transparent-with-text"
|
||||
buttonClassName="rounded-none text-left"
|
||||
buttonContainerClassName="w-full"
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
onClose: () => void;
|
||||
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const SpreadsheetStartDateColumn: React.FC<Props> = observer((props: Props) => {
|
||||
const { issue, onChange, disabled } = props;
|
||||
const { issue, onChange, disabled, onClose } = props;
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
@ -36,6 +37,7 @@ export const SpreadsheetStartDateColumn: React.FC<Props> = observer((props: Prop
|
||||
buttonVariant="transparent-with-text"
|
||||
buttonClassName="rounded-none text-left"
|
||||
buttonContainerClassName="w-full"
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -7,12 +7,13 @@ import { TIssue } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
onClose: () => void;
|
||||
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const SpreadsheetStateColumn: React.FC<Props> = observer((props) => {
|
||||
const { issue, onChange, disabled } = props;
|
||||
const { issue, onChange, disabled, onClose } = props;
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
@ -24,6 +25,7 @@ export const SpreadsheetStateColumn: React.FC<Props> = observer((props) => {
|
||||
buttonVariant="transparent-with-text"
|
||||
buttonClassName="rounded-none text-left"
|
||||
buttonContainerClassName="w-full"
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -0,0 +1,68 @@
|
||||
import { useRef } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
// types
|
||||
import { IIssueDisplayProperties, TIssue } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
// constants
|
||||
import { SPREADSHEET_PROPERTY_DETAILS } from "constants/spreadsheet";
|
||||
// components
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
import { useEventTracker } from "hooks/store";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
type Props = {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
issueDetail: TIssue;
|
||||
disableUserActions: boolean;
|
||||
property: keyof IIssueDisplayProperties;
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
|
||||
isEstimateEnabled: boolean;
|
||||
};
|
||||
|
||||
export const IssueColumn = observer((props: Props) => {
|
||||
const { displayProperties, issueDetail, disableUserActions, property, handleIssues, isEstimateEnabled } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const tableCellRef = useRef<HTMLTableCellElement | null>(null);
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
|
||||
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
|
||||
|
||||
const { Column } = SPREADSHEET_PROPERTY_DETAILS[property];
|
||||
|
||||
return (
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey={property}
|
||||
shouldRenderProperty={shouldRenderProperty}
|
||||
>
|
||||
<td
|
||||
tabIndex={0}
|
||||
className="h-11 w-full min-w-[8rem] bg-custom-background-100 text-sm after:absolute after:w-full after:bottom-[-1px] after:border after:border-custom-border-100 border-r-[1px] border-custom-border-100 focus:border-custom-primary-70"
|
||||
ref={tableCellRef}
|
||||
>
|
||||
<Column
|
||||
issue={issueDetail}
|
||||
onChange={(issue: TIssue, data: Partial<TIssue>, updates: any) =>
|
||||
handleIssues({ ...issue, ...data }, EIssueActions.UPDATE).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
payload: {
|
||||
...issue,
|
||||
...data,
|
||||
element: "Spreadsheet layout",
|
||||
},
|
||||
updates: updates,
|
||||
path: router.asPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
disabled={disableUserActions}
|
||||
onClose={() => {
|
||||
tableCellRef?.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</WithDisplayPropertiesHOC>
|
||||
);
|
||||
});
|
@ -4,14 +4,15 @@ import { observer } from "mobx-react-lite";
|
||||
// icons
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
// constants
|
||||
import { SPREADSHEET_PROPERTY_DETAILS, SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
|
||||
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
|
||||
// components
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
import { IssueColumn } from "./issue-column";
|
||||
// ui
|
||||
import { ControlLink, Tooltip } from "@plane/ui";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
import { useEventTracker, useIssueDetail, useProject } from "hooks/store";
|
||||
import { useIssueDetail, useProject } from "hooks/store";
|
||||
// helper
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
@ -51,7 +52,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
//hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
const [isExpanded, setExpanded] = useState<boolean>(false);
|
||||
@ -106,11 +106,12 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
{/* first column/ issue name and key column */}
|
||||
<td
|
||||
className={cn(
|
||||
"sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] border-custom-border-200",
|
||||
"sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] border-custom-border-200 focus:border-custom-primary-70",
|
||||
{
|
||||
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id,
|
||||
}
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
|
||||
<div
|
||||
@ -149,11 +150,14 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issueDetail)}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<div className="w-full overflow-hidden">
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}>
|
||||
<div className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100">
|
||||
<div
|
||||
className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{issueDetail.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -161,40 +165,16 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
</ControlLink>
|
||||
</td>
|
||||
{/* Rest of the columns */}
|
||||
{SPREADSHEET_PROPERTY_LIST.map((property) => {
|
||||
const { Column } = SPREADSHEET_PROPERTY_DETAILS[property];
|
||||
|
||||
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
|
||||
|
||||
return (
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey={property}
|
||||
shouldRenderProperty={shouldRenderProperty}
|
||||
>
|
||||
<td className="h-11 w-full min-w-[8rem] bg-custom-background-100 text-sm after:absolute after:w-full after:bottom-[-1px] after:border after:border-custom-border-100 border-r-[1px] border-custom-border-100">
|
||||
<Column
|
||||
issue={issueDetail}
|
||||
onChange={(issue: TIssue, data: Partial<TIssue>, updates: any) =>
|
||||
handleIssues({ ...issue, ...data }, EIssueActions.UPDATE).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
payload: {
|
||||
...issue,
|
||||
...data,
|
||||
element: "Spreadsheet layout",
|
||||
},
|
||||
updates: updates,
|
||||
path: router.asPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
disabled={disableUserActions}
|
||||
/>
|
||||
</td>
|
||||
</WithDisplayPropertiesHOC>
|
||||
);
|
||||
})}
|
||||
{SPREADSHEET_PROPERTY_LIST.map((property) => (
|
||||
<IssueColumn
|
||||
displayProperties={displayProperties}
|
||||
issueDetail={issueDetail}
|
||||
disableUserActions={disableUserActions}
|
||||
property={property}
|
||||
handleIssues={handleIssues}
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{isExpanded &&
|
||||
|
@ -0,0 +1,46 @@
|
||||
import { useRef } from "react";
|
||||
//types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
//components
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
import { HeaderColumn } from "./columns/header-column";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
interface Props {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
property: keyof IIssueDisplayProperties;
|
||||
isEstimateEnabled: boolean;
|
||||
displayFilters: IIssueDisplayFilterOptions;
|
||||
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
}
|
||||
export const SpreadsheetHeaderColumn = observer((props: Props) => {
|
||||
const { displayProperties, displayFilters, property, isEstimateEnabled, handleDisplayFilterUpdate } = props;
|
||||
|
||||
//hooks
|
||||
const tableHeaderCellRef = useRef<HTMLTableCellElement | null>(null);
|
||||
|
||||
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
|
||||
|
||||
return (
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey={property}
|
||||
shouldRenderProperty={shouldRenderProperty}
|
||||
>
|
||||
<th
|
||||
className="h-11 w-full min-w-[8rem] items-center bg-custom-background-90 text-sm font-medium px-4 py-1 border border-b-0 border-t-0 border-custom-border-100 focus:border-custom-primary-70"
|
||||
ref={tableHeaderCellRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
<HeaderColumn
|
||||
displayFilters={displayFilters}
|
||||
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
|
||||
property={property}
|
||||
onClose={() => {
|
||||
tableHeaderCellRef?.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
</WithDisplayPropertiesHOC>
|
||||
);
|
||||
});
|
@ -6,8 +6,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/type
|
||||
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
|
||||
// components
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
import { SpreadsheetHeaderColumn } from "./columns/header-column";
|
||||
|
||||
import { SpreadsheetHeaderColumn } from "./spreadsheet-header-column";
|
||||
|
||||
interface Props {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
@ -22,7 +21,10 @@ export const SpreadsheetHeader = (props: Props) => {
|
||||
return (
|
||||
<thead className="sticky top-0 left-0 z-[1] border-b-[0.5px] border-custom-border-100">
|
||||
<tr>
|
||||
<th className="sticky left-0 z-[1] h-11 w-[28rem] flex items-center bg-custom-background-90 text-sm font-medium before:absolute before:h-full before:right-0 before:border-[0.5px] before:border-custom-border-100">
|
||||
<th
|
||||
className="sticky left-0 z-[1] h-11 w-[28rem] flex items-center bg-custom-background-90 text-sm font-medium before:absolute before:h-full before:right-0 before:border-[0.5px] before:border-custom-border-100"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
|
||||
<span className="flex h-full w-24 flex-shrink-0 items-center px-4 py-2.5">
|
||||
<span className="mr-1.5 text-custom-text-400">#</span>ID
|
||||
@ -34,25 +36,15 @@ export const SpreadsheetHeader = (props: Props) => {
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{SPREADSHEET_PROPERTY_LIST.map((property) => {
|
||||
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
|
||||
|
||||
return (
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey={property}
|
||||
shouldRenderProperty={shouldRenderProperty}
|
||||
>
|
||||
<th className="h-11 w-full min-w-[8rem] items-center bg-custom-background-90 text-sm font-medium px-4 py-1 border border-b-0 border-t-0 border-custom-border-100">
|
||||
<SpreadsheetHeaderColumn
|
||||
displayFilters={displayFilters}
|
||||
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
|
||||
property={property}
|
||||
/>
|
||||
</th>
|
||||
</WithDisplayPropertiesHOC>
|
||||
);
|
||||
})}
|
||||
{SPREADSHEET_PROPERTY_LIST.map((property) => (
|
||||
<SpreadsheetHeaderColumn
|
||||
property={property}
|
||||
displayProperties={displayProperties}
|
||||
displayFilters={displayFilters}
|
||||
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
|
@ -5,6 +5,7 @@ import { EIssueActions } from "../types";
|
||||
//components
|
||||
import { SpreadsheetIssueRow } from "./issue-row";
|
||||
import { SpreadsheetHeader } from "./spreadsheet-header";
|
||||
import { useTableKeyboardNavigation } from "hooks/use-table-keyboard-navigation";
|
||||
|
||||
type Props = {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
@ -35,8 +36,10 @@ export const SpreadsheetTable = observer((props: Props) => {
|
||||
canEditProperties,
|
||||
} = props;
|
||||
|
||||
const handleKeyBoardNavigation = useTableKeyboardNavigation();
|
||||
|
||||
return (
|
||||
<table className="overflow-y-auto">
|
||||
<table className="overflow-y-auto" onKeyDown={handleKeyBoardNavigation}>
|
||||
<SpreadsheetHeader
|
||||
displayProperties={displayProperties}
|
||||
displayFilters={displayFilters}
|
||||
|
@ -21,6 +21,7 @@ export interface DraftIssueProps {
|
||||
onClose: (saveDraftIssueInLocalStorage?: boolean) => void;
|
||||
onSubmit: (formData: Partial<TIssue>) => Promise<void>;
|
||||
projectId: string;
|
||||
isDraft: boolean;
|
||||
}
|
||||
|
||||
const issueDraftService = new IssueDraftService();
|
||||
@ -35,6 +36,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
|
||||
projectId,
|
||||
isCreateMoreToggleEnabled,
|
||||
onCreateMoreToggleChange,
|
||||
isDraft,
|
||||
} = props;
|
||||
// states
|
||||
const [issueDiscardModal, setIssueDiscardModal] = useState(false);
|
||||
@ -107,6 +109,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
|
||||
onClose={handleClose}
|
||||
onSubmit={onSubmit}
|
||||
projectId={projectId}
|
||||
isDraft={isDraft}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { FC, useState, useRef, useEffect } from "react";
|
||||
import React, { FC, useState, useRef, useEffect, Fragment } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
@ -55,8 +55,9 @@ export interface IssueFormProps {
|
||||
onCreateMoreToggleChange: (value: boolean) => void;
|
||||
onChange?: (formData: Partial<TIssue> | null) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: (values: Partial<TIssue>) => Promise<void>;
|
||||
onSubmit: (values: Partial<TIssue>, is_draft_issue?: boolean) => Promise<void>;
|
||||
projectId: string;
|
||||
isDraft: boolean;
|
||||
}
|
||||
|
||||
// services
|
||||
@ -72,6 +73,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
projectId: defaultProjectId,
|
||||
isCreateMoreToggleEnabled,
|
||||
onCreateMoreToggleChange,
|
||||
isDraft,
|
||||
} = props;
|
||||
// states
|
||||
const [labelModal, setLabelModal] = useState(false);
|
||||
@ -137,8 +139,8 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
|
||||
const issueName = watch("name");
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<TIssue>) => {
|
||||
await onSubmit(formData);
|
||||
const handleFormSubmit = async (formData: Partial<TIssue>, is_draft_issue = false) => {
|
||||
await onSubmit(formData, is_draft_issue);
|
||||
|
||||
setGptAssistantModal(false);
|
||||
|
||||
@ -248,7 +250,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<form>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{/* Don't show project selection if editing an issue */}
|
||||
@ -670,7 +672,40 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={17}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="sm" loading={isSubmitting} tabIndex={18}>
|
||||
|
||||
{isDraft && (
|
||||
<Fragment>
|
||||
{data?.id ? (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))}
|
||||
tabIndex={18}
|
||||
>
|
||||
{isSubmitting ? "Moving" : "Move from draft"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((data) => handleFormSubmit(data, true))}
|
||||
tabIndex={18}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save as draft"}
|
||||
</Button>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
tabIndex={isDraft ? 19 : 18}
|
||||
onClick={handleSubmit((data) => handleFormSubmit(data))}
|
||||
>
|
||||
{data?.id ? (isSubmitting ? "Updating" : "Update issue") : isSubmitting ? "Creating" : "Create issue"}
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -20,10 +20,19 @@ export interface IssuesModalProps {
|
||||
onSubmit?: (res: TIssue) => Promise<void>;
|
||||
withDraftIssueWrapper?: boolean;
|
||||
storeType?: TCreateModalStoreTypes;
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => {
|
||||
const { data, isOpen, onClose, onSubmit, withDraftIssueWrapper = true, storeType = EIssuesStoreType.PROJECT } = props;
|
||||
const {
|
||||
data,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
withDraftIssueWrapper = true,
|
||||
storeType = EIssuesStoreType.PROJECT,
|
||||
isDraft = false,
|
||||
} = props;
|
||||
// states
|
||||
const [changesMade, setChangesMade] = useState<Partial<TIssue> | null>(null);
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
@ -42,6 +51,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
const { issues: cycleIssues } = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { issues: viewIssues } = useIssues(EIssuesStoreType.PROJECT_VIEW);
|
||||
const { issues: profileIssues } = useIssues(EIssuesStoreType.PROFILE);
|
||||
const { issues: draftIssueStore } = useIssues(EIssuesStoreType.DRAFT);
|
||||
// store mapping based on current store
|
||||
const issueStores = {
|
||||
[EIssuesStoreType.PROJECT]: {
|
||||
@ -122,11 +132,16 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateIssue = async (payload: Partial<TIssue>): Promise<TIssue | undefined> => {
|
||||
const handleCreateIssue = async (
|
||||
payload: Partial<TIssue>,
|
||||
is_draft_issue: boolean = false
|
||||
): Promise<TIssue | undefined> => {
|
||||
if (!workspaceSlug || !payload.project_id) return;
|
||||
|
||||
try {
|
||||
const response = await currentIssueStore.createIssue(workspaceSlug, payload.project_id, payload, viewId);
|
||||
const response = is_draft_issue
|
||||
? await draftIssueStore.createIssue(workspaceSlug, payload.project_id, payload)
|
||||
: await currentIssueStore.createIssue(workspaceSlug, payload.project_id, payload, viewId);
|
||||
if (!response) throw new Error();
|
||||
|
||||
currentIssueStore.fetchIssues(workspaceSlug, payload.project_id, "mutation", viewId);
|
||||
@ -213,7 +228,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<TIssue>) => {
|
||||
const handleFormSubmit = async (formData: Partial<TIssue>, is_draft_issue: boolean = false) => {
|
||||
if (!workspaceSlug || !formData.project_id || !storeType) return;
|
||||
|
||||
const payload: Partial<TIssue> = {
|
||||
@ -222,7 +237,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
};
|
||||
|
||||
let response: TIssue | undefined = undefined;
|
||||
if (!data?.id) response = await handleCreateIssue(payload);
|
||||
if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue);
|
||||
else response = await handleUpdateIssue(payload);
|
||||
|
||||
if (response != undefined && onSubmit) await onSubmit(response);
|
||||
@ -274,6 +289,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
projectId={activeProjectId}
|
||||
isCreateMoreToggleEnabled={createMore}
|
||||
onCreateMoreToggleChange={handleCreateMoreToggleChange}
|
||||
isDraft={isDraft}
|
||||
/>
|
||||
) : (
|
||||
<IssueFormRoot
|
||||
@ -287,6 +303,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
onCreateMoreToggleChange={handleCreateMoreToggleChange}
|
||||
onSubmit={handleFormSubmit}
|
||||
projectId={activeProjectId}
|
||||
isDraft={isDraft}
|
||||
/>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
|
166
web/components/issues/issues-mobile-header.tsx
Normal file
166
web/components/issues/issues-mobile-header.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import router from "next/router";
|
||||
// components
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// icons
|
||||
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue";
|
||||
// hooks
|
||||
import { useIssues, useLabel, useMember, useProject, useProjectState } from "hooks/store";
|
||||
// layouts
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "./issue-layouts";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
import { ProjectAnalyticsModal } from "components/analytics";
|
||||
|
||||
export const IssuesMobileHeader = () => {
|
||||
const layouts = [
|
||||
{ key: "list", title: "List", icon: List },
|
||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||
];
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
const { workspaceSlug, projectId } = router.query as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[workspaceSlug, projectId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property);
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
projectDetails={currentProjectDetails ?? undefined}
|
||||
/>
|
||||
<div className="flex justify-evenly py-2 border-b border-custom-border-200">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
placement="bottom-start"
|
||||
customButton={<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{layouts.map((layout, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleLayoutChange(ISSUE_LAYOUTS[index].key);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<layout.icon className="w-3 h-3" />
|
||||
<div className="text-custom-text-300">{layout.title}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-custom-text-200 text-sm">
|
||||
Filters
|
||||
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title="Display"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-custom-text-200 text-sm">
|
||||
Display
|
||||
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setAnalyticsModal(true)}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200"
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
162
web/components/modules/module-mobile-header.tsx
Normal file
162
web/components/modules/module-mobile-header.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { ProjectAnalyticsModal } from "components/analytics";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues";
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue";
|
||||
import { useIssues, useLabel, useMember, useModule, useProjectState } from "hooks/store";
|
||||
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
import router from "next/router";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export const ModuleMobileHeader = () => {
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
const { getModuleById } = useModule();
|
||||
const layouts = [
|
||||
{ key: "list", title: "List", icon: List },
|
||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||
];
|
||||
const { workspaceSlug, projectId, moduleId } = router.query as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
moduleId: string;
|
||||
};
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.MODULE);
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, moduleId);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, moduleId);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, moduleId);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, updateFilters]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="block md:hidden">
|
||||
<ProjectAnalyticsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
moduleDetails={moduleDetails ?? undefined}
|
||||
/>
|
||||
<div className="flex justify-evenly py-2 border-b border-custom-border-200">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
placement="bottom-start"
|
||||
customButton={<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{layouts.map((layout, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleLayoutChange(ISSUE_LAYOUTS[index].key);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<layout.icon className="w-3 h-3" />
|
||||
<div className="text-custom-text-300">{layout.title}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-custom-text-200 text-sm">
|
||||
Filters
|
||||
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title="Display"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-custom-text-200 text-sm">
|
||||
Display
|
||||
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setAnalyticsModal(true)}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200"
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -208,7 +208,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
@ -81,7 +81,7 @@ export const CreateUpdateProjectViewModal: FC<Props> = observer((props) => {
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
@ -19,7 +19,7 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
|
||||
const isDarkMode = currentUser?.theme.theme === "dark";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10 p-8 rounded-xl">
|
||||
<div className="flex flex-col gap-10 pt-8 px-8 rounded-xl h-full">
|
||||
<div
|
||||
className={cn("flex item-center justify-between rounded-xl min-h-[25rem]", {
|
||||
"bg-gradient-to-l from-[#CFCFCF] to-[#212121]": currentUser?.theme.theme === "dark",
|
||||
@ -43,17 +43,6 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
|
||||
<Crown className="h-3.5 w-3.5" />
|
||||
Upgrade
|
||||
</a>
|
||||
<a
|
||||
className={cn("text-sm underline", {
|
||||
"text-white": currentUser?.theme.theme === "dark",
|
||||
"text-blue-600": currentUser?.theme.theme === "light",
|
||||
})}
|
||||
href="https://plane.so/pricing"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Talk custom pricing
|
||||
</a>
|
||||
</div>
|
||||
<span className="absolute left-0 top-0">
|
||||
<Image
|
||||
@ -84,7 +73,7 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-5 pb-8 h-full">
|
||||
{WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => (
|
||||
<div className="flex flex-col gap-2 p-4 min-h-32 w-full bg-custom-background-80 rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
|
@ -28,6 +28,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: {
|
||||
icon: FC<ISvgIcons>;
|
||||
Column: React.FC<{
|
||||
issue: TIssue;
|
||||
onClose: () => void;
|
||||
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
|
||||
disabled: boolean;
|
||||
}>;
|
||||
|
@ -1,23 +1,31 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
type TUseDropdownKeyDown = {
|
||||
(onEnterKeyDown: () => void, onEscKeyDown: () => void): (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||
(onEnterKeyDown: () => void, onEscKeyDown: () => void, stopPropagation?: boolean): (
|
||||
event: React.KeyboardEvent<HTMLElement>
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown) => {
|
||||
export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => {
|
||||
const stopEventPropagation = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (stopPropagation) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
stopEventPropagation(event);
|
||||
|
||||
onEnterKeyDown();
|
||||
} else if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
stopEventPropagation(event);
|
||||
onEscKeyDown();
|
||||
}
|
||||
},
|
||||
[onEnterKeyDown, onEscKeyDown]
|
||||
[onEnterKeyDown, onEscKeyDown, stopEventPropagation]
|
||||
);
|
||||
|
||||
return handleKeyDown;
|
||||
|
@ -1,26 +1,41 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const useReloadConfirmations = (message?: string) => {
|
||||
//TODO: remove temp flag isActive later and use showAlert as the source of truth
|
||||
const useReloadConfirmations = (isActive = true) => {
|
||||
const [showAlert, setShowAlert] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleBeforeUnload = useCallback(
|
||||
(event: BeforeUnloadEvent) => {
|
||||
if (!isActive || !showAlert) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
return message ?? "Are you sure you want to leave?";
|
||||
},
|
||||
[message]
|
||||
[isActive, showAlert]
|
||||
);
|
||||
|
||||
const handleRouteChangeStart = useCallback(
|
||||
(url: string) => {
|
||||
if (!isActive || !showAlert) return;
|
||||
const leave = confirm("Are you sure you want to leave? Changes you made may not be saved.");
|
||||
if (!leave) {
|
||||
router.events.emit("routeChangeError");
|
||||
throw `Route change to "${url}" was aborted (this error can be safely ignored).`;
|
||||
}
|
||||
},
|
||||
[isActive, showAlert, router.events]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAlert) {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [handleBeforeUnload, showAlert]);
|
||||
router.events.on("routeChangeStart", handleRouteChangeStart);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
router.events.off("routeChangeStart", handleRouteChangeStart);
|
||||
};
|
||||
}, [handleBeforeUnload, handleRouteChangeStart, router.events]);
|
||||
|
||||
return { setShowAlert };
|
||||
};
|
||||
|
56
web/hooks/use-table-keyboard-navigation.tsx
Normal file
56
web/hooks/use-table-keyboard-navigation.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
export const useTableKeyboardNavigation = () => {
|
||||
const getPreviousRow = (element: HTMLElement) => {
|
||||
const previousRow = element.closest("tr")?.previousSibling;
|
||||
|
||||
if (previousRow) return previousRow;
|
||||
//if previous row does not exist in the parent check the row with the header of the table
|
||||
return element.closest("tbody")?.previousSibling?.childNodes?.[0];
|
||||
};
|
||||
|
||||
const getNextRow = (element: HTMLElement) => {
|
||||
const nextRow = element.closest("tr")?.nextSibling;
|
||||
|
||||
if (nextRow) return nextRow;
|
||||
//if next row does not exist in the parent check the row with the body of the table
|
||||
return element.closest("thead")?.nextSibling?.childNodes?.[0];
|
||||
};
|
||||
|
||||
const handleKeyBoardNavigation = function (e: React.KeyboardEvent<HTMLTableElement>) {
|
||||
const element = e.target as HTMLElement;
|
||||
|
||||
if (!(element?.tagName === "TD" || element?.tagName === "TH")) return;
|
||||
|
||||
let c: HTMLElement | null = null;
|
||||
if (e.key == "ArrowRight") {
|
||||
// Right Arrow
|
||||
c = element.nextSibling as HTMLElement;
|
||||
} else if (e.key == "ArrowLeft") {
|
||||
// Left Arrow
|
||||
c = element.previousSibling as HTMLElement;
|
||||
} else if (e.key == "ArrowUp") {
|
||||
// Up Arrow
|
||||
const index = Array.prototype.indexOf.call(element?.parentNode?.childNodes || [], element);
|
||||
const prevRow = getPreviousRow(element);
|
||||
|
||||
c = prevRow?.childNodes?.[index] as HTMLElement;
|
||||
} else if (e.key == "ArrowDown") {
|
||||
// Down Arrow
|
||||
const index = Array.prototype.indexOf.call(element?.parentNode?.childNodes || [], element);
|
||||
const nextRow = getNextRow(element);
|
||||
|
||||
c = nextRow?.childNodes[index] as HTMLElement;
|
||||
} else if (e.key == "Enter" || e.key == "Space") {
|
||||
e.preventDefault();
|
||||
(element?.querySelector(".clickable") as HTMLElement)?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!c) return;
|
||||
|
||||
e.preventDefault();
|
||||
c?.focus();
|
||||
c?.scrollIntoView({ behavior: "smooth", block: "center", inline: "end" });
|
||||
};
|
||||
|
||||
return handleKeyBoardNavigation;
|
||||
};
|
@ -108,14 +108,13 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
selectedIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleTab)}
|
||||
onChange={(i) => handleCurrentView(CYCLE_TAB_LIST[i]?.key ?? "active")}
|
||||
>
|
||||
<div className="flex flex-col items-end justify-between gap-4 border-b border-custom-border-200 px-4 pb-4 sm:flex-row sm:items-center sm:px-5 sm:pb-0">
|
||||
<div className="flex flex-col items-start justify-between gap-4 border-b border-custom-border-200 px-4 sm:flex-row sm:items-center sm:px-5 sm:pb-0">
|
||||
<Tab.List as="div" className="flex items-center overflow-x-scroll">
|
||||
{CYCLE_TAB_LIST.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={({ selected }) =>
|
||||
`border-b-2 p-4 text-sm font-medium outline-none ${
|
||||
selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent"
|
||||
`border-b-2 p-4 text-sm font-medium outline-none ${selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent"
|
||||
}`
|
||||
}
|
||||
>
|
||||
@ -123,32 +122,32 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
{cycleTab !== "active" && (
|
||||
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
|
||||
{CYCLE_VIEW_LAYOUTS.map((layout) => {
|
||||
if (layout.key === "gantt" && cycleTab === "draft") return null;
|
||||
<div className="hidden sm:block">
|
||||
{cycleTab !== "active" && (
|
||||
<div className="flex items-center self-end sm:self-center md:self-center lg:self-center gap-1 rounded bg-custom-background-80 p-1">
|
||||
{CYCLE_VIEW_LAYOUTS.map((layout) => {
|
||||
if (layout.key === "gantt" && cycleTab === "draft") return null;
|
||||
|
||||
return (
|
||||
<Tooltip key={layout.key} tooltipContent={layout.title}>
|
||||
<button
|
||||
type="button"
|
||||
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
|
||||
cycleLayout == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
|
||||
}`}
|
||||
onClick={() => handleCurrentLayout(layout.key as TCycleLayout)}
|
||||
>
|
||||
<layout.icon
|
||||
strokeWidth={2}
|
||||
className={`h-3.5 w-3.5 ${
|
||||
cycleLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<Tooltip key={layout.key} tooltipContent={layout.title}>
|
||||
<button
|
||||
type="button"
|
||||
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${cycleLayout == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
|
||||
}`}
|
||||
onClick={() => handleCurrentLayout(layout.key as TCycleLayout)}
|
||||
>
|
||||
<layout.icon
|
||||
strokeWidth={2}
|
||||
className={`h-3.5 w-3.5 ${cycleLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tab.Panels as={Fragment}>
|
||||
|
@ -56,9 +56,6 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
//TODO:fix reload confirmations, with mobx
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
|
||||
const { handleSubmit, setValue, watch, getValues, control, reset } = useForm<IPage>({
|
||||
defaultValues: { name: "", description_html: "" },
|
||||
});
|
||||
@ -89,6 +86,8 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
|
||||
const pageStore = usePage(pageId as string);
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations(pageStore?.isSubmitting === "submitting");
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (pageStore) {
|
||||
|
@ -50,12 +50,6 @@ class MyDocument extends Document {
|
||||
src="https://plausible.io/js/script.js"
|
||||
/>
|
||||
)}
|
||||
{process.env.NEXT_PUBLIC_POSTHOG_KEY && process.env.NEXT_PUBLIC_POSTHOG_HOST && (
|
||||
<Script id="posthog-tracking">
|
||||
{`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
posthog.init('${process.env.NEXT_PUBLIC_POSTHOG_KEY}',{api_host:'${process.env.NEXT_PUBLIC_POSTHOG_HOST}'})`}
|
||||
</Script>
|
||||
)}
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
|
@ -1,5 +1,9 @@
|
||||
import { action, observable, makeObservable, computed, runInAction } from "mobx";
|
||||
import set from "lodash/set";
|
||||
import update from "lodash/update";
|
||||
import uniq from "lodash/uniq";
|
||||
import concat from "lodash/concat";
|
||||
import pull from "lodash/pull";
|
||||
// base class
|
||||
import { IssueHelperStore } from "../helpers/issue-helper.store";
|
||||
// services
|
||||
@ -29,7 +33,7 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues {
|
||||
viewFlags = {
|
||||
enableQuickAdd: false,
|
||||
enableIssueCreation: true,
|
||||
enableInlineEditing: false,
|
||||
enableInlineEditing: true,
|
||||
};
|
||||
// root store
|
||||
rootIssueStore: IIssueRootStore;
|
||||
@ -123,7 +127,7 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues {
|
||||
const response = await this.issueDraftService.createDraftIssue(workspaceSlug, projectId, data);
|
||||
|
||||
runInAction(() => {
|
||||
this.issues[projectId].push(response.id);
|
||||
update(this.issues, [projectId], (issueIds = []) => uniq(concat(issueIds, response.id)));
|
||||
});
|
||||
|
||||
this.rootStore.issues.addIssue([response]);
|
||||
@ -136,8 +140,17 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues {
|
||||
|
||||
updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
this.rootStore.issues.updateIssue(issueId, data);
|
||||
const response = await this.issueDraftService.updateDraftIssue(workspaceSlug, projectId, issueId, data);
|
||||
const response = await this.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
|
||||
if (data.hasOwnProperty("is_draft") && data?.is_draft === false) {
|
||||
runInAction(() => {
|
||||
update(this.issues, [projectId], (issueIds = []) => {
|
||||
if (issueIds.includes(issueId)) pull(issueIds, issueId);
|
||||
return issueIds;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.fetchIssues(workspaceSlug, projectId, "mutation");
|
||||
@ -147,15 +160,14 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues {
|
||||
|
||||
removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
const response = await this.issueDraftService.deleteDraftIssue(workspaceSlug, projectId, issueId);
|
||||
const response = await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId);
|
||||
|
||||
const issueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === issueId);
|
||||
if (issueIndex >= 0)
|
||||
runInAction(() => {
|
||||
this.issues[projectId].splice(issueIndex, 1);
|
||||
runInAction(() => {
|
||||
update(this.issues, [projectId], (issueIds = []) => {
|
||||
if (issueIds.includes(issueId)) pull(issueIds, issueId);
|
||||
return issueIds;
|
||||
});
|
||||
|
||||
this.rootStore.issues.removeIssue(issueId);
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
Loading…
Reference in New Issue
Block a user