mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' into comment-editor
This commit is contained in:
commit
0cf5ad684b
77
.github/workflows/create-sync-pr.yml
vendored
Normal file
77
.github/workflows/create-sync-pr.yml
vendored
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
name: Create PR in Plane EE Repository to sync the changes
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- closed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create_pr:
|
||||||
|
# Only run the job when a PR is merged
|
||||||
|
if: github.event.pull_request.merged == true
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Check SOURCE_REPO
|
||||||
|
id: check_repo
|
||||||
|
env:
|
||||||
|
SOURCE_REPO: ${{ secrets.SOURCE_REPO_NAME }}
|
||||||
|
run: |
|
||||||
|
echo "::set-output name=is_correct_repo::$(if [[ "$SOURCE_REPO" == "makeplane/plane" ]]; then echo 'true'; else echo 'false'; fi)"
|
||||||
|
|
||||||
|
- name: Checkout Code
|
||||||
|
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Branch Name
|
||||||
|
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||||
|
run: |
|
||||||
|
echo "SOURCE_BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Setup GH CLI
|
||||||
|
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||||
|
run: |
|
||||||
|
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||||
|
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||||
|
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install gh -y
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TARGET_REPO="${{ secrets.TARGET_REPO_NAME }}"
|
||||||
|
TARGET_BRANCH="${{ secrets.TARGET_REPO_BRANCH }}"
|
||||||
|
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||||
|
|
||||||
|
git checkout $SOURCE_BRANCH
|
||||||
|
git remote add target "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||||
|
git push target $SOURCE_BRANCH:$SOURCE_BRANCH
|
||||||
|
|
||||||
|
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||||
|
PR_BODY="${{ github.event.pull_request.body }}"
|
||||||
|
|
||||||
|
# Remove double quotes
|
||||||
|
PR_TITLE_CLEANED="${PR_TITLE//\"/}"
|
||||||
|
PR_BODY_CLEANED="${PR_BODY//\"/}"
|
||||||
|
|
||||||
|
# Construct PR_BODY_CONTENT using a here-document
|
||||||
|
PR_BODY_CONTENT=$(cat <<EOF
|
||||||
|
$PR_BODY_CLEANED
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
gh pr create \
|
||||||
|
--base $TARGET_BRANCH \
|
||||||
|
--head $SOURCE_BRANCH \
|
||||||
|
--title "[SYNC] $PR_TITLE_CLEANED" \
|
||||||
|
--body "$PR_BODY_CONTENT" \
|
||||||
|
--repo $TARGET_REPO
|
@ -39,10 +39,10 @@ jobs:
|
|||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
|
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
|
||||||
id: metaDeploy
|
id: metaSpace
|
||||||
uses: docker/metadata-action@v4.3.0
|
uses: docker/metadata-action@v4.3.0
|
||||||
with:
|
with:
|
||||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-deploy
|
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ jobs:
|
|||||||
file: ./space/Dockerfile.space
|
file: ./space/Dockerfile.space
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.metaDeploy.outputs.tags }}
|
tags: ${{ steps.metaSpace.outputs.tags }}
|
||||||
env:
|
env:
|
||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
@ -54,7 +54,7 @@ chmod +x setup.sh
|
|||||||
- Run setup.sh
|
- Run setup.sh
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./setup.sh http://localhost
|
./setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
> If running in a cloud env replace localhost with public facing IP address of the VM
|
> If running in a cloud env replace localhost with public facing IP address of the VM
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# Backend
|
# Backend
|
||||||
# Debug value for api server use it as 0 for production use
|
# Debug value for api server use it as 0 for production use
|
||||||
DEBUG=0
|
DEBUG=0
|
||||||
DJANGO_SETTINGS_MODULE="plane.settings.selfhosted"
|
DJANGO_SETTINGS_MODULE="plane.settings.production"
|
||||||
|
|
||||||
# Error logs
|
# Error logs
|
||||||
SENTRY_DSN=""
|
SENTRY_DSN=""
|
||||||
@ -59,3 +59,14 @@ DEFAULT_PASSWORD="password123"
|
|||||||
|
|
||||||
# SignUps
|
# SignUps
|
||||||
ENABLE_SIGNUP="1"
|
ENABLE_SIGNUP="1"
|
||||||
|
|
||||||
|
|
||||||
|
# Enable Email/Password Signup
|
||||||
|
ENABLE_EMAIL_PASSWORD="1"
|
||||||
|
|
||||||
|
# Enable Magic link Login
|
||||||
|
ENABLE_MAGIC_LINK_LOGIN="0"
|
||||||
|
|
||||||
|
# Email redirections and minio domain settings
|
||||||
|
WEB_URL="http://localhost"
|
||||||
|
|
||||||
|
@ -70,6 +70,7 @@ from plane.api.views import (
|
|||||||
ProjectIdentifierEndpoint,
|
ProjectIdentifierEndpoint,
|
||||||
ProjectFavoritesViewSet,
|
ProjectFavoritesViewSet,
|
||||||
LeaveProjectEndpoint,
|
LeaveProjectEndpoint,
|
||||||
|
ProjectPublicCoverImagesEndpoint,
|
||||||
## End Projects
|
## End Projects
|
||||||
# Issues
|
# Issues
|
||||||
IssueViewSet,
|
IssueViewSet,
|
||||||
@ -150,12 +151,11 @@ from plane.api.views import (
|
|||||||
GlobalSearchEndpoint,
|
GlobalSearchEndpoint,
|
||||||
IssueSearchEndpoint,
|
IssueSearchEndpoint,
|
||||||
## End Search
|
## End Search
|
||||||
# Gpt
|
# External
|
||||||
GPTIntegrationEndpoint,
|
GPTIntegrationEndpoint,
|
||||||
## End Gpt
|
|
||||||
# Release Notes
|
|
||||||
ReleaseNotesEndpoint,
|
ReleaseNotesEndpoint,
|
||||||
## End Release Notes
|
UnsplashEndpoint,
|
||||||
|
## End External
|
||||||
# Inbox
|
# Inbox
|
||||||
InboxViewSet,
|
InboxViewSet,
|
||||||
InboxIssueViewSet,
|
InboxIssueViewSet,
|
||||||
@ -186,6 +186,9 @@ from plane.api.views import (
|
|||||||
## Exporter
|
## Exporter
|
||||||
ExportIssuesEndpoint,
|
ExportIssuesEndpoint,
|
||||||
## End Exporter
|
## End Exporter
|
||||||
|
# Configuration
|
||||||
|
ConfigurationEndpoint,
|
||||||
|
## End Configuration
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -573,6 +576,11 @@ urlpatterns = [
|
|||||||
LeaveProjectEndpoint.as_view(),
|
LeaveProjectEndpoint.as_view(),
|
||||||
name="project",
|
name="project",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"project-covers/",
|
||||||
|
ProjectPublicCoverImagesEndpoint.as_view(),
|
||||||
|
name="project-covers",
|
||||||
|
),
|
||||||
# End Projects
|
# End Projects
|
||||||
# States
|
# States
|
||||||
path(
|
path(
|
||||||
@ -1446,20 +1454,23 @@ urlpatterns = [
|
|||||||
name="project-issue-search",
|
name="project-issue-search",
|
||||||
),
|
),
|
||||||
## End Search
|
## End Search
|
||||||
# Gpt
|
# External
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
|
||||||
GPTIntegrationEndpoint.as_view(),
|
GPTIntegrationEndpoint.as_view(),
|
||||||
name="importer",
|
name="importer",
|
||||||
),
|
),
|
||||||
## End Gpt
|
|
||||||
# Release Notes
|
|
||||||
path(
|
path(
|
||||||
"release-notes/",
|
"release-notes/",
|
||||||
ReleaseNotesEndpoint.as_view(),
|
ReleaseNotesEndpoint.as_view(),
|
||||||
name="release-notes",
|
name="release-notes",
|
||||||
),
|
),
|
||||||
## End Release Notes
|
path(
|
||||||
|
"unsplash/",
|
||||||
|
UnsplashEndpoint.as_view(),
|
||||||
|
name="release-notes",
|
||||||
|
),
|
||||||
|
## End External
|
||||||
# Inbox
|
# Inbox
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
|
||||||
@ -1728,4 +1739,11 @@ urlpatterns = [
|
|||||||
name="workspace-project-boards",
|
name="workspace-project-boards",
|
||||||
),
|
),
|
||||||
## End Public Boards
|
## End Public Boards
|
||||||
|
# Configuration
|
||||||
|
path(
|
||||||
|
"configs/",
|
||||||
|
ConfigurationEndpoint.as_view(),
|
||||||
|
name="configuration",
|
||||||
|
),
|
||||||
|
## End Configuration
|
||||||
]
|
]
|
||||||
|
@ -17,6 +17,7 @@ from .project import (
|
|||||||
ProjectMemberEndpoint,
|
ProjectMemberEndpoint,
|
||||||
WorkspaceProjectDeployBoardEndpoint,
|
WorkspaceProjectDeployBoardEndpoint,
|
||||||
LeaveProjectEndpoint,
|
LeaveProjectEndpoint,
|
||||||
|
ProjectPublicCoverImagesEndpoint,
|
||||||
)
|
)
|
||||||
from .user import (
|
from .user import (
|
||||||
UserEndpoint,
|
UserEndpoint,
|
||||||
@ -147,16 +148,13 @@ from .page import (
|
|||||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||||
|
|
||||||
|
|
||||||
from .gpt import GPTIntegrationEndpoint
|
from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint
|
||||||
|
|
||||||
from .estimate import (
|
from .estimate import (
|
||||||
ProjectEstimatePointEndpoint,
|
ProjectEstimatePointEndpoint,
|
||||||
BulkEstimatePointEndpoint,
|
BulkEstimatePointEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from .release import ReleaseNotesEndpoint
|
|
||||||
|
|
||||||
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
|
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
|
||||||
|
|
||||||
from .analytic import (
|
from .analytic import (
|
||||||
@ -169,4 +167,6 @@ from .analytic import (
|
|||||||
|
|
||||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
|
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
|
||||||
|
|
||||||
from .exporter import ExportIssuesEndpoint
|
from .exporter import ExportIssuesEndpoint
|
||||||
|
|
||||||
|
from .config import ConfigurationEndpoint
|
40
apiserver/plane/api/views/config.py
Normal file
40
apiserver/plane/api/views/config.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Python imports
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .base import BaseAPIView
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
AllowAny,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
try:
|
||||||
|
data = {}
|
||||||
|
data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None)
|
||||||
|
data["github"] = os.environ.get("GITHUB_CLIENT_ID", None)
|
||||||
|
data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None)
|
||||||
|
data["magic_login"] = (
|
||||||
|
bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD)
|
||||||
|
) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1"
|
||||||
|
data["email_password_login"] = (
|
||||||
|
os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1"
|
||||||
|
)
|
||||||
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
@ -2,9 +2,10 @@
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
|
import openai
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
import openai
|
from rest_framework.permissions import AllowAny
|
||||||
from sentry_sdk import capture_exception
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
@ -15,6 +16,7 @@ from .base import BaseAPIView
|
|||||||
from plane.api.permissions import ProjectEntityPermission
|
from plane.api.permissions import ProjectEntityPermission
|
||||||
from plane.db.models import Workspace, Project
|
from plane.db.models import Workspace, Project
|
||||||
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
||||||
|
from plane.utils.integrations.github import get_release_notes
|
||||||
|
|
||||||
|
|
||||||
class GPTIntegrationEndpoint(BaseAPIView):
|
class GPTIntegrationEndpoint(BaseAPIView):
|
||||||
@ -73,3 +75,44 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
|||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseNotesEndpoint(BaseAPIView):
|
||||||
|
def get(self, request):
|
||||||
|
try:
|
||||||
|
release_notes = get_release_notes()
|
||||||
|
return Response(release_notes, status=status.HTTP_200_OK)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UnsplashEndpoint(BaseAPIView):
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
try:
|
||||||
|
query = request.GET.get("query", False)
|
||||||
|
page = request.GET.get("page", 1)
|
||||||
|
per_page = request.GET.get("per_page", 20)
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
|
||||||
|
if query
|
||||||
|
else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.get(url=url, headers=headers)
|
||||||
|
return Response(resp.json(), status=status.HTTP_200_OK)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
@ -712,10 +712,18 @@ class LabelViewSet(BaseViewSet):
|
|||||||
ProjectMemberPermission,
|
ProjectMemberPermission,
|
||||||
]
|
]
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def create(self, request, slug, project_id):
|
||||||
serializer.save(
|
try:
|
||||||
project_id=self.kwargs.get("project_id"),
|
serializer = LabelSerializer(data=request.data)
|
||||||
)
|
if serializer.is_valid():
|
||||||
|
serializer.save(project_id=project_id)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except IntegrityError:
|
||||||
|
return Response({"error": "Label with the same name already exists in the project"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.filter_queryset(
|
return self.filter_queryset(
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import jwt
|
import jwt
|
||||||
|
import boto3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
@ -495,7 +496,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||||||
serializer_class = ProjectMemberAdminSerializer
|
serializer_class = ProjectMemberAdminSerializer
|
||||||
model = ProjectMember
|
model = ProjectMember
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
ProjectBasePermission,
|
ProjectMemberPermission,
|
||||||
]
|
]
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
@ -617,7 +618,8 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
except ProjectMember.DoesNotExist:
|
except ProjectMember.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST
|
{"error": "Project Member does not exist"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
@ -1209,3 +1211,38 @@ class LeaveProjectEndpoint(BaseAPIView):
|
|||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
AllowAny,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
try:
|
||||||
|
files = []
|
||||||
|
s3 = boto3.client(
|
||||||
|
"s3",
|
||||||
|
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||||
|
)
|
||||||
|
params = {
|
||||||
|
"Bucket": settings.AWS_S3_BUCKET_NAME,
|
||||||
|
"Prefix": "static/project-cover/",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = s3.list_objects_v2(**params)
|
||||||
|
# Extracting file keys from the response
|
||||||
|
if "Contents" in response:
|
||||||
|
for content in response["Contents"]:
|
||||||
|
if not content["Key"].endswith(
|
||||||
|
"/"
|
||||||
|
): # This line ensures we're only getting files, not "sub-folders"
|
||||||
|
files.append(
|
||||||
|
f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(files, status=status.HTTP_200_OK)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response([], status=status.HTTP_200_OK)
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
# Third party imports
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework import status
|
|
||||||
from sentry_sdk import capture_exception
|
|
||||||
|
|
||||||
# Module imports
|
|
||||||
from .base import BaseAPIView
|
|
||||||
from plane.utils.integrations.github import get_release_notes
|
|
||||||
|
|
||||||
|
|
||||||
class ReleaseNotesEndpoint(BaseAPIView):
|
|
||||||
def get(self, request):
|
|
||||||
try:
|
|
||||||
release_notes = get_release_notes()
|
|
||||||
return Response(release_notes, status=status.HTTP_200_OK)
|
|
||||||
except Exception as e:
|
|
||||||
capture_exception(e)
|
|
||||||
return Response(
|
|
||||||
{"error": "Something went wrong please try again later"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
@ -1197,7 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
|||||||
projects = request.query_params.getlist("project", [])
|
projects = request.query_params.getlist("project", [])
|
||||||
|
|
||||||
queryset = IssueActivity.objects.filter(
|
queryset = IssueActivity.objects.filter(
|
||||||
~Q(field__in=["comment", "vote", "reaction"]),
|
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
actor=user_id,
|
actor=user_id,
|
||||||
|
@ -33,9 +33,8 @@ def create_issue_relation(apps, schema_editor):
|
|||||||
def update_issue_priority_choice(apps, schema_editor):
|
def update_issue_priority_choice(apps, schema_editor):
|
||||||
IssueModel = apps.get_model("db", "Issue")
|
IssueModel = apps.get_model("db", "Issue")
|
||||||
updated_issues = []
|
updated_issues = []
|
||||||
for obj in IssueModel.objects.all():
|
for obj in IssueModel.objects.filter(priority=None):
|
||||||
if obj.priority is None:
|
obj.priority = "none"
|
||||||
obj.priority = "none"
|
|
||||||
updated_issues.append(obj)
|
updated_issues.append(obj)
|
||||||
IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100)
|
IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100)
|
||||||
|
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
# Generated by Django 4.2.3 on 2023-09-15 06:55
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
from django.conf import settings
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("db", "0044_auto_20230913_0709"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="GlobalView",
|
|
||||||
fields=[
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At"),),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),),
|
|
||||||
("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True,),),
|
|
||||||
("name", models.CharField(max_length=255, verbose_name="View Name")),
|
|
||||||
("description", models.TextField(blank=True, verbose_name="View Description"),),
|
|
||||||
("query", models.JSONField(verbose_name="View Query")),
|
|
||||||
("access", models.PositiveSmallIntegerField(choices=[(0, "Private"), (1, "Public")], default=1),),
|
|
||||||
("query_data", models.JSONField(default=dict)),
|
|
||||||
("created_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="%(class)s_created_by", to=settings.AUTH_USER_MODEL, verbose_name="Created By",),),
|
|
||||||
("updated_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="%(class)s_updated_by", to=settings.AUTH_USER_MODEL, verbose_name="Last Modified By",),),
|
|
||||||
("workspace", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="global_views", to="db.workspace",),),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "Global View",
|
|
||||||
"verbose_name_plural": "Global Views",
|
|
||||||
"db_table": "global_views",
|
|
||||||
"ordering": ("-created_at",),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="issueactivity",
|
|
||||||
name="epoch",
|
|
||||||
field=models.FloatField(null=True),
|
|
||||||
),
|
|
||||||
]
|
|
@ -0,0 +1,79 @@
|
|||||||
|
# Generated by Django 4.2.5 on 2023-09-29 10:14
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import plane.db.models.workspace
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def update_issue_activity_priority(apps, schema_editor):
|
||||||
|
IssueActivity = apps.get_model("db", "IssueActivity")
|
||||||
|
updated_issue_activity = []
|
||||||
|
for obj in IssueActivity.objects.filter(field="priority"):
|
||||||
|
# Set the old and new value to none if it is empty for Priority
|
||||||
|
obj.new_value = obj.new_value or "none"
|
||||||
|
obj.old_value = obj.old_value or "none"
|
||||||
|
updated_issue_activity.append(obj)
|
||||||
|
IssueActivity.objects.bulk_update(
|
||||||
|
updated_issue_activity,
|
||||||
|
["new_value", "old_value"],
|
||||||
|
batch_size=2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_issue_activity_blocked(apps, schema_editor):
|
||||||
|
IssueActivity = apps.get_model("db", "IssueActivity")
|
||||||
|
updated_issue_activity = []
|
||||||
|
for obj in IssueActivity.objects.filter(field="blocks"):
|
||||||
|
# Set the field to blocked_by
|
||||||
|
obj.field = "blocked_by"
|
||||||
|
updated_issue_activity.append(obj)
|
||||||
|
IssueActivity.objects.bulk_update(
|
||||||
|
updated_issue_activity,
|
||||||
|
["field"],
|
||||||
|
batch_size=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('db', '0044_auto_20230913_0709'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GlobalView',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='View Name')),
|
||||||
|
('description', models.TextField(blank=True, verbose_name='View Description')),
|
||||||
|
('query', models.JSONField(verbose_name='View Query')),
|
||||||
|
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
|
||||||
|
('query_data', models.JSONField(default=dict)),
|
||||||
|
('sort_order', models.FloatField(default=65535)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||||
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Global View',
|
||||||
|
'verbose_name_plural': 'Global Views',
|
||||||
|
'db_table': 'global_views',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspacemember',
|
||||||
|
name='issue_props',
|
||||||
|
field=models.JSONField(default=plane.db.models.workspace.get_issue_props),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='issueactivity',
|
||||||
|
name='epoch',
|
||||||
|
field=models.FloatField(null=True),
|
||||||
|
),
|
||||||
|
migrations.RunPython(update_issue_activity_priority),
|
||||||
|
migrations.RunPython(update_issue_activity_blocked),
|
||||||
|
]
|
@ -1,26 +0,0 @@
|
|||||||
# Generated by Django 4.2.5 on 2023-09-26 10:15
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
def update_issue_activity(apps, schema_editor):
|
|
||||||
IssueActivity = apps.get_model("db", "IssueActivity")
|
|
||||||
updated_issue_activity = []
|
|
||||||
for obj in IssueActivity.objects.all():
|
|
||||||
obj.epoch = int(obj.created_at.timestamp())
|
|
||||||
updated_issue_activity.append(obj)
|
|
||||||
IssueActivity.objects.bulk_update(
|
|
||||||
updated_issue_activity,
|
|
||||||
["epoch"],
|
|
||||||
batch_size=5000,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('db', '0045_auto_20230915_0655'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RunPython(update_issue_activity),
|
|
||||||
]
|
|
@ -1,44 +0,0 @@
|
|||||||
# Generated by Django 4.2.5 on 2023-09-26 10:29
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
def update_issue_activity_priority(apps, schema_editor):
|
|
||||||
IssueActivity = apps.get_model("db", "IssueActivity")
|
|
||||||
updated_issue_activity = []
|
|
||||||
for obj in IssueActivity.objects.filter(field="priority"):
|
|
||||||
# Set the old and new value to none if it is empty for Priority
|
|
||||||
obj.new_value = obj.new_value or "none"
|
|
||||||
obj.old_value = obj.old_value or "none"
|
|
||||||
updated_issue_activity.append(obj)
|
|
||||||
IssueActivity.objects.bulk_update(
|
|
||||||
updated_issue_activity,
|
|
||||||
["new_value", "old_value"],
|
|
||||||
batch_size=1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_issue_activity_blocked(apps, schema_editor):
|
|
||||||
IssueActivity = apps.get_model("db", "IssueActivity")
|
|
||||||
updated_issue_activity = []
|
|
||||||
for obj in IssueActivity.objects.filter(field="blocks"):
|
|
||||||
# Set the field to blocked_by
|
|
||||||
obj.field = "blocked_by"
|
|
||||||
updated_issue_activity.append(obj)
|
|
||||||
IssueActivity.objects.bulk_update(
|
|
||||||
updated_issue_activity,
|
|
||||||
["field"],
|
|
||||||
batch_size=1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('db', '0046_auto_20230926_1015'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RunPython(update_issue_activity_priority),
|
|
||||||
migrations.RunPython(update_issue_activity_blocked),
|
|
||||||
]
|
|
@ -1,24 +0,0 @@
|
|||||||
# Generated by Django 4.2.5 on 2023-09-27 11:18
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import plane.db.models.workspace
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('db', '0047_auto_20230926_1029'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='globalview',
|
|
||||||
name='sort_order',
|
|
||||||
field=models.FloatField(default=65535),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='workspacemember',
|
|
||||||
name='issue_props',
|
|
||||||
field=models.JSONField(default=plane.db.models.workspace.get_issue_props),
|
|
||||||
),
|
|
||||||
]
|
|
@ -114,3 +114,6 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL")
|
|||||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||||
|
|
||||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||||
|
|
||||||
|
# Unsplash Access key
|
||||||
|
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||||
|
@ -7,6 +7,7 @@ import dj_database_url
|
|||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
from sentry_sdk.integrations.redis import RedisIntegration
|
from sentry_sdk.integrations.redis import RedisIntegration
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from .common import * # noqa
|
from .common import * # noqa
|
||||||
|
|
||||||
@ -89,90 +90,112 @@ if bool(os.environ.get("SENTRY_DSN", False)):
|
|||||||
profiles_sample_rate=1.0,
|
profiles_sample_rate=1.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# The AWS region to connect to.
|
if DOCKERIZED and USE_MINIO:
|
||||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
INSTALLED_APPS += ("storages",)
|
||||||
|
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
|
||||||
|
# The AWS access key to use.
|
||||||
|
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
|
||||||
|
# The AWS secret access key to use.
|
||||||
|
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
|
||||||
|
# The name of the bucket to store files in.
|
||||||
|
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
|
||||||
|
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||||
|
AWS_S3_ENDPOINT_URL = os.environ.get(
|
||||||
|
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
|
||||||
|
)
|
||||||
|
# Default permissions
|
||||||
|
AWS_DEFAULT_ACL = "public-read"
|
||||||
|
AWS_QUERYSTRING_AUTH = False
|
||||||
|
AWS_S3_FILE_OVERWRITE = False
|
||||||
|
|
||||||
# The AWS access key to use.
|
# Custom Domain settings
|
||||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
|
||||||
|
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
|
||||||
|
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
|
||||||
|
else:
|
||||||
|
# The AWS region to connect to.
|
||||||
|
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||||
|
|
||||||
# The AWS secret access key to use.
|
# The AWS access key to use.
|
||||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
||||||
|
|
||||||
# The optional AWS session token to use.
|
# The AWS secret access key to use.
|
||||||
# AWS_SESSION_TOKEN = ""
|
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
||||||
|
|
||||||
# The name of the bucket to store files in.
|
# The optional AWS session token to use.
|
||||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
# AWS_SESSION_TOKEN = ""
|
||||||
|
|
||||||
# How to construct S3 URLs ("auto", "path", "virtual").
|
# The name of the bucket to store files in.
|
||||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||||
|
|
||||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
# How to construct S3 URLs ("auto", "path", "virtual").
|
||||||
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
|
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||||
|
|
||||||
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||||
AWS_S3_KEY_PREFIX = ""
|
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
|
||||||
|
|
||||||
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
|
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
||||||
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
|
AWS_S3_KEY_PREFIX = ""
|
||||||
# and their permissions will be set to "public-read".
|
|
||||||
AWS_S3_BUCKET_AUTH = False
|
|
||||||
|
|
||||||
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
|
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
|
||||||
# is True. It also affects the "Cache-Control" header of the files.
|
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
|
||||||
# Important: Changing this setting will not affect existing files.
|
# and their permissions will be set to "public-read".
|
||||||
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
|
AWS_S3_BUCKET_AUTH = False
|
||||||
|
|
||||||
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
|
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
|
||||||
# cannot be used with `AWS_S3_BUCKET_AUTH`.
|
# is True. It also affects the "Cache-Control" header of the files.
|
||||||
AWS_S3_PUBLIC_URL = ""
|
# Important: Changing this setting will not affect existing files.
|
||||||
|
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
|
||||||
|
|
||||||
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
|
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
|
||||||
# understand the consequences before enabling.
|
# cannot be used with `AWS_S3_BUCKET_AUTH`.
|
||||||
# Important: Changing this setting will not affect existing files.
|
AWS_S3_PUBLIC_URL = ""
|
||||||
AWS_S3_REDUCED_REDUNDANCY = False
|
|
||||||
|
|
||||||
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
|
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
|
||||||
# single `name` argument.
|
# understand the consequences before enabling.
|
||||||
# Important: Changing this setting will not affect existing files.
|
# Important: Changing this setting will not affect existing files.
|
||||||
AWS_S3_CONTENT_DISPOSITION = ""
|
AWS_S3_REDUCED_REDUNDANCY = False
|
||||||
|
|
||||||
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
|
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
|
||||||
# single `name` argument.
|
# single `name` argument.
|
||||||
# Important: Changing this setting will not affect existing files.
|
# Important: Changing this setting will not affect existing files.
|
||||||
AWS_S3_CONTENT_LANGUAGE = ""
|
AWS_S3_CONTENT_DISPOSITION = ""
|
||||||
|
|
||||||
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
|
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
|
||||||
# single `name` argument.
|
# single `name` argument.
|
||||||
# Important: Changing this setting will not affect existing files.
|
# Important: Changing this setting will not affect existing files.
|
||||||
AWS_S3_METADATA = {}
|
AWS_S3_CONTENT_LANGUAGE = ""
|
||||||
|
|
||||||
# If True, then files will be stored using AES256 server-side encryption.
|
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
|
||||||
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
|
# single `name` argument.
|
||||||
# Otherwise, server-side encryption is not be enabled.
|
# Important: Changing this setting will not affect existing files.
|
||||||
# Important: Changing this setting will not affect existing files.
|
AWS_S3_METADATA = {}
|
||||||
AWS_S3_ENCRYPT_KEY = False
|
|
||||||
|
|
||||||
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
|
# If True, then files will be stored using AES256 server-side encryption.
|
||||||
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
|
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
|
||||||
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
|
# Otherwise, server-side encryption is not be enabled.
|
||||||
|
# Important: Changing this setting will not affect existing files.
|
||||||
|
AWS_S3_ENCRYPT_KEY = False
|
||||||
|
|
||||||
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
|
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
|
||||||
# compressed size is smaller than their uncompressed size.
|
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
|
||||||
# Important: Changing this setting will not affect existing files.
|
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
|
||||||
AWS_S3_GZIP = True
|
|
||||||
|
|
||||||
# The signature version to use for S3 requests.
|
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
|
||||||
AWS_S3_SIGNATURE_VERSION = None
|
# compressed size is smaller than their uncompressed size.
|
||||||
|
# Important: Changing this setting will not affect existing files.
|
||||||
|
AWS_S3_GZIP = True
|
||||||
|
|
||||||
# If True, then files with the same name will overwrite each other. By default it's set to False to have
|
# The signature version to use for S3 requests.
|
||||||
# extra characters appended.
|
AWS_S3_SIGNATURE_VERSION = None
|
||||||
AWS_S3_FILE_OVERWRITE = False
|
|
||||||
|
|
||||||
STORAGES["default"] = {
|
# If True, then files with the same name will overwrite each other. By default it's set to False to have
|
||||||
"BACKEND": "django_s3_storage.storage.S3Storage",
|
# extra characters appended.
|
||||||
}
|
AWS_S3_FILE_OVERWRITE = False
|
||||||
|
|
||||||
|
STORAGES["default"] = {
|
||||||
|
"BACKEND": "django_s3_storage.storage.S3Storage",
|
||||||
|
}
|
||||||
# AWS Settings End
|
# AWS Settings End
|
||||||
|
|
||||||
# Enable Connection Pooling (if desired)
|
# Enable Connection Pooling (if desired)
|
||||||
@ -193,16 +216,27 @@ CSRF_COOKIE_SECURE = True
|
|||||||
|
|
||||||
REDIS_URL = os.environ.get("REDIS_URL")
|
REDIS_URL = os.environ.get("REDIS_URL")
|
||||||
|
|
||||||
CACHES = {
|
if DOCKERIZED:
|
||||||
"default": {
|
CACHES = {
|
||||||
"BACKEND": "django_redis.cache.RedisCache",
|
"default": {
|
||||||
"LOCATION": REDIS_URL,
|
"BACKEND": "django_redis.cache.RedisCache",
|
||||||
"OPTIONS": {
|
"LOCATION": REDIS_URL,
|
||||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
"OPTIONS": {
|
||||||
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django_redis.cache.RedisCache",
|
||||||
|
"LOCATION": REDIS_URL,
|
||||||
|
"OPTIONS": {
|
||||||
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||||
|
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
|
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
|
||||||
@ -225,8 +259,12 @@ broker_url = (
|
|||||||
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
CELERY_RESULT_BACKEND = broker_url
|
if DOCKERIZED:
|
||||||
CELERY_BROKER_URL = broker_url
|
CELERY_BROKER_URL = REDIS_URL
|
||||||
|
CELERY_RESULT_BACKEND = REDIS_URL
|
||||||
|
else:
|
||||||
|
CELERY_BROKER_URL = broker_url
|
||||||
|
CELERY_RESULT_BACKEND = broker_url
|
||||||
|
|
||||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||||
|
|
||||||
@ -238,3 +276,6 @@ SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
|
|||||||
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
|
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
|
||||||
SCOUT_NAME = "Plane"
|
SCOUT_NAME = "Plane"
|
||||||
|
|
||||||
|
# Unsplash Access key
|
||||||
|
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||||
|
|
||||||
|
@ -126,3 +126,4 @@ ANALYTICS_BASE_API = False
|
|||||||
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
|
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
|
||||||
|
|
||||||
|
@ -218,3 +218,7 @@ CELERY_BROKER_URL = broker_url
|
|||||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||||
|
|
||||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
# Unsplash Access key
|
||||||
|
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"repository": "https://github.com/makeplane/plane.git",
|
"repository": "https://github.com/makeplane/plane.git",
|
||||||
|
"version": "0.13.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
@ -75,8 +75,6 @@ export const TableMenu = ({ editor }: { editor: any }) => {
|
|||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
const tableNode = findTableAncestor(range.startContainer);
|
const tableNode = findTableAncestor(range.startContainer);
|
||||||
|
|
||||||
let parent = tableNode?.parentElement;
|
|
||||||
|
|
||||||
if (tableNode) {
|
if (tableNode) {
|
||||||
const tableRect = tableNode.getBoundingClientRect();
|
const tableRect = tableNode.getBoundingClientRect();
|
||||||
const tableCenter = tableRect.left + tableRect.width / 2;
|
const tableCenter = tableRect.left + tableRect.width / 2;
|
||||||
@ -85,18 +83,6 @@ export const TableMenu = ({ editor }: { editor: any }) => {
|
|||||||
const tableBottom = tableRect.bottom;
|
const tableBottom = tableRect.bottom;
|
||||||
|
|
||||||
setTableLocation({ bottom: tableBottom, left: menuLeft });
|
setTableLocation({ bottom: tableBottom, left: menuLeft });
|
||||||
|
|
||||||
while (parent) {
|
|
||||||
if (!parent.classList.contains("disable-scroll"))
|
|
||||||
parent.classList.add("disable-scroll");
|
|
||||||
parent = parent.parentElement;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const scrollDisabledContainers = document.querySelectorAll(".disable-scroll");
|
|
||||||
|
|
||||||
scrollDisabledContainers.forEach((container) => {
|
|
||||||
container.classList.remove("disable-scroll");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -110,13 +96,9 @@ export const TableMenu = ({ editor }: { editor: any }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
|
className={`absolute z-20 left-1/2 -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
|
||||||
isOpen ? "block" : "hidden"
|
isOpen ? "block" : "hidden"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
|
||||||
bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`,
|
|
||||||
left: `${tableLocation.left}px`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<Tooltip key={index} tooltipContent={item.name}>
|
<Tooltip key={index} tooltipContent={item.name}>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "eslint-config-custom",
|
"name": "eslint-config-custom",
|
||||||
"version": "0.0.0",
|
"version": "0.13.2",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tailwind-config-custom",
|
"name": "tailwind-config-custom",
|
||||||
"version": "0.0.1",
|
"version": "0.13.2",
|
||||||
"description": "common tailwind configuration across monorepo",
|
"description": "common tailwind configuration across monorepo",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tsconfig",
|
"name": "tsconfig",
|
||||||
"version": "0.0.0",
|
"version": "0.13.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
1
packages/ui/README.md
Normal file
1
packages/ui/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# UI Package
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ui",
|
"name": "ui",
|
||||||
"version": "0.0.0",
|
"version": "0.13.2",
|
||||||
"main": "./index.tsx",
|
"main": "./index.tsx",
|
||||||
"types": "./index.tsx",
|
"types": "./index.tsx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
@ -10,9 +10,12 @@ import githubWhiteImage from "public/logos/github-white.svg";
|
|||||||
|
|
||||||
export interface GithubLoginButtonProps {
|
export interface GithubLoginButtonProps {
|
||||||
handleSignIn: React.Dispatch<string>;
|
handleSignIn: React.Dispatch<string>;
|
||||||
|
clientId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
|
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||||
|
const { handleSignIn, clientId } = props;
|
||||||
|
// states
|
||||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||||
|
|
||||||
@ -38,7 +41,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
|
|||||||
<div className="w-full flex justify-center items-center">
|
<div className="w-full flex justify-center items-center">
|
||||||
<Link
|
<Link
|
||||||
className="w-full"
|
className="w-full"
|
||||||
href={`https://github.com/login/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||||
>
|
>
|
||||||
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
||||||
<Image
|
<Image
|
||||||
|
@ -1,22 +1,23 @@
|
|||||||
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
|
import { FC, useEffect, useRef, useCallback, useState } from "react";
|
||||||
|
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
|
||||||
export interface IGoogleLoginButton {
|
export interface IGoogleLoginButton {
|
||||||
text?: string;
|
clientId: string;
|
||||||
handleSignIn: React.Dispatch<any>;
|
handleSignIn: React.Dispatch<any>;
|
||||||
styles?: CSSProperties;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||||
|
const { handleSignIn, clientId } = props;
|
||||||
|
// refs
|
||||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||||
|
// states
|
||||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||||
|
|
||||||
const loadScript = useCallback(() => {
|
const loadScript = useCallback(() => {
|
||||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||||
|
|
||||||
(window as any)?.google?.accounts.id.initialize({
|
(window as any)?.google?.accounts.id.initialize({
|
||||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
|
client_id: clientId,
|
||||||
callback: handleSignIn,
|
callback: handleSignIn,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,26 +1,30 @@
|
|||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
import Image from "next/image";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
// mobx
|
// mobx
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
// services
|
// services
|
||||||
import authenticationService from "services/authentication.service";
|
import authenticationService from "services/authentication.service";
|
||||||
|
import { AppConfigService } from "services/app-config.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
|
import { EmailPasswordForm, GoogleLoginButton, EmailCodeForm } from "components/accounts";
|
||||||
// images
|
// images
|
||||||
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
|
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
|
||||||
|
|
||||||
|
const appConfig = new AppConfigService();
|
||||||
|
|
||||||
export const SignInView = observer(() => {
|
export const SignInView = observer(() => {
|
||||||
const { user: userStore } = useMobxStore();
|
const { user: userStore } = useMobxStore();
|
||||||
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { next_path } = router.query as { next_path: string };
|
||||||
|
// toast
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
// fetch app config
|
||||||
|
const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig());
|
||||||
|
|
||||||
const onSignInError = (error: any) => {
|
const onSignInError = (error: any) => {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
@ -31,17 +35,17 @@ export const SignInView = observer(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSignInSuccess = (response: any) => {
|
const onSignInSuccess = (response: any) => {
|
||||||
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
|
|
||||||
|
|
||||||
const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/login";
|
|
||||||
|
|
||||||
userStore.setCurrentUser(response?.user);
|
userStore.setCurrentUser(response?.user);
|
||||||
|
|
||||||
if (!isOnboarded) {
|
const isOnboard = response?.user?.onboarding_step?.profile_complete || false;
|
||||||
router.push(`/onboarding?next_path=${nextPath}`);
|
|
||||||
return;
|
if (isOnboard) {
|
||||||
|
if (next_path) router.push(next_path);
|
||||||
|
else router.push("/login");
|
||||||
|
} else {
|
||||||
|
if (next_path) router.push(`/onboarding?next_path=${next_path}`);
|
||||||
|
else router.push("/onboarding");
|
||||||
}
|
}
|
||||||
router.push((nextPath ?? "/login").toString());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
||||||
@ -63,24 +67,6 @@ export const SignInView = observer(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGitHubSignIn = async (credential: string) => {
|
|
||||||
try {
|
|
||||||
if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) {
|
|
||||||
const socialAuthPayload = {
|
|
||||||
medium: "github",
|
|
||||||
credential,
|
|
||||||
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
|
|
||||||
};
|
|
||||||
const response = await authenticationService.socialAuth(socialAuthPayload);
|
|
||||||
onSignInSuccess(response);
|
|
||||||
} else {
|
|
||||||
throw Error("Cant find credentials");
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
onSignInError(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasswordSignIn = async (formData: any) => {
|
const handlePasswordSignIn = async (formData: any) => {
|
||||||
await authenticationService
|
await authenticationService
|
||||||
.emailLogin(formData)
|
.emailLogin(formData)
|
||||||
@ -118,38 +104,32 @@ export const SignInView = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
|
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
|
||||||
<div>
|
<div>
|
||||||
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">Sign in to Plane</h1>
|
||||||
<>
|
{data?.email_password_login && <EmailPasswordForm onSubmit={handlePasswordSignIn} />}
|
||||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
|
||||||
Sign in to Plane
|
{data?.magic_login && (
|
||||||
</h1>
|
<div className="flex flex-col divide-y divide-custom-border-200">
|
||||||
<div className="flex flex-col divide-y divide-custom-border-200">
|
<div className="pb-7">
|
||||||
<div className="pb-7">
|
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
|
||||||
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
|
|
||||||
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
|
|
||||||
{/* <GithubLoginButton handleSignIn={handleGitHubSignIn} /> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
) : (
|
|
||||||
<EmailPasswordForm onSubmit={handlePasswordSignIn} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
|
||||||
<p className="pt-16 text-custom-text-200 text-sm text-center">
|
{data?.google && <GoogleLoginButton clientId={data.google} handleSignIn={handleGoogleSignIn} />}
|
||||||
By signing up, you agree to the{" "}
|
</div>
|
||||||
<a
|
|
||||||
href="https://plane.so/terms-and-conditions"
|
<p className="pt-16 text-custom-text-200 text-sm text-center">
|
||||||
target="_blank"
|
By signing up, you agree to the{" "}
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
className="font-medium underline"
|
href="https://plane.so/terms-and-conditions"
|
||||||
>
|
target="_blank"
|
||||||
Terms & Conditions
|
rel="noopener noreferrer"
|
||||||
</a>
|
className="font-medium underline"
|
||||||
</p>
|
>
|
||||||
) : null}
|
Terms & Conditions
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -44,19 +44,43 @@ const IssueNavbar = observer(() => {
|
|||||||
}, [projectStore, workspace_slug, project_slug]);
|
}, [projectStore, workspace_slug, project_slug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workspace_slug && project_slug) {
|
if (workspace_slug && project_slug && projectStore?.deploySettings) {
|
||||||
if (!board) {
|
const viewsAcceptable: string[] = [];
|
||||||
router.push({
|
let currentBoard: string | null = null;
|
||||||
pathname: `/${workspace_slug}/${project_slug}`,
|
|
||||||
query: {
|
if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list");
|
||||||
board: "list",
|
if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban");
|
||||||
},
|
if (projectStore?.deploySettings?.views?.calendar) viewsAcceptable.push("calendar");
|
||||||
});
|
if (projectStore?.deploySettings?.views?.gantt) viewsAcceptable.push("gantt");
|
||||||
return projectStore.setActiveBoard("list");
|
if (projectStore?.deploySettings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet");
|
||||||
|
|
||||||
|
if (board) {
|
||||||
|
if (viewsAcceptable.includes(board.toString())) {
|
||||||
|
currentBoard = board.toString();
|
||||||
|
} else {
|
||||||
|
if (viewsAcceptable && viewsAcceptable.length > 0) {
|
||||||
|
currentBoard = viewsAcceptable[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (viewsAcceptable && viewsAcceptable.length > 0) {
|
||||||
|
currentBoard = viewsAcceptable[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBoard) {
|
||||||
|
if (projectStore?.activeBoard === null || projectStore?.activeBoard !== currentBoard) {
|
||||||
|
projectStore.setActiveBoard(currentBoard);
|
||||||
|
router.push({
|
||||||
|
pathname: `/${workspace_slug}/${project_slug}`,
|
||||||
|
query: {
|
||||||
|
board: currentBoard,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
projectStore.setActiveBoard(board.toString());
|
|
||||||
}
|
}
|
||||||
}, [board, workspace_slug, project_slug]);
|
}, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-5 relative w-full flex items-center gap-4">
|
<div className="px-5 relative w-full flex items-center gap-4">
|
||||||
@ -105,7 +129,7 @@ const IssueNavbar = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Link href={`/?next_path=${router.asPath}`}>
|
<Link href={`/login/?next_path=${router.asPath}`}>
|
||||||
<a>
|
<a>
|
||||||
<PrimaryButton className="flex-shrink-0" outline>
|
<PrimaryButton className="flex-shrink-0" outline>
|
||||||
Sign in
|
Sign in
|
||||||
|
@ -7,7 +7,13 @@ import { SignInView, UserLoggedIn } from "components/accounts";
|
|||||||
export const LoginView = observer(() => {
|
export const LoginView = observer(() => {
|
||||||
const { user: userStore } = useMobxStore();
|
const { user: userStore } = useMobxStore();
|
||||||
|
|
||||||
if (!userStore.currentUser) return <SignInView />;
|
return (
|
||||||
|
<>
|
||||||
return <UserLoggedIn />;
|
{userStore?.loader ? (
|
||||||
|
<div className="relative w-screen h-screen flex justify-center items-center">Loading</div>
|
||||||
|
) : (
|
||||||
|
<>{userStore.currentUser ? <UserLoggedIn /> : <SignInView />}</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
@ -3,12 +3,14 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
// next imports
|
// next imports
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
// js cookie
|
||||||
|
import Cookie from "js-cookie";
|
||||||
// mobx store
|
// mobx store
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
import { RootStore } from "store/root";
|
import { RootStore } from "store/root";
|
||||||
|
|
||||||
const MobxStoreInit = () => {
|
const MobxStoreInit = () => {
|
||||||
const store: RootStore = useMobxStore();
|
const { user: userStore }: RootStore = useMobxStore();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] };
|
const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] };
|
||||||
@ -19,6 +21,11 @@ const MobxStoreInit = () => {
|
|||||||
// store.issue.userSelectedStates = states || [];
|
// store.issue.userSelectedStates = states || [];
|
||||||
// }, [store.issue]);
|
// }, [store.issue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const authToken = Cookie.get("accessToken") || null;
|
||||||
|
if (authToken) userStore.fetchCurrentUser();
|
||||||
|
}, [userStore]);
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space",
|
"name": "space",
|
||||||
"version": "0.0.1",
|
"version": "0.13.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo run develop",
|
"dev": "turbo run develop",
|
||||||
|
19
space/pages/index.tsx
Normal file
19
space/pages/index.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// next
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
const Index: NextPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { next_path } = router.query as { next_path: string };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (next_path) router.push(`/login?next_path=${next_path}`);
|
||||||
|
else router.push(`/login`);
|
||||||
|
}, [router, next_path]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
@ -5,4 +5,4 @@ import { LoginView } from "components/views";
|
|||||||
|
|
||||||
const LoginPage = () => <LoginView />;
|
const LoginPage = () => <LoginView />;
|
||||||
|
|
||||||
export default LoginPage;
|
export default LoginPage;
|
||||||
|
30
space/services/app-config.service.ts
Normal file
30
space/services/app-config.service.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// services
|
||||||
|
import APIService from "services/api.service";
|
||||||
|
// helper
|
||||||
|
import { API_BASE_URL } from "helpers/common.helper";
|
||||||
|
|
||||||
|
export interface IEnvConfig {
|
||||||
|
github: string;
|
||||||
|
google: string;
|
||||||
|
github_app_name: string | null;
|
||||||
|
email_password_login: boolean;
|
||||||
|
magic_login: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppConfigService extends APIService {
|
||||||
|
constructor() {
|
||||||
|
super(API_BASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async envConfig(): Promise<IEnvConfig> {
|
||||||
|
return this.get("/api/configs/", {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -92,24 +92,6 @@ class FileService extends APIService {
|
|||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUnsplashImages(page: number = 1, query?: string): Promise<UnSplashImage[]> {
|
|
||||||
const url = "/api/unsplash";
|
|
||||||
|
|
||||||
return this.request({
|
|
||||||
method: "get",
|
|
||||||
url,
|
|
||||||
params: {
|
|
||||||
page,
|
|
||||||
per_page: 20,
|
|
||||||
query,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((response) => response?.data?.results ?? response?.data)
|
|
||||||
.catch((error) => {
|
|
||||||
throw error?.response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
|
@ -7,12 +7,17 @@ import { ActorDetail } from "types/issue";
|
|||||||
import { IUser } from "types/user";
|
import { IUser } from "types/user";
|
||||||
|
|
||||||
export interface IUserStore {
|
export interface IUserStore {
|
||||||
|
loader: boolean;
|
||||||
|
error: any | null;
|
||||||
currentUser: any | null;
|
currentUser: any | null;
|
||||||
fetchCurrentUser: () => void;
|
fetchCurrentUser: () => void;
|
||||||
currentActor: () => any;
|
currentActor: () => any;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserStore implements IUserStore {
|
class UserStore implements IUserStore {
|
||||||
|
loader: boolean = false;
|
||||||
|
error: any | null = null;
|
||||||
|
|
||||||
currentUser: IUser | null = null;
|
currentUser: IUser | null = null;
|
||||||
// root store
|
// root store
|
||||||
rootStore;
|
rootStore;
|
||||||
@ -73,14 +78,19 @@ class UserStore implements IUserStore {
|
|||||||
|
|
||||||
fetchCurrentUser = async () => {
|
fetchCurrentUser = async () => {
|
||||||
try {
|
try {
|
||||||
|
this.loader = true;
|
||||||
|
this.error = null;
|
||||||
const response = await this.userService.currentUser();
|
const response = await this.userService.currentUser();
|
||||||
if (response) {
|
if (response) {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
|
this.loader = false;
|
||||||
this.currentUser = response;
|
this.currentUser = response;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch current user", error);
|
console.error("Failed to fetch current user", error);
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"NEXT_PUBLIC_GITHUB_ID",
|
|
||||||
"NEXT_PUBLIC_GOOGLE_CLIENTID",
|
|
||||||
"NEXT_PUBLIC_API_BASE_URL",
|
"NEXT_PUBLIC_API_BASE_URL",
|
||||||
"NEXT_PUBLIC_DEPLOY_URL",
|
"NEXT_PUBLIC_DEPLOY_URL",
|
||||||
"API_BASE_URL",
|
"API_BASE_URL",
|
||||||
@ -12,8 +10,6 @@
|
|||||||
"NEXT_PUBLIC_GITHUB_APP_NAME",
|
"NEXT_PUBLIC_GITHUB_APP_NAME",
|
||||||
"NEXT_PUBLIC_ENABLE_SENTRY",
|
"NEXT_PUBLIC_ENABLE_SENTRY",
|
||||||
"NEXT_PUBLIC_ENABLE_OAUTH",
|
"NEXT_PUBLIC_ENABLE_OAUTH",
|
||||||
"NEXT_PUBLIC_UNSPLASH_ACCESS",
|
|
||||||
"NEXT_PUBLIC_UNSPLASH_ENABLED",
|
|
||||||
"NEXT_PUBLIC_TRACK_EVENTS",
|
"NEXT_PUBLIC_TRACK_EVENTS",
|
||||||
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
||||||
"NEXT_PUBLIC_CRISP_ID",
|
"NEXT_PUBLIC_CRISP_ID",
|
||||||
|
@ -1,12 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
// react hook form
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
// components
|
|
||||||
import { EmailResetPasswordForm } from "components/account";
|
|
||||||
// ui
|
// ui
|
||||||
import { Input, PrimaryButton } from "components/ui";
|
import { Input, PrimaryButton } from "components/ui";
|
||||||
// types
|
// types
|
||||||
@ -18,14 +11,12 @@ type EmailPasswordFormValues = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
|
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
|
||||||
|
setIsResettingPassword: (value: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
export const EmailPasswordForm: React.FC<Props> = (props) => {
|
||||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
const { onSubmit, setIsResettingPassword } = props;
|
||||||
|
// form info
|
||||||
const router = useRouter();
|
|
||||||
const isSignUpPage = router.pathname === "/sign-up";
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@ -42,94 +33,62 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
<form
|
||||||
{isResettingPassword
|
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
|
||||||
? "Reset your password"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
: isSignUpPage
|
>
|
||||||
? "Sign up on Plane"
|
<div className="space-y-1">
|
||||||
: "Sign in to Plane"}
|
<Input
|
||||||
</h1>
|
id="email"
|
||||||
{isResettingPassword ? (
|
type="email"
|
||||||
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
|
name="email"
|
||||||
) : (
|
register={register}
|
||||||
<form
|
validations={{
|
||||||
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
|
required: "Email address is required",
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
validate: (value) =>
|
||||||
>
|
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||||
<div className="space-y-1">
|
value
|
||||||
<Input
|
) || "Email address is not valid",
|
||||||
id="email"
|
}}
|
||||||
type="email"
|
error={errors.email}
|
||||||
name="email"
|
placeholder="Enter your email address..."
|
||||||
register={register}
|
className="border-custom-border-300 h-[46px]"
|
||||||
validations={{
|
/>
|
||||||
required: "Email address is required",
|
</div>
|
||||||
validate: (value) =>
|
<div className="space-y-1">
|
||||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
<Input
|
||||||
value
|
id="password"
|
||||||
) || "Email address is not valid",
|
type="password"
|
||||||
}}
|
name="password"
|
||||||
error={errors.email}
|
register={register}
|
||||||
placeholder="Enter your email address..."
|
validations={{
|
||||||
className="border-custom-border-300 h-[46px]"
|
required: "Password is required",
|
||||||
/>
|
}}
|
||||||
</div>
|
error={errors.password}
|
||||||
<div className="space-y-1">
|
placeholder="Enter your password..."
|
||||||
<Input
|
className="border-custom-border-300 h-[46px]"
|
||||||
id="password"
|
/>
|
||||||
type="password"
|
</div>
|
||||||
name="password"
|
<div className="text-right text-xs">
|
||||||
register={register}
|
<button
|
||||||
validations={{
|
type="button"
|
||||||
required: "Password is required",
|
onClick={() => setIsResettingPassword(true)}
|
||||||
}}
|
className="text-custom-text-200 hover:text-custom-primary-100"
|
||||||
error={errors.password}
|
>
|
||||||
placeholder="Enter your password..."
|
Forgot your password?
|
||||||
className="border-custom-border-300 h-[46px]"
|
</button>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div className="text-right text-xs">
|
<PrimaryButton
|
||||||
{isSignUpPage ? (
|
type="submit"
|
||||||
<Link href="/">
|
className="w-full text-center h-[46px]"
|
||||||
<a className="text-custom-text-200 hover:text-custom-primary-100">
|
disabled={!isValid && isDirty}
|
||||||
Already have an account? Sign in.
|
loading={isSubmitting}
|
||||||
</a>
|
>
|
||||||
</Link>
|
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||||
) : (
|
</PrimaryButton>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</form>
|
||||||
onClick={() => setIsResettingPassword(true)}
|
|
||||||
className="text-custom-text-200 hover:text-custom-primary-100"
|
|
||||||
>
|
|
||||||
Forgot your password?
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<PrimaryButton
|
|
||||||
type="submit"
|
|
||||||
className="w-full text-center h-[46px]"
|
|
||||||
disabled={!isValid && isDirty}
|
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSignUpPage
|
|
||||||
? isSubmitting
|
|
||||||
? "Signing up..."
|
|
||||||
: "Sign up"
|
|
||||||
: isSubmitting
|
|
||||||
? "Signing in..."
|
|
||||||
: "Sign in"}
|
|
||||||
</PrimaryButton>
|
|
||||||
{!isSignUpPage && (
|
|
||||||
<Link href="/sign-up">
|
|
||||||
<a className="block text-custom-text-200 hover:text-custom-primary-100 text-xs mt-4">
|
|
||||||
Don{"'"}t have an account? Sign up.
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
114
web/components/account/email-signup-form.tsx
Normal file
114
web/components/account/email-signup-form.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
// ui
|
||||||
|
import { Input, PrimaryButton } from "components/ui";
|
||||||
|
// types
|
||||||
|
type EmailPasswordFormValues = {
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
confirm_password: string;
|
||||||
|
medium?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmailSignUpForm: React.FC<Props> = (props) => {
|
||||||
|
const { onSubmit } = props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
formState: { errors, isSubmitting, isValid, isDirty },
|
||||||
|
} = useForm<EmailPasswordFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
confirm_password: "",
|
||||||
|
medium: "email",
|
||||||
|
},
|
||||||
|
mode: "onChange",
|
||||||
|
reValidateMode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form
|
||||||
|
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
register={register}
|
||||||
|
validations={{
|
||||||
|
required: "Email address is required",
|
||||||
|
validate: (value) =>
|
||||||
|
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||||
|
value
|
||||||
|
) || "Email address is not valid",
|
||||||
|
}}
|
||||||
|
error={errors.email}
|
||||||
|
placeholder="Enter your email address..."
|
||||||
|
className="border-custom-border-300 h-[46px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
register={register}
|
||||||
|
validations={{
|
||||||
|
required: "Password is required",
|
||||||
|
}}
|
||||||
|
error={errors.password}
|
||||||
|
placeholder="Enter your password..."
|
||||||
|
className="border-custom-border-300 h-[46px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
id="confirm_password"
|
||||||
|
type="password"
|
||||||
|
name="confirm_password"
|
||||||
|
register={register}
|
||||||
|
validations={{
|
||||||
|
required: "Password is required",
|
||||||
|
validate: (val: string) => {
|
||||||
|
if (watch("password") != val) {
|
||||||
|
return "Your passwords do no match";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
error={errors.confirm_password}
|
||||||
|
placeholder="Confirm your password..."
|
||||||
|
className="border-custom-border-300 h-[46px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs">
|
||||||
|
<Link href="/">
|
||||||
|
<a className="text-custom-text-200 hover:text-custom-primary-100">
|
||||||
|
Already have an account? Sign in.
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<PrimaryButton
|
||||||
|
type="submit"
|
||||||
|
className="w-full text-center h-[46px]"
|
||||||
|
disabled={!isValid && isDirty}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Signing up..." : "Sign up"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,29 +1,27 @@
|
|||||||
import { useEffect, useState, FC } from "react";
|
import { useEffect, useState, FC } from "react";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
// next-themes
|
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
// images
|
// images
|
||||||
import githubBlackImage from "/public/logos/github-black.png";
|
import githubBlackImage from "/public/logos/github-black.png";
|
||||||
import githubWhiteImage from "/public/logos/github-white.png";
|
import githubWhiteImage from "/public/logos/github-white.png";
|
||||||
|
|
||||||
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
|
|
||||||
|
|
||||||
export interface GithubLoginButtonProps {
|
export interface GithubLoginButtonProps {
|
||||||
handleSignIn: React.Dispatch<string>;
|
handleSignIn: React.Dispatch<string>;
|
||||||
|
clientId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
|
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||||
|
const { handleSignIn, clientId } = props;
|
||||||
|
// states
|
||||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||||
|
// router
|
||||||
const {
|
const {
|
||||||
query: { code },
|
query: { code },
|
||||||
} = useRouter();
|
} = useRouter();
|
||||||
|
// theme
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -42,7 +40,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
|
|||||||
return (
|
return (
|
||||||
<div className="w-full flex justify-center items-center">
|
<div className="w-full flex justify-center items-center">
|
||||||
<Link
|
<Link
|
||||||
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||||
>
|
>
|
||||||
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
||||||
<Image
|
<Image
|
||||||
|
@ -1,22 +1,23 @@
|
|||||||
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
|
import { FC, useEffect, useRef, useCallback, useState } from "react";
|
||||||
|
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
|
||||||
export interface IGoogleLoginButton {
|
export interface IGoogleLoginButton {
|
||||||
text?: string;
|
|
||||||
handleSignIn: React.Dispatch<any>;
|
handleSignIn: React.Dispatch<any>;
|
||||||
styles?: CSSProperties;
|
clientId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||||
|
const { handleSignIn, clientId } = props;
|
||||||
|
// refs
|
||||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||||
|
// states
|
||||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||||
|
|
||||||
const loadScript = useCallback(() => {
|
const loadScript = useCallback(() => {
|
||||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||||
|
|
||||||
window?.google?.accounts.id.initialize({
|
window?.google?.accounts.id.initialize({
|
||||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
|
client_id: clientId,
|
||||||
callback: handleSignIn,
|
callback: handleSignIn,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -39,7 +40,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
|||||||
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
||||||
|
|
||||||
setGsiScriptLoaded(true);
|
setGsiScriptLoaded(true);
|
||||||
}, [handleSignIn, gsiScriptLoaded]);
|
}, [handleSignIn, gsiScriptLoaded, clientId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window?.google?.accounts?.id) {
|
if (window?.google?.accounts?.id) {
|
||||||
|
@ -3,3 +3,4 @@ export * from "./email-password-form";
|
|||||||
export * from "./email-reset-password-form";
|
export * from "./email-reset-password-form";
|
||||||
export * from "./github-login-button";
|
export * from "./github-login-button";
|
||||||
export * from "./google-login";
|
export * from "./google-login";
|
||||||
|
export * from "./email-signup-form";
|
||||||
|
@ -13,9 +13,14 @@ import { IProject } from "types";
|
|||||||
type Props = {
|
type Props = {
|
||||||
projectDetails: IProject | undefined;
|
projectDetails: IProject | undefined;
|
||||||
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
|
export const AutoArchiveAutomation: React.FC<Props> = ({
|
||||||
|
projectDetails,
|
||||||
|
handleChange,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
const [monthModal, setmonthModal] = useState(false);
|
const [monthModal, setmonthModal] = useState(false);
|
||||||
|
|
||||||
const initialValues: Partial<IProject> = { archive_in: 1 };
|
const initialValues: Partial<IProject> = { archive_in: 1 };
|
||||||
@ -49,6 +54,7 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
|
|||||||
: handleChange({ archive_in: 0 })
|
: handleChange({ archive_in: 0 })
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -70,6 +76,7 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
|
|||||||
input
|
input
|
||||||
verticalPosition="bottom"
|
verticalPosition="bottom"
|
||||||
width="w-full"
|
width="w-full"
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||||
|
@ -24,9 +24,14 @@ import { getStatesList } from "helpers/state.helper";
|
|||||||
type Props = {
|
type Props = {
|
||||||
projectDetails: IProject | undefined;
|
projectDetails: IProject | undefined;
|
||||||
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
|
export const AutoCloseAutomation: React.FC<Props> = ({
|
||||||
|
projectDetails,
|
||||||
|
handleChange,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
const [monthModal, setmonthModal] = useState(false);
|
const [monthModal, setmonthModal] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -98,6 +103,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
|
|||||||
: handleChange({ close_in: 0, default_state: null })
|
: handleChange({ close_in: 0, default_state: null })
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -119,6 +125,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
|
|||||||
}}
|
}}
|
||||||
input
|
input
|
||||||
width="w-full"
|
width="w-full"
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||||
|
@ -161,7 +161,6 @@ export const CommandPalette: React.FC = observer(() => {
|
|||||||
/>
|
/>
|
||||||
<CreateUpdateViewModal
|
<CreateUpdateViewModal
|
||||||
handleClose={() => setIsCreateViewModalOpen(false)}
|
handleClose={() => setIsCreateViewModalOpen(false)}
|
||||||
viewType="project"
|
|
||||||
isOpen={isCreateViewModalOpen}
|
isOpen={isCreateViewModalOpen}
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
|
@ -10,14 +10,7 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
|||||||
// helpers
|
// helpers
|
||||||
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||||
// types
|
// types
|
||||||
import {
|
import { IIssueFilterOptions, IIssueLabels, IState, IUserLite, TStateGroups } from "types";
|
||||||
IIssueFilterOptions,
|
|
||||||
IIssueLabels,
|
|
||||||
IProject,
|
|
||||||
IState,
|
|
||||||
IUserLite,
|
|
||||||
TStateGroups,
|
|
||||||
} from "types";
|
|
||||||
// constants
|
// constants
|
||||||
import { STATE_GROUP_COLORS } from "constants/state";
|
import { STATE_GROUP_COLORS } from "constants/state";
|
||||||
|
|
||||||
@ -27,9 +20,7 @@ type Props = {
|
|||||||
clearAllFilters: (...args: any) => void;
|
clearAllFilters: (...args: any) => void;
|
||||||
labels: IIssueLabels[] | undefined;
|
labels: IIssueLabels[] | undefined;
|
||||||
members: IUserLite[] | undefined;
|
members: IUserLite[] | undefined;
|
||||||
states?: IState[] | undefined;
|
states: IState[] | undefined;
|
||||||
stateGroup?: string[] | undefined;
|
|
||||||
project?: IProject[] | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FiltersList: React.FC<Props> = ({
|
export const FiltersList: React.FC<Props> = ({
|
||||||
@ -39,7 +30,6 @@ export const FiltersList: React.FC<Props> = ({
|
|||||||
labels,
|
labels,
|
||||||
members,
|
members,
|
||||||
states,
|
states,
|
||||||
project,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!filters) return <></>;
|
if (!filters) return <></>;
|
||||||
|
|
||||||
@ -165,29 +155,6 @@ export const FiltersList: React.FC<Props> = ({
|
|||||||
: key === "assignees"
|
: key === "assignees"
|
||||||
? filters.assignees?.map((memberId: string) => {
|
? filters.assignees?.map((memberId: string) => {
|
||||||
const member = members?.find((m) => m.id === memberId);
|
const member = members?.find((m) => m.id === memberId);
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={memberId}
|
|
||||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
|
||||||
>
|
|
||||||
<Avatar user={member} />
|
|
||||||
<span>{member?.display_name}</span>
|
|
||||||
<span
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
setFilters({
|
|
||||||
assignees: filters.assignees?.filter((p: any) => p !== memberId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: key === "subscriber"
|
|
||||||
? filters.subscriber?.map((memberId: string) => {
|
|
||||||
const member = members?.find((m) => m.id === memberId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -333,30 +300,6 @@ export const FiltersList: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
: key === "project"
|
|
||||||
? filters.project?.map((projectId) => {
|
|
||||||
const currentProject = project?.find((p) => p.id === projectId);
|
|
||||||
console.log("currentProject", currentProject);
|
|
||||||
console.log("currentProject", projectId);
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
key={currentProject?.id}
|
|
||||||
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
|
|
||||||
>
|
|
||||||
<span>{currentProject?.name}</span>
|
|
||||||
<span
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
setFilters({
|
|
||||||
project: filters.project?.filter((p) => p !== projectId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: (filters[key] as any)?.join(", ")}
|
: (filters[key] as any)?.join(", ")}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
export * from "./date-filter-modal";
|
export * from "./date-filter-modal";
|
||||||
export * from "./date-filter-select";
|
export * from "./date-filter-select";
|
||||||
export * from "./filters-list";
|
export * from "./filters-list";
|
||||||
|
export * from "./workspace-filters-list";
|
||||||
export * from "./issues-view-filter";
|
export * from "./issues-view-filter";
|
||||||
|
364
web/components/core/filters/workspace-filters-list.tsx
Normal file
364
web/components/core/filters/workspace-filters-list.tsx
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { PriorityIcon, StateGroupIcon } from "components/icons";
|
||||||
|
// ui
|
||||||
|
import { Avatar } from "components/ui";
|
||||||
|
// helpers
|
||||||
|
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||||
|
// helpers
|
||||||
|
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||||
|
// types
|
||||||
|
import {
|
||||||
|
IIssueLabels,
|
||||||
|
IProject,
|
||||||
|
IUserLite,
|
||||||
|
IWorkspaceIssueFilterOptions,
|
||||||
|
TStateGroups,
|
||||||
|
} from "types";
|
||||||
|
// constants
|
||||||
|
import { STATE_GROUP_COLORS } from "constants/state";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filters: Partial<IWorkspaceIssueFilterOptions>;
|
||||||
|
setFilters: (updatedFilter: Partial<IWorkspaceIssueFilterOptions>) => void;
|
||||||
|
clearAllFilters: (...args: any) => void;
|
||||||
|
labels: IIssueLabels[] | undefined;
|
||||||
|
members: IUserLite[] | undefined;
|
||||||
|
stateGroup: string[] | undefined;
|
||||||
|
project?: IProject[] | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceFiltersList: React.FC<Props> = ({
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
clearAllFilters,
|
||||||
|
labels,
|
||||||
|
members,
|
||||||
|
stateGroup,
|
||||||
|
project,
|
||||||
|
}) => {
|
||||||
|
if (!filters) return <></>;
|
||||||
|
|
||||||
|
const nullFilters = Object.keys(filters).filter(
|
||||||
|
(key) => filters[key as keyof IWorkspaceIssueFilterOptions] === null
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
|
||||||
|
{Object.keys(filters).map((filterKey) => {
|
||||||
|
const key = filterKey as keyof typeof filters;
|
||||||
|
|
||||||
|
if (filters[key] === null || (filters[key]?.length ?? 0) <= 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-x-2 rounded-full border border-custom-border-200 bg-custom-background-80 px-2 py-1"
|
||||||
|
>
|
||||||
|
<span className="capitalize text-custom-text-200">
|
||||||
|
{key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}:
|
||||||
|
</span>
|
||||||
|
{filters[key] === null || (filters[key]?.length ?? 0) <= 0 ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
|
||||||
|
) : Array.isArray(filters[key]) ? (
|
||||||
|
<div className="space-x-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{key === "state_group"
|
||||||
|
? filters.state_group?.map((stateGroup) => {
|
||||||
|
const group = stateGroup as TStateGroups;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={group}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
|
||||||
|
style={{
|
||||||
|
color: STATE_GROUP_COLORS[group],
|
||||||
|
backgroundColor: `${STATE_GROUP_COLORS[group]}20`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<StateGroupIcon stateGroup={group} color={undefined} />
|
||||||
|
</span>
|
||||||
|
<span>{group}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
state_group: filters.state_group?.filter((g) => g !== group),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: key === "priority"
|
||||||
|
? filters.priority?.map((priority: any) => (
|
||||||
|
<p
|
||||||
|
key={priority}
|
||||||
|
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${
|
||||||
|
priority === "urgent"
|
||||||
|
? "bg-red-500/20 text-red-500"
|
||||||
|
: priority === "high"
|
||||||
|
? "bg-orange-500/20 text-orange-500"
|
||||||
|
: priority === "medium"
|
||||||
|
? "bg-yellow-500/20 text-yellow-500"
|
||||||
|
: priority === "low"
|
||||||
|
? "bg-green-500/20 text-green-500"
|
||||||
|
: "bg-custom-background-90 text-custom-text-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<PriorityIcon priority={priority} />
|
||||||
|
</span>
|
||||||
|
<span>{priority === "null" ? "None" : priority}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
priority: filters.priority?.filter((p: any) => p !== priority),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
: key === "assignees"
|
||||||
|
? filters.assignees?.map((memberId: string) => {
|
||||||
|
const member = members?.find((m) => m.id === memberId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={memberId}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
||||||
|
>
|
||||||
|
<Avatar user={member} />
|
||||||
|
<span>{member?.display_name}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
assignees: filters.assignees?.filter((p: any) => p !== memberId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: key === "subscriber"
|
||||||
|
? filters.subscriber?.map((memberId: string) => {
|
||||||
|
const member = members?.find((m) => m.id === memberId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={memberId}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
||||||
|
>
|
||||||
|
<Avatar user={member} />
|
||||||
|
<span>{member?.display_name}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
assignees: filters.assignees?.filter((p: any) => p !== memberId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: key === "created_by"
|
||||||
|
? filters.created_by?.map((memberId: string) => {
|
||||||
|
const member = members?.find((m) => m.id === memberId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${memberId}-${key}`}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
|
||||||
|
>
|
||||||
|
<Avatar user={member} />
|
||||||
|
<span>{member?.display_name}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
created_by: filters.created_by?.filter(
|
||||||
|
(p: any) => p !== memberId
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: key === "labels"
|
||||||
|
? filters.labels?.map((labelId: string) => {
|
||||||
|
const label = labels?.find((l) => l.id === labelId);
|
||||||
|
|
||||||
|
if (!label) return null;
|
||||||
|
const color = label.color !== "" ? label.color : "#0f172a";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5"
|
||||||
|
style={{
|
||||||
|
color: color,
|
||||||
|
backgroundColor: `${color}20`, // add 20% opacity
|
||||||
|
}}
|
||||||
|
key={labelId}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-1.5 w-1.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{label.name}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
labels: filters.labels?.filter((l: any) => l !== labelId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon
|
||||||
|
className="h-3 w-3"
|
||||||
|
style={{
|
||||||
|
color: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: key === "start_date"
|
||||||
|
? filters.start_date?.map((date: string) => {
|
||||||
|
if (filters.start_date && filters.start_date.length <= 0) return null;
|
||||||
|
|
||||||
|
const splitDate = date.split(";");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
|
||||||
|
>
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full" />
|
||||||
|
<span className="capitalize">
|
||||||
|
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
start_date: filters.start_date?.filter((d: any) => d !== date),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: key === "target_date"
|
||||||
|
? filters.target_date?.map((date: string) => {
|
||||||
|
if (filters.target_date && filters.target_date.length <= 0) return null;
|
||||||
|
|
||||||
|
const splitDate = date.split(";");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
|
||||||
|
>
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full" />
|
||||||
|
<span className="capitalize">
|
||||||
|
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
target_date: filters.target_date?.filter((d: any) => d !== date),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: key === "project"
|
||||||
|
? filters.project?.map((projectId) => {
|
||||||
|
const currentProject = project?.find((p) => p.id === projectId);
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={currentProject?.id}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
|
||||||
|
>
|
||||||
|
<span>{currentProject?.name}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
project: filters.project?.filter((p) => p !== projectId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: (filters[key] as any)?.join(", ")}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
[key]: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-x-1 capitalize">
|
||||||
|
{filters[key as keyof typeof filters]}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
[key]: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearAllFilters}
|
||||||
|
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<span>Clear all filters</span>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,32 +1,23 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||||
|
|
||||||
// next
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
// swr
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
// react-dropdown
|
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
|
|
||||||
// headless ui
|
|
||||||
import { Tab, Transition, Popover } from "@headlessui/react";
|
import { Tab, Transition, Popover } from "@headlessui/react";
|
||||||
|
|
||||||
// services
|
// services
|
||||||
import fileService from "services/file.service";
|
import fileService from "services/file.service";
|
||||||
|
|
||||||
// components
|
|
||||||
import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui";
|
|
||||||
// hooks
|
// hooks
|
||||||
import useWorkspaceDetails from "hooks/use-workspace-details";
|
import useWorkspaceDetails from "hooks/use-workspace-details";
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
|
// components
|
||||||
const unsplashEnabled =
|
import { Input, PrimaryButton, SecondaryButton, Loader } from "components/ui";
|
||||||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
|
|
||||||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1";
|
|
||||||
|
|
||||||
const tabOptions = [
|
const tabOptions = [
|
||||||
|
{
|
||||||
|
key: "unsplash",
|
||||||
|
title: "Unsplash",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "images",
|
key: "images",
|
||||||
title: "Images",
|
title: "Images",
|
||||||
@ -64,8 +55,22 @@ export const ImagePickerPopover: React.FC<Props> = ({
|
|||||||
search: "",
|
search: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () =>
|
const { data: unsplashImages, error: unsplashError } = useSWR(
|
||||||
fileService.getUnsplashImages(1, searchParams)
|
`UNSPLASH_IMAGES_${searchParams}`,
|
||||||
|
() => fileService.getUnsplashImages(searchParams),
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: projectCoverImages } = useSWR(
|
||||||
|
`PROJECT_COVER_IMAGES`,
|
||||||
|
() => fileService.getProjectCoverImages(),
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const imagePickerRef = useRef<HTMLDivElement>(null);
|
const imagePickerRef = useRef<HTMLDivElement>(null);
|
||||||
@ -115,18 +120,17 @@ export const ImagePickerPopover: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!images || value !== null) return;
|
if (!unsplashImages || value !== null) return;
|
||||||
onChange(images[0].urls.regular);
|
|
||||||
}, [value, onChange, images]);
|
onChange(unsplashImages[0].urls.regular);
|
||||||
|
}, [value, onChange, unsplashImages]);
|
||||||
|
|
||||||
useOutsideClickDetector(imagePickerRef, () => setIsOpen(false));
|
useOutsideClickDetector(imagePickerRef, () => setIsOpen(false));
|
||||||
|
|
||||||
if (!unsplashEnabled) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative z-[2]" ref={ref}>
|
<Popover className="relative z-[2]" ref={ref}>
|
||||||
<Popover.Button
|
<Popover.Button
|
||||||
className="rounded-sm border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
||||||
onClick={() => setIsOpen((prev) => !prev)}
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
@ -141,15 +145,19 @@ export const ImagePickerPopover: React.FC<Props> = ({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-80 shadow-lg">
|
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-sm">
|
||||||
<div
|
<div
|
||||||
ref={imagePickerRef}
|
ref={imagePickerRef}
|
||||||
className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl"
|
className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl"
|
||||||
>
|
>
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<div>
|
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
|
||||||
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
|
{tabOptions.map((tab) => {
|
||||||
{tabOptions.map((tab) => (
|
if (!unsplashImages && unsplashError && tab.key === "unsplash") return null;
|
||||||
|
if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images")
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
@ -160,50 +168,106 @@ export const ImagePickerPopover: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
{tab.title}
|
{tab.title}
|
||||||
</Tab>
|
</Tab>
|
||||||
))}
|
);
|
||||||
</Tab.List>
|
})}
|
||||||
</div>
|
</Tab.List>
|
||||||
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
|
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
<Tab.Panel className="h-full w-full space-y-4">
|
{(unsplashImages || !unsplashError) && (
|
||||||
<div className="flex gap-x-2 pt-7">
|
<Tab.Panel className="h-full w-full space-y-4 mt-4">
|
||||||
<Input
|
<div className="flex gap-x-2">
|
||||||
name="search"
|
<Input
|
||||||
className="text-sm"
|
name="search"
|
||||||
id="search"
|
className="text-sm"
|
||||||
value={formData.search}
|
id="search"
|
||||||
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
|
value={formData.search}
|
||||||
placeholder="Search for images"
|
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
|
||||||
/>
|
placeholder="Search for images"
|
||||||
<PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm">
|
/>
|
||||||
Search
|
<PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm">
|
||||||
</PrimaryButton>
|
Search
|
||||||
</div>
|
</PrimaryButton>
|
||||||
{images ? (
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-4">
|
{unsplashImages ? (
|
||||||
{images.map((image) => (
|
unsplashImages.length > 0 ? (
|
||||||
<div
|
<div className="grid grid-cols-4 gap-4">
|
||||||
key={image.id}
|
{unsplashImages.map((image) => (
|
||||||
className="relative col-span-2 aspect-video md:col-span-1"
|
<div
|
||||||
>
|
key={image.id}
|
||||||
<img
|
className="relative col-span-2 aspect-video md:col-span-1"
|
||||||
src={image.urls.small}
|
onClick={() => {
|
||||||
alt={image.alt_description}
|
setIsOpen(false);
|
||||||
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
|
onChange(image.urls.regular);
|
||||||
onClick={() => {
|
}}
|
||||||
setIsOpen(false);
|
>
|
||||||
onChange(image.urls.regular);
|
<img
|
||||||
}}
|
src={image.urls.small}
|
||||||
/>
|
alt={image.alt_description}
|
||||||
|
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (
|
||||||
</div>
|
<p className="text-center text-custom-text-300 text-xs pt-7">
|
||||||
) : (
|
No images found.
|
||||||
<div className="flex justify-center pt-20">
|
</p>
|
||||||
<Spinner />
|
)
|
||||||
</div>
|
) : (
|
||||||
)}
|
<Loader className="grid grid-cols-4 gap-4">
|
||||||
</Tab.Panel>
|
<Loader.Item height="80px" width="100%" />
|
||||||
<Tab.Panel className="h-full w-full pt-5">
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
</Loader>
|
||||||
|
)}
|
||||||
|
</Tab.Panel>
|
||||||
|
)}
|
||||||
|
{(!projectCoverImages || projectCoverImages.length !== 0) && (
|
||||||
|
<Tab.Panel className="h-full w-full space-y-4 mt-4">
|
||||||
|
{projectCoverImages ? (
|
||||||
|
projectCoverImages.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{projectCoverImages.map((image, index) => (
|
||||||
|
<div
|
||||||
|
key={image}
|
||||||
|
className="relative col-span-2 aspect-video md:col-span-1"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onChange(image);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={`Default project cover image- ${index}`}
|
||||||
|
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-custom-text-300 text-xs pt-7">
|
||||||
|
No images found.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Loader className="grid grid-cols-4 gap-4 pt-4">
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
<Loader.Item height="80px" width="100%" />
|
||||||
|
</Loader>
|
||||||
|
)}
|
||||||
|
</Tab.Panel>
|
||||||
|
)}
|
||||||
|
<Tab.Panel className="h-full w-full mt-4">
|
||||||
<div className="w-full h-full flex flex-col gap-y-2">
|
<div className="w-full h-full flex flex-col gap-y-2">
|
||||||
<div className="flex items-center gap-3 w-full flex-1">
|
<div className="flex items-center gap-3 w-full flex-1">
|
||||||
<div
|
<div
|
||||||
|
@ -6,6 +6,7 @@ import { useRouter } from "next/router";
|
|||||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||||
import { Draggable } from "react-beautiful-dnd";
|
import { Draggable } from "react-beautiful-dnd";
|
||||||
// components
|
// components
|
||||||
|
import { CreateUpdateDraftIssueModal } from "components/issues";
|
||||||
import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core";
|
import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core";
|
||||||
// ui
|
// ui
|
||||||
import { CustomMenu } from "components/ui";
|
import { CustomMenu } from "components/ui";
|
||||||
@ -57,6 +58,7 @@ export const SingleBoard: React.FC<Props> = (props) => {
|
|||||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||||
|
|
||||||
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
|
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
|
||||||
|
const [isCreateDraftIssueModalOpen, setIsCreateDraftIssueModalOpen] = useState(false);
|
||||||
|
|
||||||
const { displayFilters, groupedIssues } = viewProps;
|
const { displayFilters, groupedIssues } = viewProps;
|
||||||
|
|
||||||
@ -96,10 +98,27 @@ export const SingleBoard: React.FC<Props> = (props) => {
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddIssueToGroup = () => {
|
||||||
|
if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true);
|
||||||
|
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
|
||||||
|
else onCreateClick();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
|
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
|
||||||
|
<CreateUpdateDraftIssueModal
|
||||||
|
isOpen={isCreateDraftIssueModalOpen}
|
||||||
|
handleClose={() => setIsCreateDraftIssueModalOpen(false)}
|
||||||
|
prePopulateData={{
|
||||||
|
...(cycleId && { cycle: cycleId.toString() }),
|
||||||
|
...(moduleId && { module: moduleId.toString() }),
|
||||||
|
[displayFilters?.group_by! === "labels" ? "labels_list" : displayFilters?.group_by!]:
|
||||||
|
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<BoardHeader
|
<BoardHeader
|
||||||
addIssueToGroup={addIssueToGroup}
|
addIssueToGroup={handleAddIssueToGroup}
|
||||||
currentState={currentState}
|
currentState={currentState}
|
||||||
groupTitle={groupTitle}
|
groupTitle={groupTitle}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
@ -218,21 +237,22 @@ export const SingleBoard: React.FC<Props> = (props) => {
|
|||||||
{displayFilters?.group_by !== "created_by" && (
|
{displayFilters?.group_by !== "created_by" && (
|
||||||
<div>
|
<div>
|
||||||
{type === "issue"
|
{type === "issue"
|
||||||
? !disableAddIssueOption && (
|
? !disableAddIssueOption &&
|
||||||
|
!isDraftIssuesPage && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
|
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
|
if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
|
||||||
addIssueToGroup();
|
else onCreateClick();
|
||||||
} else onCreateClick();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
Add Issue
|
Add Issue
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
: !disableUserActions && (
|
: !disableUserActions &&
|
||||||
|
!isDraftIssuesPage && (
|
||||||
<CustomMenu
|
<CustomMenu
|
||||||
customButton={
|
customButton={
|
||||||
<button
|
<button
|
||||||
@ -246,7 +266,13 @@ export const SingleBoard: React.FC<Props> = (props) => {
|
|||||||
position="left"
|
position="left"
|
||||||
noBorder
|
noBorder
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem onClick={() => onCreateClick()}>
|
<CustomMenu.MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true);
|
||||||
|
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
|
||||||
|
else onCreateClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
Create new
|
Create new
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
{openIssuesListModal && (
|
{openIssuesListModal && (
|
||||||
|
@ -483,7 +483,6 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
<CreateUpdateViewModal
|
<CreateUpdateViewModal
|
||||||
isOpen={createViewModal !== null}
|
isOpen={createViewModal !== null}
|
||||||
handleClose={() => setCreateViewModal(null)}
|
handleClose={() => setCreateViewModal(null)}
|
||||||
viewType="project"
|
|
||||||
preLoadedData={createViewModal}
|
preLoadedData={createViewModal}
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
|
@ -13,6 +13,7 @@ import projectService from "services/project.service";
|
|||||||
// hooks
|
// hooks
|
||||||
import useProjects from "hooks/use-projects";
|
import useProjects from "hooks/use-projects";
|
||||||
// components
|
// components
|
||||||
|
import { CreateUpdateDraftIssueModal } from "components/issues";
|
||||||
import { SingleListIssue, ListInlineCreateIssueForm } from "components/core";
|
import { SingleListIssue, ListInlineCreateIssueForm } from "components/core";
|
||||||
// ui
|
// ui
|
||||||
import { Avatar, CustomMenu } from "components/ui";
|
import { Avatar, CustomMenu } from "components/ui";
|
||||||
@ -75,6 +76,7 @@ export const SingleList: React.FC<Props> = (props) => {
|
|||||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
|
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
|
||||||
|
const [isDraftIssuesModalOpen, setIsDraftIssuesModalOpen] = useState(false);
|
||||||
|
|
||||||
const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues";
|
const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues";
|
||||||
const isProfileIssuesPage = router.pathname.split("/")[2] === "profile";
|
const isProfileIssuesPage = router.pathname.split("/")[2] === "profile";
|
||||||
@ -208,154 +210,169 @@ export const SingleList: React.FC<Props> = (props) => {
|
|||||||
if (!groupedIssues) return null;
|
if (!groupedIssues) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Disclosure as="div" defaultOpen>
|
<>
|
||||||
{({ open }) => (
|
<CreateUpdateDraftIssueModal
|
||||||
<div>
|
isOpen={isDraftIssuesModalOpen}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 bg-custom-background-90">
|
handleClose={() => setIsDraftIssuesModalOpen(false)}
|
||||||
<Disclosure.Button>
|
prePopulateData={{
|
||||||
<div className="flex items-center gap-x-3">
|
...(cycleId && { cycle: cycleId.toString() }),
|
||||||
{displayFilters?.group_by !== null && (
|
...(moduleId && { module: moduleId.toString() }),
|
||||||
<div className="flex items-center">{getGroupIcon()}</div>
|
[displayFilters?.group_by! === "labels" ? "labels_list" : displayFilters?.group_by!]:
|
||||||
)}
|
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
|
||||||
{displayFilters?.group_by !== null ? (
|
}}
|
||||||
<h2
|
/>
|
||||||
className={`text-sm font-semibold leading-6 text-custom-text-100 ${
|
|
||||||
displayFilters?.group_by === "created_by" ? "" : "capitalize"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{getGroupTitle()}
|
|
||||||
</h2>
|
|
||||||
) : (
|
|
||||||
<h2 className="font-medium leading-5">All Issues</h2>
|
|
||||||
)}
|
|
||||||
<span className="text-custom-text-200 min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs">
|
|
||||||
{groupedIssues[groupTitle as keyof IIssue].length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Disclosure.Button>
|
|
||||||
{isArchivedIssues ? (
|
|
||||||
""
|
|
||||||
) : type === "issue" ? (
|
|
||||||
!disableAddIssueOption && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
|
|
||||||
onClick={() => {
|
|
||||||
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
|
|
||||||
addIssueToGroup();
|
|
||||||
} else setIsCreateIssueFormOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
) : disableUserActions ? (
|
|
||||||
""
|
|
||||||
) : (
|
|
||||||
<CustomMenu
|
|
||||||
customButton={
|
|
||||||
<div className="flex cursor-pointer items-center">
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
position="right"
|
|
||||||
noBorder
|
|
||||||
>
|
|
||||||
<CustomMenu.MenuItem onClick={() => setIsCreateIssueFormOpen(true)}>
|
|
||||||
Create new
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
{openIssuesListModal && (
|
|
||||||
<CustomMenu.MenuItem onClick={openIssuesListModal}>
|
|
||||||
Add an existing issue
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
)}
|
|
||||||
</CustomMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Transition
|
|
||||||
show={open}
|
|
||||||
enter="transition duration-100 ease-out"
|
|
||||||
enterFrom="transform opacity-0"
|
|
||||||
enterTo="transform opacity-100"
|
|
||||||
leave="transition duration-75 ease-out"
|
|
||||||
leaveFrom="transform opacity-100"
|
|
||||||
leaveTo="transform opacity-0"
|
|
||||||
>
|
|
||||||
<Disclosure.Panel>
|
|
||||||
{groupedIssues[groupTitle] ? (
|
|
||||||
groupedIssues[groupTitle].length > 0 ? (
|
|
||||||
groupedIssues[groupTitle].map((issue, index) => (
|
|
||||||
<SingleListIssue
|
|
||||||
key={issue.id}
|
|
||||||
type={type}
|
|
||||||
issue={issue}
|
|
||||||
projectId={issue.project_detail.id}
|
|
||||||
groupTitle={groupTitle}
|
|
||||||
index={index}
|
|
||||||
editIssue={() => handleIssueAction(issue, "edit")}
|
|
||||||
makeIssueCopy={() => handleIssueAction(issue, "copy")}
|
|
||||||
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
|
|
||||||
handleDraftIssueSelect={
|
|
||||||
handleDraftIssueAction
|
|
||||||
? () => handleDraftIssueAction(issue, "edit")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
handleDraftIssueDelete={
|
|
||||||
handleDraftIssueAction
|
|
||||||
? () => handleDraftIssueAction(issue, "delete")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
handleMyIssueOpen={handleMyIssueOpen}
|
|
||||||
removeIssue={() => {
|
|
||||||
if (removeIssue !== null && issue.bridge_id)
|
|
||||||
removeIssue(issue.bridge_id, issue.id);
|
|
||||||
}}
|
|
||||||
disableUserActions={disableUserActions}
|
|
||||||
user={user}
|
|
||||||
userAuth={userAuth}
|
|
||||||
viewProps={viewProps}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="bg-custom-background-100 px-4 py-2.5 text-sm text-custom-text-200">
|
|
||||||
No issues.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">Loading...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ListInlineCreateIssueForm
|
<Disclosure as="div" defaultOpen>
|
||||||
isOpen={isCreateIssueFormOpen && !disableAddIssueOption}
|
{({ open }) => (
|
||||||
handleClose={() => setIsCreateIssueFormOpen(false)}
|
<div>
|
||||||
prePopulatedData={{
|
<div className="flex items-center justify-between px-4 py-2.5 bg-custom-background-90">
|
||||||
...(cycleId && { cycle: cycleId.toString() }),
|
<Disclosure.Button>
|
||||||
...(moduleId && { module: moduleId.toString() }),
|
<div className="flex items-center gap-x-3">
|
||||||
[displayFilters?.group_by!]: groupTitle,
|
{displayFilters?.group_by !== null && (
|
||||||
}}
|
<div className="flex items-center">{getGroupIcon()}</div>
|
||||||
/>
|
)}
|
||||||
|
{displayFilters?.group_by !== null ? (
|
||||||
{!disableAddIssueOption && !isCreateIssueFormOpen && (
|
<h2
|
||||||
// TODO: add border here
|
className={`text-sm font-semibold leading-6 text-custom-text-100 ${
|
||||||
<div className="w-full bg-custom-background-100 px-6 py-3 border-b border-custom-border-100">
|
displayFilters?.group_by === "created_by" ? "" : "capitalize"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getGroupTitle()}
|
||||||
|
</h2>
|
||||||
|
) : (
|
||||||
|
<h2 className="font-medium leading-5">All Issues</h2>
|
||||||
|
)}
|
||||||
|
<span className="text-custom-text-200 min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs">
|
||||||
|
{groupedIssues[groupTitle as keyof IIssue].length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Disclosure.Button>
|
||||||
|
{isArchivedIssues ? (
|
||||||
|
""
|
||||||
|
) : type === "issue" ? (
|
||||||
|
!disableAddIssueOption && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
|
if (isDraftIssuesPage) setIsDraftIssuesModalOpen(true);
|
||||||
addIssueToGroup();
|
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
|
||||||
} else setIsCreateIssueFormOpen(true);
|
else setIsCreateIssueFormOpen(true);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
|
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)
|
||||||
|
) : disableUserActions ? (
|
||||||
|
""
|
||||||
|
) : (
|
||||||
|
<CustomMenu
|
||||||
|
customButton={
|
||||||
|
<div className="flex cursor-pointer items-center">
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
position="right"
|
||||||
|
noBorder
|
||||||
|
>
|
||||||
|
<CustomMenu.MenuItem onClick={() => setIsCreateIssueFormOpen(true)}>
|
||||||
|
Create new
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
{openIssuesListModal && (
|
||||||
|
<CustomMenu.MenuItem onClick={openIssuesListModal}>
|
||||||
|
Add an existing issue
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
</CustomMenu>
|
||||||
)}
|
)}
|
||||||
</Disclosure.Panel>
|
</div>
|
||||||
</Transition>
|
<Transition
|
||||||
</div>
|
show={open}
|
||||||
)}
|
enter="transition duration-100 ease-out"
|
||||||
</Disclosure>
|
enterFrom="transform opacity-0"
|
||||||
|
enterTo="transform opacity-100"
|
||||||
|
leave="transition duration-75 ease-out"
|
||||||
|
leaveFrom="transform opacity-100"
|
||||||
|
leaveTo="transform opacity-0"
|
||||||
|
>
|
||||||
|
<Disclosure.Panel>
|
||||||
|
{groupedIssues[groupTitle] ? (
|
||||||
|
groupedIssues[groupTitle].length > 0 ? (
|
||||||
|
groupedIssues[groupTitle].map((issue, index) => (
|
||||||
|
<SingleListIssue
|
||||||
|
key={issue.id}
|
||||||
|
type={type}
|
||||||
|
issue={issue}
|
||||||
|
projectId={issue.project_detail.id}
|
||||||
|
groupTitle={groupTitle}
|
||||||
|
index={index}
|
||||||
|
editIssue={() => handleIssueAction(issue, "edit")}
|
||||||
|
makeIssueCopy={() => handleIssueAction(issue, "copy")}
|
||||||
|
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
|
||||||
|
handleDraftIssueSelect={
|
||||||
|
handleDraftIssueAction
|
||||||
|
? () => handleDraftIssueAction(issue, "edit")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
handleDraftIssueDelete={
|
||||||
|
handleDraftIssueAction
|
||||||
|
? () => handleDraftIssueAction(issue, "delete")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
handleMyIssueOpen={handleMyIssueOpen}
|
||||||
|
removeIssue={() => {
|
||||||
|
if (removeIssue !== null && issue.bridge_id)
|
||||||
|
removeIssue(issue.bridge_id, issue.id);
|
||||||
|
}}
|
||||||
|
disableUserActions={disableUserActions}
|
||||||
|
user={user}
|
||||||
|
userAuth={userAuth}
|
||||||
|
viewProps={viewProps}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="bg-custom-background-100 px-4 py-2.5 text-sm text-custom-text-200">
|
||||||
|
No issues.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">Loading...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ListInlineCreateIssueForm
|
||||||
|
isOpen={isCreateIssueFormOpen && !disableAddIssueOption}
|
||||||
|
handleClose={() => setIsCreateIssueFormOpen(false)}
|
||||||
|
prePopulatedData={{
|
||||||
|
...(cycleId && { cycle: cycleId.toString() }),
|
||||||
|
...(moduleId && { module: moduleId.toString() }),
|
||||||
|
[displayFilters?.group_by! === "labels"
|
||||||
|
? "labels_list"
|
||||||
|
: displayFilters?.group_by!]:
|
||||||
|
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!disableAddIssueOption && !isCreateIssueFormOpen && !isDraftIssuesPage && (
|
||||||
|
<div className="w-full bg-custom-background-100 px-6 py-3 border-b border-custom-border-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isDraftIssuesPage) setIsDraftIssuesModalOpen(true);
|
||||||
|
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
|
||||||
|
else setIsCreateIssueFormOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Disclosure.Panel>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Disclosure>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -10,5 +10,4 @@ export * from "./state-column";
|
|||||||
export * from "./updated-on-column";
|
export * from "./updated-on-column";
|
||||||
export * from "./spreadsheet-view";
|
export * from "./spreadsheet-view";
|
||||||
export * from "./issue-column/issue-column";
|
export * from "./issue-column/issue-column";
|
||||||
export * from "./spreadsheet-columns";
|
|
||||||
export * from "./issue-column/spreadsheet-issue-column";
|
export * from "./issue-column/spreadsheet-issue-column";
|
||||||
|
@ -83,88 +83,89 @@ export const IssueColumn: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center w-[28rem] text-sm h-11 sticky top-0 bg-custom-background-100 truncate border-b border-r border-custom-border-100">
|
<div className="group flex items-center w-[28rem] text-sm h-11 sticky top-0 bg-custom-background-100 truncate border-b border-r border-custom-border-100">
|
||||||
<div
|
{properties.key && (
|
||||||
className="flex gap-1.5 px-4 pr-0 py-2.5 items-center"
|
<div
|
||||||
style={issue.parent ? { paddingLeft } : {}}
|
className="flex gap-1.5 px-4 pr-0 py-2.5 items-center min-w-[96px]"
|
||||||
>
|
style={issue.parent && nestingLevel !== 0 ? { paddingLeft } : {}}
|
||||||
<div className="relative flex items-center cursor-pointer text-xs text-center hover:text-custom-text-100">
|
>
|
||||||
{properties.key && (
|
<div className="relative flex items-center cursor-pointer text-xs text-center hover:text-custom-text-100">
|
||||||
<span className="flex items-center justify-center font-medium opacity-100 group-hover:opacity-0">
|
<span className="flex items-center justify-center font-medium opacity-100 group-hover:opacity-0 ">
|
||||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
{!isNotAllowed && !disableUserActions && (
|
|
||||||
<div className="absolute top-0 left-2.5 opacity-0 group-hover:opacity-100">
|
|
||||||
<Popover2
|
|
||||||
isOpen={isOpen}
|
|
||||||
canEscapeKeyClose
|
|
||||||
onInteraction={(nextOpenState) => setIsOpen(nextOpenState)}
|
|
||||||
content={
|
|
||||||
<div
|
|
||||||
className={`flex flex-col gap-1.5 overflow-y-scroll whitespace-nowrap rounded-md border p-1 text-xs shadow-lg focus:outline-none max-h-44 min-w-full border-custom-border-200 bg-custom-background-90`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
|
||||||
onClick={() => {
|
|
||||||
handleEditIssue(issue);
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-start gap-2">
|
|
||||||
<PencilIcon className="h-4 w-4" />
|
|
||||||
<span>Edit issue</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
{!isNotAllowed && !disableUserActions && (
|
||||||
type="button"
|
<div className="absolute top-0 left-2.5 opacity-0 group-hover:opacity-100">
|
||||||
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
<Popover2
|
||||||
onClick={() => {
|
isOpen={isOpen}
|
||||||
handleDeleteIssue(issue);
|
canEscapeKeyClose
|
||||||
setIsOpen(false);
|
onInteraction={(nextOpenState) => setIsOpen(nextOpenState)}
|
||||||
}}
|
content={
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-1.5 overflow-y-scroll whitespace-nowrap rounded-md border p-1 text-xs shadow-lg focus:outline-none max-h-44 min-w-full border-custom-border-100 bg-custom-background-90`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-start gap-2">
|
<button
|
||||||
<TrashIcon className="h-4 w-4" />
|
type="button"
|
||||||
<span>Delete issue</span>
|
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
||||||
</div>
|
onClick={() => {
|
||||||
</button>
|
handleEditIssue(issue);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
<span>Edit issue</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleCopyText();
|
handleDeleteIssue(issue);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-start gap-2">
|
<div className="flex items-center justify-start gap-2">
|
||||||
<LinkIcon className="h-4 w-4" />
|
<TrashIcon className="h-4 w-4" />
|
||||||
<span>Copy issue link</span>
|
<span>Delete issue</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
}
|
<button
|
||||||
placement="bottom-start"
|
type="button"
|
||||||
|
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
||||||
|
onClick={() => {
|
||||||
|
handleCopyText();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
<span>Copy issue link</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="bottom-start"
|
||||||
|
>
|
||||||
|
<EllipsisHorizontalIcon className="h-5 w-5 text-custom-text-200" />
|
||||||
|
</Popover2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issue.sub_issues_count > 0 && (
|
||||||
|
<div className="h-6 w-6 flex justify-center items-center">
|
||||||
|
<button
|
||||||
|
className="h-5 w-5 hover:bg-custom-background-90 hover:text-custom-text-100 rounded-sm cursor-pointer"
|
||||||
|
onClick={() => handleToggleExpand(issue.id)}
|
||||||
>
|
>
|
||||||
<EllipsisHorizontalIcon className="h-5 w-5 text-custom-text-200" />
|
<Icon iconName="chevron_right" className={`${expanded ? "rotate-90" : ""}`} />
|
||||||
</Popover2>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{issue.sub_issues_count > 0 && (
|
|
||||||
<div className="h-6 w-6 flex justify-center items-center">
|
|
||||||
<button
|
|
||||||
className="h-5 w-5 hover:bg-custom-background-90 hover:text-custom-text-100 rounded-sm cursor-pointer"
|
|
||||||
onClick={() => handleToggleExpand(issue.id)}
|
|
||||||
>
|
|
||||||
<Icon iconName="chevron_right" className={`${expanded ? "rotate-90" : ""}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="flex items-center px-4 py-2.5 h-full truncate flex-grow">
|
<span className="flex items-center px-4 py-2.5 h-full truncate flex-grow">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -1,274 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
// hooks
|
|
||||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
|
||||||
import useLocalStorage from "hooks/use-local-storage";
|
|
||||||
// component
|
|
||||||
import { CustomMenu, Icon } from "components/ui";
|
|
||||||
// icon
|
|
||||||
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
|
||||||
// types
|
|
||||||
import { TIssueOrderByOptions } from "types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
columnData: any;
|
|
||||||
gridTemplateColumns: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SpreadsheetColumns: React.FC<Props> = ({ columnData, gridTemplateColumns }) => {
|
|
||||||
const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage(
|
|
||||||
"spreadsheetViewSorting",
|
|
||||||
""
|
|
||||||
);
|
|
||||||
const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } =
|
|
||||||
useLocalStorage("spreadsheetViewActiveSortingProperty", "");
|
|
||||||
|
|
||||||
const { displayFilters, setDisplayFilters } = useSpreadsheetIssuesView();
|
|
||||||
|
|
||||||
const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => {
|
|
||||||
setDisplayFilters({ order_by: order });
|
|
||||||
setSelectedMenuItem(`${order}_${itemKey}`);
|
|
||||||
setActiveSortingProperty(order === "-created_at" ? "" : itemKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`grid auto-rows-[minmax(36px,1fr)] w-full min-w-max`}
|
|
||||||
style={{ gridTemplateColumns }}
|
|
||||||
>
|
|
||||||
{columnData.map((col: any) => {
|
|
||||||
if (col.isActive) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`bg-custom-background-90 w-full ${
|
|
||||||
col.propertyName === "title"
|
|
||||||
? "sticky left-0 z-20 bg-custom-background-90 pl-24"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{col.propertyName === "title" ? (
|
|
||||||
<div
|
|
||||||
className={`flex items-center justify-start gap-1.5 cursor-default text-sm text-custom-text-200 text-current w-full py-2.5 px-2`}
|
|
||||||
>
|
|
||||||
{col.colName}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<CustomMenu
|
|
||||||
className="!w-full"
|
|
||||||
customButton={
|
|
||||||
<div
|
|
||||||
className={`relative group flex items-center justify-start gap-1.5 cursor-pointer text-sm text-custom-text-200 hover:text-custom-text-100 w-full py-3 px-2 ${
|
|
||||||
activeSortingProperty === col.propertyName ? "bg-custom-background-80" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{activeSortingProperty === col.propertyName && (
|
|
||||||
<div className="absolute top-1 right-1.5">
|
|
||||||
<Icon
|
|
||||||
iconName="filter_list"
|
|
||||||
className="flex items-center justify-center h-3.5 w-3.5 rounded-full bg-custom-primary text-xs text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{col.icon ? (
|
|
||||||
<col.icon
|
|
||||||
className={`text-custom-text-200 group-hover:text-custom-text-100 ${
|
|
||||||
col.propertyName === "estimate" ? "-rotate-90" : ""
|
|
||||||
}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
height="14"
|
|
||||||
width="14"
|
|
||||||
/>
|
|
||||||
) : col.propertyName === "priority" ? (
|
|
||||||
<span className="text-sm material-symbols-rounded text-custom-text-200">
|
|
||||||
signal_cellular_alt
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
|
|
||||||
{col.colName}
|
|
||||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
width="xl"
|
|
||||||
>
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
handleOrderBy(col.ascendingOrder, col.propertyName);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`group flex gap-1.5 px-1 items-center justify-between ${
|
|
||||||
selectedMenuItem === `${col.ascendingOrder}_${col.propertyName}`
|
|
||||||
? "text-custom-text-100"
|
|
||||||
: "text-custom-text-200 hover:text-custom-text-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
{col.propertyName === "assignee" || col.propertyName === "labels" ? (
|
|
||||||
<>
|
|
||||||
<span className="relative flex items-center h-6 w-6">
|
|
||||||
<Icon
|
|
||||||
iconName="east"
|
|
||||||
className="absolute left-0 rotate-90 text-xs leading-3"
|
|
||||||
/>
|
|
||||||
<Icon iconName="sort" className="absolute right-0 text-sm" />
|
|
||||||
</span>
|
|
||||||
<span>A</span>
|
|
||||||
<Icon iconName="east" className="text-sm" />
|
|
||||||
<span>Z</span>
|
|
||||||
</>
|
|
||||||
) : col.propertyName === "due_date" ||
|
|
||||||
col.propertyName === "created_on" ||
|
|
||||||
col.propertyName === "updated_on" ? (
|
|
||||||
<>
|
|
||||||
<span className="relative flex items-center h-6 w-6">
|
|
||||||
<Icon
|
|
||||||
iconName="east"
|
|
||||||
className="absolute left-0 rotate-90 text-xs leading-3"
|
|
||||||
/>
|
|
||||||
<Icon iconName="sort" className="absolute right-0 text-sm" />
|
|
||||||
</span>
|
|
||||||
<span>New</span>
|
|
||||||
<Icon iconName="east" className="text-sm" />
|
|
||||||
<span>Old</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="relative flex items-center h-6 w-6">
|
|
||||||
<Icon
|
|
||||||
iconName="east"
|
|
||||||
className="absolute left-0 rotate-90 text-xs leading-3"
|
|
||||||
/>
|
|
||||||
<Icon iconName="sort" className="absolute right-0 text-sm" />
|
|
||||||
</span>
|
|
||||||
<span>First</span>
|
|
||||||
<Icon iconName="east" className="text-sm" />
|
|
||||||
<span>Last</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CheckIcon
|
|
||||||
className={`h-3.5 w-3.5 opacity-0 group-hover:opacity-100 ${
|
|
||||||
selectedMenuItem === `${col.ascendingOrder}_${col.propertyName}`
|
|
||||||
? "opacity-100"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
className={`mt-0.5 ${
|
|
||||||
selectedMenuItem === `${col.descendingOrder}_${col.propertyName}`
|
|
||||||
? "bg-custom-background-80"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
key={col.property}
|
|
||||||
onClick={() => {
|
|
||||||
handleOrderBy(col.descendingOrder, col.propertyName);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`group flex gap-1.5 px-1 items-center justify-between ${
|
|
||||||
selectedMenuItem === `${col.descendingOrder}_${col.propertyName}`
|
|
||||||
? "text-custom-text-100"
|
|
||||||
: "text-custom-text-200 hover:text-custom-text-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
{col.propertyName === "assignee" || col.propertyName === "labels" ? (
|
|
||||||
<>
|
|
||||||
<span className="relative flex items-center h-6 w-6">
|
|
||||||
<Icon
|
|
||||||
iconName="east"
|
|
||||||
className="absolute left-0 -rotate-90 text-xs leading-3"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
iconName="sort"
|
|
||||||
className="absolute rotate-180 transform scale-x-[-1] right-0 text-sm"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span>Z</span>
|
|
||||||
<Icon iconName="east" className="text-sm" />
|
|
||||||
<span>A</span>
|
|
||||||
</>
|
|
||||||
) : col.propertyName === "due_date" ? (
|
|
||||||
<>
|
|
||||||
<span className="relative flex items-center h-6 w-6">
|
|
||||||
<Icon
|
|
||||||
iconName="east"
|
|
||||||
className="absolute left-0 -rotate-90 text-xs leading-3"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
iconName="sort"
|
|
||||||
className="absolute rotate-180 transform scale-x-[-1] right-0 text-sm"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span>Old</span>
|
|
||||||
<Icon iconName="east" className="text-sm" />
|
|
||||||
<span>New</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="relative flex items-center h-6 w-6">
|
|
||||||
<Icon
|
|
||||||
iconName="east"
|
|
||||||
className="absolute left-0 -rotate-90 text-xs leading-3"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
iconName="sort"
|
|
||||||
className="absolute rotate-180 transform scale-x-[-1] right-0 text-sm"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span>Last</span>
|
|
||||||
<Icon iconName="east" className="text-sm" />
|
|
||||||
<span>First</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CheckIcon
|
|
||||||
className={`h-3.5 w-3.5 opacity-0 group-hover:opacity-100 ${
|
|
||||||
selectedMenuItem === `${col.descendingOrder}_${col.propertyName}`
|
|
||||||
? "opacity-100"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
{selectedMenuItem &&
|
|
||||||
selectedMenuItem !== "" &&
|
|
||||||
displayFilters?.order_by !== "-created_at" &&
|
|
||||||
selectedMenuItem.includes(col.propertyName) && (
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
className={`mt-0.5${
|
|
||||||
selectedMenuItem === `-created_at_${col.propertyName}`
|
|
||||||
? "bg-custom-background-80"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
key={col.property}
|
|
||||||
onClick={() => {
|
|
||||||
handleOrderBy("-created_at", col.propertyName);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={`group flex gap-1.5 px-1 items-center justify-between `}>
|
|
||||||
<div className="flex gap-1.5 items-center">
|
|
||||||
<span className="relative flex items-center justify-center h-6 w-6">
|
|
||||||
<Icon iconName="ink_eraser" className="text-sm" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span>Clear sorting</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
)}
|
|
||||||
</CustomMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
// next
|
// next
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@ -19,13 +19,20 @@ import {
|
|||||||
SpreadsheetStateColumn,
|
SpreadsheetStateColumn,
|
||||||
SpreadsheetUpdatedOnColumn,
|
SpreadsheetUpdatedOnColumn,
|
||||||
} from "components/core";
|
} from "components/core";
|
||||||
import { CustomMenu, Spinner } from "components/ui";
|
import { CustomMenu, Icon, Spinner } from "components/ui";
|
||||||
import { IssuePeekOverview } from "components/issues";
|
import { IssuePeekOverview } from "components/issues";
|
||||||
// hooks
|
// hooks
|
||||||
import useIssuesProperties from "hooks/use-issue-properties";
|
import useIssuesProperties from "hooks/use-issue-properties";
|
||||||
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
|
import { useWorkspaceView } from "hooks/use-workspace-view";
|
||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IIssue, ISubIssueResponse, UserAuth } from "types";
|
import {
|
||||||
import useWorkspaceIssuesFilters from "hooks/use-worskpace-issue-filter";
|
ICurrentUserResponse,
|
||||||
|
IIssue,
|
||||||
|
ISubIssueResponse,
|
||||||
|
TIssueOrderByOptions,
|
||||||
|
UserAuth,
|
||||||
|
} from "types";
|
||||||
import {
|
import {
|
||||||
CYCLE_DETAILS,
|
CYCLE_DETAILS,
|
||||||
CYCLE_ISSUES_WITH_PARAMS,
|
CYCLE_ISSUES_WITH_PARAMS,
|
||||||
@ -39,7 +46,7 @@ import {
|
|||||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||||
import projectIssuesServices from "services/issues.service";
|
import projectIssuesServices from "services/issues.service";
|
||||||
// icon
|
// icon
|
||||||
import { PlusIcon } from "lucide-react";
|
import { CheckIcon, ChevronDownIcon, PlusIcon } from "lucide-react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
spreadsheetIssues: IIssue[];
|
spreadsheetIssues: IIssue[];
|
||||||
@ -70,13 +77,24 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
|
|
||||||
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
|
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
|
||||||
|
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId, workspaceViewId } = router.query;
|
const { workspaceSlug, projectId, cycleId, moduleId, viewId, globalViewId } = router.query;
|
||||||
|
|
||||||
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
|
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
|
||||||
|
|
||||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||||
|
|
||||||
|
const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage(
|
||||||
|
"spreadsheetViewSorting",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } =
|
||||||
|
useLocalStorage("spreadsheetViewActiveSortingProperty", "");
|
||||||
|
|
||||||
const workspaceIssuesPath = [
|
const workspaceIssuesPath = [
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
@ -111,12 +129,19 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
router.pathname.includes(path.path)
|
router.pathname.includes(path.path)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { params: workspaceViewParams } = useWorkspaceIssuesFilters(
|
const {
|
||||||
workspaceSlug?.toString(),
|
params: workspaceViewParams,
|
||||||
workspaceViewId?.toString()
|
filters: workspaceViewFilters,
|
||||||
);
|
handleFilters,
|
||||||
|
} = useWorkspaceView();
|
||||||
|
|
||||||
const { params } = useSpreadsheetIssuesView();
|
const workspaceViewProperties = workspaceViewFilters.display_properties;
|
||||||
|
|
||||||
|
const isWorkspaceView = globalViewId || currentWorkspaceIssuePath;
|
||||||
|
|
||||||
|
const currentViewProperties = isWorkspaceView ? workspaceViewProperties : properties;
|
||||||
|
|
||||||
|
const { params, displayFilters, setDisplayFilters } = useSpreadsheetIssuesView();
|
||||||
|
|
||||||
const partialUpdateIssue = useCallback(
|
const partialUpdateIssue = useCallback(
|
||||||
(formData: Partial<IIssue>, issue: IIssue) => {
|
(formData: Partial<IIssue>, issue: IIssue) => {
|
||||||
@ -128,8 +153,8 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
|
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
|
||||||
: viewId
|
: viewId
|
||||||
? VIEW_ISSUES(viewId.toString(), params)
|
? VIEW_ISSUES(viewId.toString(), params)
|
||||||
: workspaceViewId
|
: globalViewId
|
||||||
? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), workspaceViewParams)
|
? WORKSPACE_VIEW_ISSUES(globalViewId.toString(), workspaceViewParams)
|
||||||
: currentWorkspaceIssuePath
|
: currentWorkspaceIssuePath
|
||||||
? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params)
|
? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params)
|
||||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(issue.project_detail.id, params);
|
: PROJECT_ISSUES_LIST_WITH_PARAMS(issue.project_detail.id, params);
|
||||||
@ -198,9 +223,9 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
cycleId,
|
cycleId,
|
||||||
moduleId,
|
moduleId,
|
||||||
viewId,
|
viewId,
|
||||||
workspaceViewId,
|
globalViewId,
|
||||||
currentWorkspaceIssuePath,
|
|
||||||
workspaceViewParams,
|
workspaceViewParams,
|
||||||
|
currentWorkspaceIssuePath,
|
||||||
params,
|
params,
|
||||||
user,
|
user,
|
||||||
]
|
]
|
||||||
@ -208,10 +233,219 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
|
|
||||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||||
|
|
||||||
const renderColumn = (header: string, Component: React.ComponentType<any>) => (
|
const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => {
|
||||||
<div className="relative flex flex-col h-max w-full bg-custom-background-100 rounded-sm">
|
if (globalViewId) handleFilters("display_filters", { order_by: order });
|
||||||
<div className="flex items-center min-w-[9rem] px-4 py-2.5 text-sm font-medium z-[1] h-11 w-full sticky top-0 bg-custom-background-90 border border-l-0 border-custom-border-200">
|
else setDisplayFilters({ order_by: order });
|
||||||
{header}
|
setSelectedMenuItem(`${order}_${itemKey}`);
|
||||||
|
setActiveSortingProperty(order === "-created_at" ? "" : itemKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderColumn = (
|
||||||
|
header: string,
|
||||||
|
propertyName: string,
|
||||||
|
Component: React.ComponentType<any>,
|
||||||
|
ascendingOrder: TIssueOrderByOptions,
|
||||||
|
descendingOrder: TIssueOrderByOptions
|
||||||
|
) => (
|
||||||
|
<div className="relative flex flex-col h-max w-full bg-custom-background-100">
|
||||||
|
<div className="flex items-center min-w-[9rem] px-4 py-2.5 text-sm font-medium z-[1] h-11 w-full sticky top-0 bg-custom-background-90 border border-l-0 border-custom-border-100">
|
||||||
|
{currentWorkspaceIssuePath ? (
|
||||||
|
<span>{header}</span>
|
||||||
|
) : (
|
||||||
|
<CustomMenu
|
||||||
|
customButtonClassName="!w-full"
|
||||||
|
className="!w-full"
|
||||||
|
position="left"
|
||||||
|
customButton={
|
||||||
|
<div
|
||||||
|
className={`relative group flex items-center justify-between gap-1.5 cursor-pointer text-sm text-custom-text-200 hover:text-custom-text-100 w-full py-3 px-2 ${
|
||||||
|
activeSortingProperty === propertyName ? "bg-custom-background-80" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{activeSortingProperty === propertyName && (
|
||||||
|
<div className="absolute top-1 right-1.5">
|
||||||
|
<Icon
|
||||||
|
iconName="filter_list"
|
||||||
|
className="flex items-center justify-center h-3.5 w-3.5 rounded-full bg-custom-primary text-xs text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{header}
|
||||||
|
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
width="xl"
|
||||||
|
>
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleOrderBy(ascendingOrder, propertyName);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`group flex gap-1.5 px-1 items-center justify-between ${
|
||||||
|
selectedMenuItem === `${ascendingOrder}_${propertyName}`
|
||||||
|
? "text-custom-text-100"
|
||||||
|
: "text-custom-text-200 hover:text-custom-text-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{propertyName === "assignee" || propertyName === "labels" ? (
|
||||||
|
<>
|
||||||
|
<span className="relative flex items-center h-6 w-6">
|
||||||
|
<Icon
|
||||||
|
iconName="east"
|
||||||
|
className="absolute left-0 rotate-90 text-xs leading-3"
|
||||||
|
/>
|
||||||
|
<Icon iconName="sort" className="absolute right-0 text-sm" />
|
||||||
|
</span>
|
||||||
|
<span>A</span>
|
||||||
|
<Icon iconName="east" className="text-sm" />
|
||||||
|
<span>Z</span>
|
||||||
|
</>
|
||||||
|
) : propertyName === "due_date" ||
|
||||||
|
propertyName === "created_on" ||
|
||||||
|
propertyName === "updated_on" ? (
|
||||||
|
<>
|
||||||
|
<span className="relative flex items-center h-6 w-6">
|
||||||
|
<Icon
|
||||||
|
iconName="east"
|
||||||
|
className="absolute left-0 rotate-90 text-xs leading-3"
|
||||||
|
/>
|
||||||
|
<Icon iconName="sort" className="absolute right-0 text-sm" />
|
||||||
|
</span>
|
||||||
|
<span>New</span>
|
||||||
|
<Icon iconName="east" className="text-sm" />
|
||||||
|
<span>Old</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="relative flex items-center h-6 w-6">
|
||||||
|
<Icon
|
||||||
|
iconName="east"
|
||||||
|
className="absolute left-0 rotate-90 text-xs leading-3"
|
||||||
|
/>
|
||||||
|
<Icon iconName="sort" className="absolute right-0 text-sm" />
|
||||||
|
</span>
|
||||||
|
<span>First</span>
|
||||||
|
<Icon iconName="east" className="text-sm" />
|
||||||
|
<span>Last</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CheckIcon
|
||||||
|
className={`h-3.5 w-3.5 opacity-0 group-hover:opacity-100 ${
|
||||||
|
selectedMenuItem === `${ascendingOrder}_${propertyName}` ? "opacity-100" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
className={`mt-0.5 ${
|
||||||
|
selectedMenuItem === `${descendingOrder}_${propertyName}`
|
||||||
|
? "bg-custom-background-80"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
key={propertyName}
|
||||||
|
onClick={() => {
|
||||||
|
handleOrderBy(descendingOrder, propertyName);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`group flex gap-1.5 px-1 items-center justify-between ${
|
||||||
|
selectedMenuItem === `${descendingOrder}_${propertyName}`
|
||||||
|
? "text-custom-text-100"
|
||||||
|
: "text-custom-text-200 hover:text-custom-text-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{propertyName === "assignee" || propertyName === "labels" ? (
|
||||||
|
<>
|
||||||
|
<span className="relative flex items-center h-6 w-6">
|
||||||
|
<Icon
|
||||||
|
iconName="east"
|
||||||
|
className="absolute left-0 -rotate-90 text-xs leading-3"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
iconName="sort"
|
||||||
|
className="absolute rotate-180 transform scale-x-[-1] right-0 text-sm"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>Z</span>
|
||||||
|
<Icon iconName="east" className="text-sm" />
|
||||||
|
<span>A</span>
|
||||||
|
</>
|
||||||
|
) : propertyName === "due_date" ? (
|
||||||
|
<>
|
||||||
|
<span className="relative flex items-center h-6 w-6">
|
||||||
|
<Icon
|
||||||
|
iconName="east"
|
||||||
|
className="absolute left-0 -rotate-90 text-xs leading-3"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
iconName="sort"
|
||||||
|
className="absolute rotate-180 transform scale-x-[-1] right-0 text-sm"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>Old</span>
|
||||||
|
<Icon iconName="east" className="text-sm" />
|
||||||
|
<span>New</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="relative flex items-center h-6 w-6">
|
||||||
|
<Icon
|
||||||
|
iconName="east"
|
||||||
|
className="absolute left-0 -rotate-90 text-xs leading-3"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
iconName="sort"
|
||||||
|
className="absolute rotate-180 transform scale-x-[-1] right-0 text-sm"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>Last</span>
|
||||||
|
<Icon iconName="east" className="text-sm" />
|
||||||
|
<span>First</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CheckIcon
|
||||||
|
className={`h-3.5 w-3.5 opacity-0 group-hover:opacity-100 ${
|
||||||
|
selectedMenuItem === `${descendingOrder}_${propertyName}` ? "opacity-100" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
{selectedMenuItem &&
|
||||||
|
selectedMenuItem !== "" &&
|
||||||
|
displayFilters?.order_by !== "-created_at" &&
|
||||||
|
selectedMenuItem.includes(propertyName) && (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
className={`mt-0.5${
|
||||||
|
selectedMenuItem === `-created_at_${propertyName}`
|
||||||
|
? "bg-custom-background-80"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
key={propertyName}
|
||||||
|
onClick={() => {
|
||||||
|
handleOrderBy("-created_at", propertyName);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`group flex gap-1.5 px-1 items-center justify-between `}>
|
||||||
|
<div className="flex gap-1.5 items-center">
|
||||||
|
<span className="relative flex items-center justify-center h-6 w-6">
|
||||||
|
<Icon iconName="ink_eraser" className="text-sm" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span>Clear sorting</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
</CustomMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full min-w-[9rem] w-full">
|
<div className="h-full min-w-[9rem] w-full">
|
||||||
{spreadsheetIssues.map((issue: IIssue, index) => (
|
{spreadsheetIssues.map((issue: IIssue, index) => (
|
||||||
@ -221,7 +455,7 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
projectId={issue.project_detail.id}
|
projectId={issue.project_detail.id}
|
||||||
partialUpdateIssue={partialUpdateIssue}
|
partialUpdateIssue={partialUpdateIssue}
|
||||||
expandedIssues={expandedIssues}
|
expandedIssues={expandedIssues}
|
||||||
properties={properties}
|
properties={currentViewProperties}
|
||||||
user={user}
|
user={user}
|
||||||
isNotAllowed={isNotAllowed}
|
isNotAllowed={isNotAllowed}
|
||||||
/>
|
/>
|
||||||
@ -230,6 +464,27 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const scrollLeft = containerRef.current.scrollLeft;
|
||||||
|
setIsScrolled(scrollLeft > 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentContainerRef = containerRef.current;
|
||||||
|
|
||||||
|
if (currentContainerRef) {
|
||||||
|
currentContainerRef.addEventListener("scroll", handleScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (currentContainerRef) {
|
||||||
|
currentContainerRef.removeEventListener("scroll", handleScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IssuePeekOverview
|
<IssuePeekOverview
|
||||||
@ -238,17 +493,24 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||||
readOnly={disableUserActions}
|
readOnly={disableUserActions}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex h-full w-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100">
|
<div className="relative flex h-full w-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-200">
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
<div className="flex max-h-full overflow-y-auto">
|
<div ref={containerRef} className="flex max-h-full h-full overflow-y-auto">
|
||||||
{spreadsheetIssues ? (
|
{spreadsheetIssues ? (
|
||||||
<>
|
<>
|
||||||
<div className="sticky left-0 w-[28rem] z-[2]">
|
<div className="sticky left-0 w-[28rem] z-[2]">
|
||||||
<div className="relative flex flex-col h-max w-full bg-custom-background-100 rounded-sm z-[2]">
|
<div
|
||||||
<div className="flex items-center text-sm font-medium z-[2] h-11 w-full sticky top-0 bg-custom-background-90 border border-l-0 border-custom-border-200">
|
className="relative flex flex-col h-max w-full bg-custom-background-100 z-[2]"
|
||||||
<span className="flex items-center px-4 py-2.5 h-full w-20 flex-shrink-0">
|
style={{
|
||||||
ID
|
boxShadow: isScrolled ? "8px -9px 12px rgba(0, 0, 0, 0.15)" : "",
|
||||||
</span>
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center text-sm font-medium z-[2] h-11 w-full sticky top-0 bg-custom-background-90 border border-l-0 border-custom-border-100">
|
||||||
|
{currentViewProperties.key && (
|
||||||
|
<span className="flex items-center px-4 py-2.5 h-full w-24 flex-shrink-0">
|
||||||
|
ID
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="flex items-center px-4 py-2.5 h-full w-full flex-grow">
|
<span className="flex items-center px-4 py-2.5 h-full w-full flex-grow">
|
||||||
Issue
|
Issue
|
||||||
</span>
|
</span>
|
||||||
@ -262,7 +524,7 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
expandedIssues={expandedIssues}
|
expandedIssues={expandedIssues}
|
||||||
setExpandedIssues={setExpandedIssues}
|
setExpandedIssues={setExpandedIssues}
|
||||||
setCurrentProjectId={setCurrentProjectId}
|
setCurrentProjectId={setCurrentProjectId}
|
||||||
properties={properties}
|
properties={currentViewProperties}
|
||||||
handleIssueAction={handleIssueAction}
|
handleIssueAction={handleIssueAction}
|
||||||
disableUserActions={disableUserActions}
|
disableUserActions={disableUserActions}
|
||||||
userAuth={userAuth}
|
userAuth={userAuth}
|
||||||
@ -270,15 +532,79 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{renderColumn("State", SpreadsheetStateColumn)}
|
{currentViewProperties.state &&
|
||||||
{renderColumn("Priority", SpreadsheetPriorityColumn)}
|
renderColumn(
|
||||||
{renderColumn("Assignees", SpreadsheetAssigneeColumn)}
|
"State",
|
||||||
{renderColumn("Label", SpreadsheetLabelColumn)}
|
"state",
|
||||||
{renderColumn("Start Date", SpreadsheetStartDateColumn)}
|
SpreadsheetStateColumn,
|
||||||
{renderColumn("Due Date", SpreadsheetDueDateColumn)}
|
"state__name",
|
||||||
{renderColumn("Estimate", SpreadsheetEstimateColumn)}
|
"-state__name"
|
||||||
{renderColumn("Created On", SpreadsheetCreatedOnColumn)}
|
)}
|
||||||
{renderColumn("Updated On", SpreadsheetUpdatedOnColumn)}
|
|
||||||
|
{currentViewProperties.priority &&
|
||||||
|
renderColumn(
|
||||||
|
"Priority",
|
||||||
|
"priority",
|
||||||
|
SpreadsheetPriorityColumn,
|
||||||
|
"priority",
|
||||||
|
"-priority"
|
||||||
|
)}
|
||||||
|
{currentViewProperties.assignee &&
|
||||||
|
renderColumn(
|
||||||
|
"Assignees",
|
||||||
|
"assignee",
|
||||||
|
SpreadsheetAssigneeColumn,
|
||||||
|
"assignees__first_name",
|
||||||
|
"-assignees__first_name"
|
||||||
|
)}
|
||||||
|
{currentViewProperties.labels &&
|
||||||
|
renderColumn(
|
||||||
|
"Label",
|
||||||
|
"labels",
|
||||||
|
SpreadsheetLabelColumn,
|
||||||
|
"labels__name",
|
||||||
|
"-labels__name"
|
||||||
|
)}
|
||||||
|
{currentViewProperties.start_date &&
|
||||||
|
renderColumn(
|
||||||
|
"Start Date",
|
||||||
|
"start_date",
|
||||||
|
SpreadsheetStartDateColumn,
|
||||||
|
"-start_date",
|
||||||
|
"start_date"
|
||||||
|
)}
|
||||||
|
{currentViewProperties.due_date &&
|
||||||
|
renderColumn(
|
||||||
|
"Due Date",
|
||||||
|
"due_date",
|
||||||
|
SpreadsheetDueDateColumn,
|
||||||
|
"-target_date",
|
||||||
|
"target_date"
|
||||||
|
)}
|
||||||
|
{currentViewProperties.estimate &&
|
||||||
|
renderColumn(
|
||||||
|
"Estimate",
|
||||||
|
"estimate",
|
||||||
|
SpreadsheetEstimateColumn,
|
||||||
|
"estimate_point",
|
||||||
|
"-estimate_point"
|
||||||
|
)}
|
||||||
|
{currentViewProperties.created_on &&
|
||||||
|
renderColumn(
|
||||||
|
"Created On",
|
||||||
|
"created_on",
|
||||||
|
SpreadsheetCreatedOnColumn,
|
||||||
|
"-created_at",
|
||||||
|
"created_at"
|
||||||
|
)}
|
||||||
|
{currentViewProperties.updated_on &&
|
||||||
|
renderColumn(
|
||||||
|
"Updated On",
|
||||||
|
"updated_on",
|
||||||
|
SpreadsheetUpdatedOnColumn,
|
||||||
|
"-updated_at",
|
||||||
|
"updated_at"
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col justify-center items-center h-full w-full">
|
<div className="flex flex-col justify-center items-center h-full w-full">
|
||||||
|
@ -76,7 +76,7 @@ export const StateColumn: React.FC<Props> = ({
|
|||||||
value={issue.state_detail}
|
value={issue.state_detail}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onChange={handleStateChange}
|
onChange={handleStateChange}
|
||||||
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
|
buttonClassName="!shadow-none !border-0"
|
||||||
hideDropdownArrow
|
hideDropdownArrow
|
||||||
disabled={isNotAllowed}
|
disabled={isNotAllowed}
|
||||||
/>
|
/>
|
||||||
|
@ -4,6 +4,7 @@ import { Tab, Transition, Popover } from "@headlessui/react";
|
|||||||
// react colors
|
// react colors
|
||||||
import { TwitterPicker } from "react-color";
|
import { TwitterPicker } from "react-color";
|
||||||
// hooks
|
// hooks
|
||||||
|
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// types
|
// types
|
||||||
import { Props } from "./types";
|
import { Props } from "./types";
|
||||||
@ -38,6 +39,7 @@ const EmojiIconPicker: React.FC<Props> = ({
|
|||||||
|
|
||||||
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
|
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const emojiPickerRef = useRef<HTMLDivElement>(null);
|
const emojiPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -49,10 +51,12 @@ const EmojiIconPicker: React.FC<Props> = ({
|
|||||||
}, [value, onChange]);
|
}, [value, onChange]);
|
||||||
|
|
||||||
useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false));
|
useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false));
|
||||||
|
useDynamicDropdownPosition(isOpen, () => setIsOpen(false), buttonRef, emojiPickerRef);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative z-[1]">
|
<Popover className="relative z-[1]">
|
||||||
<Popover.Button
|
<Popover.Button
|
||||||
|
ref={buttonRef}
|
||||||
onClick={() => setIsOpen((prev) => !prev)}
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
className="outline-none"
|
className="outline-none"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -61,6 +65,8 @@ const EmojiIconPicker: React.FC<Props> = ({
|
|||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
<Transition
|
<Transition
|
||||||
show={isOpen}
|
show={isOpen}
|
||||||
|
static
|
||||||
|
as={React.Fragment}
|
||||||
enter="transition ease-out duration-100"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
enterFrom="transform opacity-0 scale-95"
|
||||||
enterTo="transform opacity-100 scale-100"
|
enterTo="transform opacity-100 scale-100"
|
||||||
@ -68,11 +74,11 @@ const EmojiIconPicker: React.FC<Props> = ({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Popover.Panel className="absolute z-10 mt-2 w-[250px] rounded-[4px] border border-custom-border-200 bg-custom-background-80 shadow-lg">
|
<Popover.Panel
|
||||||
<div
|
ref={emojiPickerRef}
|
||||||
ref={emojiPickerRef}
|
className="fixed z-10 mt-2 w-[250px] rounded-[4px] border border-custom-border-200 bg-custom-background-80 shadow-lg"
|
||||||
className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl"
|
>
|
||||||
>
|
<div className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl">
|
||||||
<Tab.Group as="div" className="flex h-full w-full flex-col">
|
<Tab.Group as="div" className="flex h-full w-full flex-col">
|
||||||
<Tab.List className="flex-0 -mx-2 flex justify-around gap-1 p-1">
|
<Tab.List className="flex-0 -mx-2 flex justify-around gap-1 p-1">
|
||||||
{tabOptions.map((tab) => (
|
{tabOptions.map((tab) => (
|
||||||
|
@ -80,6 +80,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { cycleId, moduleId } = router.query;
|
const { cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
|
const isCyclePage = router.pathname.split("/")[4] === "cycles" && !cycleId;
|
||||||
|
const isModulePage = router.pathname.split("/")[4] === "modules" && !moduleId;
|
||||||
|
|
||||||
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
|
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
|
||||||
blocks && blocks.length > 0
|
blocks && blocks.length > 0
|
||||||
? blocks.map((block: any) => ({
|
? blocks.map((block: any) => ({
|
||||||
@ -317,7 +320,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
|||||||
SidebarBlockRender={SidebarBlockRender}
|
SidebarBlockRender={SidebarBlockRender}
|
||||||
enableReorder={enableReorder}
|
enableReorder={enableReorder}
|
||||||
/>
|
/>
|
||||||
{chartBlocks && (
|
{chartBlocks && !(isCyclePage || isModulePage) && (
|
||||||
<div className="pl-2.5 py-3">
|
<div className="pl-2.5 py-3">
|
||||||
<GanttInlineCreateIssueForm
|
<GanttInlineCreateIssueForm
|
||||||
isOpen={isCreateIssueFormOpen}
|
isOpen={isCreateIssueFormOpen}
|
||||||
|
@ -8,6 +8,7 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
import aiService from "services/ai.service";
|
import aiService from "services/ai.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
// components
|
// components
|
||||||
import { GptAssistantModal } from "components/core";
|
import { GptAssistantModal } from "components/core";
|
||||||
import { ParentIssuesListModal } from "components/issues";
|
import { ParentIssuesListModal } from "components/issues";
|
||||||
@ -63,6 +64,7 @@ interface IssueFormProps {
|
|||||||
action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue"
|
action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue"
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
data?: Partial<IIssue> | null;
|
data?: Partial<IIssue> | null;
|
||||||
|
isOpen: boolean;
|
||||||
prePopulatedData?: Partial<IIssue> | null;
|
prePopulatedData?: Partial<IIssue> | null;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
|
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
@ -92,6 +94,7 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
const {
|
const {
|
||||||
handleFormSubmit,
|
handleFormSubmit,
|
||||||
data,
|
data,
|
||||||
|
isOpen,
|
||||||
prePopulatedData,
|
prePopulatedData,
|
||||||
projectId,
|
projectId,
|
||||||
setActiveProject,
|
setActiveProject,
|
||||||
@ -112,6 +115,8 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
||||||
|
|
||||||
|
const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {});
|
||||||
|
|
||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -136,6 +141,33 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
|
|
||||||
const issueName = watch("name");
|
const issueName = watch("name");
|
||||||
|
|
||||||
|
const payload: Partial<IIssue> = {
|
||||||
|
name: watch("name"),
|
||||||
|
description: watch("description"),
|
||||||
|
description_html: watch("description_html"),
|
||||||
|
state: watch("state"),
|
||||||
|
priority: watch("priority"),
|
||||||
|
assignees: watch("assignees"),
|
||||||
|
labels: watch("labels"),
|
||||||
|
start_date: watch("start_date"),
|
||||||
|
target_date: watch("target_date"),
|
||||||
|
project: watch("project"),
|
||||||
|
parent: watch("parent"),
|
||||||
|
cycle: watch("cycle"),
|
||||||
|
module: watch("module"),
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || data) return;
|
||||||
|
|
||||||
|
setLocalStorageValue(
|
||||||
|
JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [JSON.stringify(payload), isOpen, data]);
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
@ -276,7 +308,7 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit((formData) =>
|
onSubmit={handleSubmit((formData) =>
|
||||||
handleCreateUpdateIssue(formData, "convertToNewIssue")
|
handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createDraft")
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
@ -385,6 +385,7 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = (props) =
|
|||||||
>
|
>
|
||||||
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||||
<DraftIssueForm
|
<DraftIssueForm
|
||||||
|
isOpen={isOpen}
|
||||||
handleFormSubmit={handleFormSubmit}
|
handleFormSubmit={handleFormSubmit}
|
||||||
prePopulatedData={prePopulateData}
|
prePopulatedData={prePopulateData}
|
||||||
data={data}
|
data={data}
|
||||||
|
@ -133,18 +133,19 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
const issueName = watch("name");
|
const issueName = watch("name");
|
||||||
|
|
||||||
const payload: Partial<IIssue> = {
|
const payload: Partial<IIssue> = {
|
||||||
name: getValues("name"),
|
name: watch("name"),
|
||||||
description: getValues("description"),
|
description: watch("description"),
|
||||||
state: getValues("state"),
|
description_html: watch("description_html"),
|
||||||
priority: getValues("priority"),
|
state: watch("state"),
|
||||||
assignees: getValues("assignees"),
|
priority: watch("priority"),
|
||||||
labels: getValues("labels"),
|
assignees: watch("assignees"),
|
||||||
start_date: getValues("start_date"),
|
labels: watch("labels"),
|
||||||
target_date: getValues("target_date"),
|
start_date: watch("start_date"),
|
||||||
project: getValues("project"),
|
target_date: watch("target_date"),
|
||||||
parent: getValues("parent"),
|
project: watch("project"),
|
||||||
cycle: getValues("cycle"),
|
parent: watch("parent"),
|
||||||
module: getValues("module"),
|
cycle: watch("cycle"),
|
||||||
|
module: watch("module"),
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -20,6 +20,7 @@ import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
|||||||
import useProjects from "hooks/use-projects";
|
import useProjects from "hooks/use-projects";
|
||||||
import useMyIssues from "hooks/my-issues/use-my-issues";
|
import useMyIssues from "hooks/my-issues/use-my-issues";
|
||||||
import useLocalStorage from "hooks/use-local-storage";
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
|
import { useWorkspaceView } from "hooks/use-workspace-view";
|
||||||
// components
|
// components
|
||||||
import { IssueForm, ConfirmIssueDiscard } from "components/issues";
|
import { IssueForm, ConfirmIssueDiscard } from "components/issues";
|
||||||
// types
|
// types
|
||||||
@ -37,6 +38,7 @@ import {
|
|||||||
VIEW_ISSUES,
|
VIEW_ISSUES,
|
||||||
INBOX_ISSUES,
|
INBOX_ISSUES,
|
||||||
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
|
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
|
||||||
|
WORKSPACE_VIEW_ISSUES,
|
||||||
} from "constants/fetch-keys";
|
} from "constants/fetch-keys";
|
||||||
// constants
|
// constants
|
||||||
import { INBOX_ISSUE_SOURCE } from "constants/inbox";
|
import { INBOX_ISSUE_SOURCE } from "constants/inbox";
|
||||||
@ -81,7 +83,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
|||||||
const [prePopulateData, setPreloadedData] = useState<Partial<IIssue>>({});
|
const [prePopulateData, setPreloadedData] = useState<Partial<IIssue>>({});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId, inboxId } = router.query;
|
const { workspaceSlug, projectId, cycleId, moduleId, viewId, globalViewId, inboxId } =
|
||||||
|
router.query;
|
||||||
|
|
||||||
const { displayFilters, params } = useIssuesView();
|
const { displayFilters, params } = useIssuesView();
|
||||||
const { params: calendarParams } = useCalendarIssuesView();
|
const { params: calendarParams } = useCalendarIssuesView();
|
||||||
@ -94,6 +97,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
|||||||
|
|
||||||
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
|
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
|
||||||
|
|
||||||
|
const { params: globalViewParams } = useWorkspaceView();
|
||||||
|
|
||||||
const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } =
|
const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } =
|
||||||
useLocalStorage<any>("draftedIssue", {});
|
useLocalStorage<any>("draftedIssue", {});
|
||||||
|
|
||||||
@ -276,6 +281,40 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const workspaceIssuesPath = [
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
sub_issue: false,
|
||||||
|
},
|
||||||
|
path: "workspace-views/all-issues",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
assignees: user?.id ?? undefined,
|
||||||
|
sub_issue: false,
|
||||||
|
},
|
||||||
|
path: "workspace-views/assigned",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
created_by: user?.id ?? undefined,
|
||||||
|
sub_issue: false,
|
||||||
|
},
|
||||||
|
path: "workspace-views/created",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
subscriber: user?.id ?? undefined,
|
||||||
|
sub_issue: false,
|
||||||
|
},
|
||||||
|
path: "workspace-views/subscribed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentWorkspaceIssuePath = workspaceIssuesPath.find((path) =>
|
||||||
|
router.pathname.includes(path.path)
|
||||||
|
);
|
||||||
|
|
||||||
const calendarFetchKey = cycleId
|
const calendarFetchKey = cycleId
|
||||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams)
|
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams)
|
||||||
: moduleId
|
: moduleId
|
||||||
@ -332,6 +371,14 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
|||||||
mutate(USER_ISSUE(workspaceSlug as string));
|
mutate(USER_ISSUE(workspaceSlug as string));
|
||||||
|
|
||||||
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
|
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
|
||||||
|
|
||||||
|
if (globalViewId)
|
||||||
|
mutate(WORKSPACE_VIEW_ISSUES(globalViewId.toString(), globalViewParams));
|
||||||
|
|
||||||
|
if (currentWorkspaceIssuePath)
|
||||||
|
mutate(
|
||||||
|
WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params)
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
|
@ -4,21 +4,18 @@ import { useRouter } from "next/router";
|
|||||||
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
// hook
|
|
||||||
import useProjects from "hooks/use-projects";
|
|
||||||
import useWorkspaceMembers from "hooks/use-workspace-members";
|
|
||||||
// services
|
// services
|
||||||
import issuesService from "services/issues.service";
|
import issuesService from "services/issues.service";
|
||||||
// components
|
// components
|
||||||
import { DateFilterModal } from "components/core";
|
import { DateFilterModal } from "components/core";
|
||||||
// ui
|
// ui
|
||||||
import { Avatar, MultiLevelDropdown } from "components/ui";
|
import { MultiLevelDropdown } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import { PriorityIcon, StateGroupIcon } from "components/icons";
|
import { PriorityIcon, StateGroupIcon } from "components/icons";
|
||||||
// helpers
|
// helpers
|
||||||
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
|
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
|
||||||
// types
|
// types
|
||||||
import { IIssueFilterOptions, TStateGroups } from "types";
|
import { IIssueFilterOptions, IQuery, TStateGroups } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { WORKSPACE_LABELS } from "constants/fetch-keys";
|
import { WORKSPACE_LABELS } from "constants/fetch-keys";
|
||||||
// constants
|
// constants
|
||||||
@ -26,7 +23,7 @@ import { GROUP_CHOICES, PRIORITIES } from "constants/project";
|
|||||||
import { DATE_FILTER_OPTIONS } from "constants/filters";
|
import { DATE_FILTER_OPTIONS } from "constants/filters";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
filters: Partial<IIssueFilterOptions>;
|
filters: Partial<IIssueFilterOptions> | IQuery;
|
||||||
onSelect: (option: any) => void;
|
onSelect: (option: any) => void;
|
||||||
direction?: "left" | "right";
|
direction?: "left" | "right";
|
||||||
height?: "sm" | "md" | "rg" | "lg";
|
height?: "sm" | "md" | "rg" | "lg";
|
||||||
@ -58,11 +55,6 @@ export const MyIssuesSelectFilters: React.FC<Props> = ({
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
const { projects: allProjects } = useProjects();
|
|
||||||
const joinedProjects = allProjects?.filter((p) => p.is_member);
|
|
||||||
|
|
||||||
const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDateFilterModalOpen && (
|
{isDateFilterModalOpen && (
|
||||||
@ -82,19 +74,25 @@ export const MyIssuesSelectFilters: React.FC<Props> = ({
|
|||||||
height={height}
|
height={height}
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
id: "project",
|
id: "priority",
|
||||||
label: "Project",
|
label: "Priority",
|
||||||
value: joinedProjects,
|
value: PRIORITIES,
|
||||||
hasChildren: true,
|
hasChildren: true,
|
||||||
children: joinedProjects?.map((project) => ({
|
children: [
|
||||||
id: project.id,
|
...PRIORITIES.map((priority) => ({
|
||||||
label: <div className="flex items-center gap-2">{project.name}</div>,
|
id: priority === null ? "null" : priority,
|
||||||
value: {
|
label: (
|
||||||
key: "project",
|
<div className="flex items-center gap-2 capitalize">
|
||||||
value: project.id,
|
<PriorityIcon priority={priority} /> {priority ?? "None"}
|
||||||
},
|
</div>
|
||||||
selected: filters?.project?.includes(project.id),
|
),
|
||||||
})),
|
value: {
|
||||||
|
key: "priority",
|
||||||
|
value: priority === null ? "null" : priority,
|
||||||
|
},
|
||||||
|
selected: filters?.priority?.includes(priority === null ? "null" : priority),
|
||||||
|
})),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "state_group",
|
id: "state_group",
|
||||||
@ -144,87 +142,6 @@ export const MyIssuesSelectFilters: React.FC<Props> = ({
|
|||||||
selected: filters?.labels?.includes(label.id),
|
selected: filters?.labels?.includes(label.id),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "priority",
|
|
||||||
label: "Priority",
|
|
||||||
value: PRIORITIES,
|
|
||||||
hasChildren: true,
|
|
||||||
children: [
|
|
||||||
...PRIORITIES.map((priority) => ({
|
|
||||||
id: priority === null ? "null" : priority,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2 capitalize">
|
|
||||||
<PriorityIcon priority={priority} /> {priority ?? "None"}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "priority",
|
|
||||||
value: priority === null ? "null" : priority,
|
|
||||||
},
|
|
||||||
selected: filters?.priority?.includes(priority === null ? "null" : priority),
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "created_by",
|
|
||||||
label: "Created by",
|
|
||||||
value: workspaceMembers,
|
|
||||||
hasChildren: true,
|
|
||||||
children: workspaceMembers?.map((member) => ({
|
|
||||||
id: member.member.id,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar user={member.member} />
|
|
||||||
{member.member.display_name}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "created_by",
|
|
||||||
value: member.member.id,
|
|
||||||
},
|
|
||||||
selected: filters?.created_by?.includes(member.member.id),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "assignees",
|
|
||||||
label: "Assignees",
|
|
||||||
value: workspaceMembers,
|
|
||||||
hasChildren: true,
|
|
||||||
children: workspaceMembers?.map((member) => ({
|
|
||||||
id: member.member.id,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar user={member.member} />
|
|
||||||
{member.member.display_name}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "assignees",
|
|
||||||
value: member.member.id,
|
|
||||||
},
|
|
||||||
selected: filters?.assignees?.includes(member.member.id),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "subscriber",
|
|
||||||
label: "Subscriber",
|
|
||||||
value: workspaceMembers,
|
|
||||||
hasChildren: true,
|
|
||||||
children: workspaceMembers?.map((member) => ({
|
|
||||||
id: member.member.id,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar user={member.member} />
|
|
||||||
{member.member.display_name}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "subscriber",
|
|
||||||
value: member.member.id,
|
|
||||||
},
|
|
||||||
selected: filters?.subscriber?.includes(member.member.id),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "start_date",
|
id: "start_date",
|
||||||
label: "Start date",
|
label: "Start date",
|
||||||
|
@ -30,7 +30,7 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
|
|||||||
|
|
||||||
export const MyIssuesViewOptions: React.FC = () => {
|
export const MyIssuesViewOptions: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, workspaceViewId } = router.query;
|
const { workspaceSlug, globalViewId } = router.query;
|
||||||
|
|
||||||
const { displayFilters, setDisplayFilters, filters, setFilters } = useMyIssuesFilters(
|
const { displayFilters, setDisplayFilters, filters, setFilters } = useMyIssuesFilters(
|
||||||
workspaceSlug?.toString()
|
workspaceSlug?.toString()
|
||||||
@ -42,7 +42,7 @@ export const MyIssuesViewOptions: React.FC = () => {
|
|||||||
router.pathname.includes(pathname)
|
router.pathname.includes(pathname)
|
||||||
);
|
);
|
||||||
|
|
||||||
const showFilters = isWorkspaceViewPath || workspaceViewId;
|
const showFilters = isWorkspaceViewPath || globalViewId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
@ -14,7 +14,7 @@ import { ExistingIssuesListModal } from "components/core";
|
|||||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
import { BlockedIcon } from "components/icons";
|
import { BlockedIcon } from "components/icons";
|
||||||
// types
|
// types
|
||||||
import { BlockeIssueDetail, IIssue, ISearchIssueResponse, UserAuth } from "types";
|
import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
issueId?: string;
|
issueId?: string;
|
||||||
@ -41,6 +41,9 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
|
|||||||
setIsBlockedModalOpen(false);
|
setIsBlockedModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const blockedByIssue =
|
||||||
|
watch("related_issues")?.filter((i) => i.relation_type === "blocked_by") || [];
|
||||||
|
|
||||||
const onSubmit = async (data: ISearchIssueResponse[]) => {
|
const onSubmit = async (data: ISearchIssueResponse[]) => {
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
@ -80,18 +83,13 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
|
|||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
submitChanges({
|
submitChanges({
|
||||||
related_issues: [
|
related_issues: [...watch("related_issues"), ...response],
|
||||||
...watch("related_issues")?.filter((i) => i.relation_type !== "blocked_by"),
|
|
||||||
...response,
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExistingIssuesListModal
|
<ExistingIssuesListModal
|
||||||
|
@ -15,7 +15,7 @@ import issuesService from "services/issues.service";
|
|||||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
import { BlockerIcon } from "components/icons";
|
import { BlockerIcon } from "components/icons";
|
||||||
// types
|
// types
|
||||||
import { BlockeIssueDetail, IIssue, ISearchIssueResponse, UserAuth } from "types";
|
import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
issueId?: string;
|
issueId?: string;
|
||||||
|
@ -75,10 +75,8 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
|
|||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then(() => {
|
||||||
submitChanges({
|
submitChanges();
|
||||||
related_issues: [...watch("related_issues"), ...(response ?? [])],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
|
@ -75,10 +75,8 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
|
|||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then(() => {
|
||||||
submitChanges({
|
submitChanges();
|
||||||
related_issues: [...watch("related_issues"), ...(response ?? [])],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
|
@ -53,7 +53,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
|
|||||||
// types
|
// types
|
||||||
import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types";
|
import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { ISSUE_DETAILS } from "constants/fetch-keys";
|
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||||
import { ContrastIcon } from "components/icons";
|
import { ContrastIcon } from "components/icons";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -480,6 +480,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||||
}}
|
}}
|
||||||
watch={watchIssue}
|
watch={watchIssue}
|
||||||
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
||||||
@ -500,6 +501,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||||
}}
|
}}
|
||||||
watch={watchIssue}
|
watch={watchIssue}
|
||||||
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
||||||
@ -517,6 +519,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
|||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||||
}}
|
}}
|
||||||
watch={watchIssue}
|
watch={watchIssue}
|
||||||
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
||||||
@ -534,6 +537,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
|||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||||
}}
|
}}
|
||||||
watch={watchIssue}
|
watch={watchIssue}
|
||||||
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
// next imports
|
// next imports
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
// swr
|
||||||
|
import { mutate } from "swr";
|
||||||
// lucide icons
|
// lucide icons
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@ -13,6 +14,7 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
// components
|
// components
|
||||||
|
import { IssuePeekOverview } from "components/issues/peek-overview";
|
||||||
import { SubIssuesRootList } from "./issues-list";
|
import { SubIssuesRootList } from "./issues-list";
|
||||||
import { IssueProperty } from "./properties";
|
import { IssueProperty } from "./properties";
|
||||||
// ui
|
// ui
|
||||||
@ -21,6 +23,8 @@ import { Tooltip, CustomMenu } from "components/ui";
|
|||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IIssue } from "types";
|
import { ICurrentUserResponse, IIssue } from "types";
|
||||||
import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
||||||
|
// fetch keys
|
||||||
|
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||||
|
|
||||||
export interface ISubIssues {
|
export interface ISubIssues {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
@ -39,7 +43,6 @@ export interface ISubIssues {
|
|||||||
issueId: string,
|
issueId: string,
|
||||||
issue?: IIssue | null
|
issue?: IIssue | null
|
||||||
) => void;
|
) => void;
|
||||||
setPeekParentId: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubIssues: React.FC<ISubIssues> = ({
|
export const SubIssues: React.FC<ISubIssues> = ({
|
||||||
@ -55,14 +58,12 @@ export const SubIssues: React.FC<ISubIssues> = ({
|
|||||||
handleIssuesLoader,
|
handleIssuesLoader,
|
||||||
copyText,
|
copyText,
|
||||||
handleIssueCrudOperation,
|
handleIssueCrudOperation,
|
||||||
setPeekParentId,
|
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { query } = router;
|
||||||
|
const { peekIssue } = query as { peekIssue: string };
|
||||||
|
|
||||||
const openPeekOverview = (issue_id: string) => {
|
const openPeekOverview = (issue_id: string) => {
|
||||||
const { query } = router;
|
|
||||||
|
|
||||||
setPeekParentId(parentIssue?.id);
|
|
||||||
router.push({
|
router.push({
|
||||||
pathname: router.pathname,
|
pathname: router.pathname,
|
||||||
query: { ...query, peekIssue: issue_id },
|
query: { ...query, peekIssue: issue_id },
|
||||||
@ -200,7 +201,17 @@ export const SubIssues: React.FC<ISubIssues> = ({
|
|||||||
handleIssuesLoader={handleIssuesLoader}
|
handleIssuesLoader={handleIssuesLoader}
|
||||||
copyText={copyText}
|
copyText={copyText}
|
||||||
handleIssueCrudOperation={handleIssueCrudOperation}
|
handleIssueCrudOperation={handleIssueCrudOperation}
|
||||||
setPeekParentId={setPeekParentId}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{peekIssue && peekIssue === issue?.id && (
|
||||||
|
<IssuePeekOverview
|
||||||
|
handleMutation={() =>
|
||||||
|
parentIssue && parentIssue?.id && mutate(SUB_ISSUES(parentIssue?.id))
|
||||||
|
}
|
||||||
|
projectId={issue?.project ?? ""}
|
||||||
|
workspaceSlug={workspaceSlug ?? ""}
|
||||||
|
readOnly={!editable}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -28,7 +28,6 @@ export interface ISubIssuesRootList {
|
|||||||
issueId: string,
|
issueId: string,
|
||||||
issue?: IIssue | null
|
issue?: IIssue | null
|
||||||
) => void;
|
) => void;
|
||||||
setPeekParentId: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
|
export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
|
||||||
@ -43,7 +42,6 @@ export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
|
|||||||
handleIssuesLoader,
|
handleIssuesLoader,
|
||||||
copyText,
|
copyText,
|
||||||
handleIssueCrudOperation,
|
handleIssueCrudOperation,
|
||||||
setPeekParentId,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { data: issues, isLoading } = useSWR(
|
const { data: issues, isLoading } = useSWR(
|
||||||
workspaceSlug && projectId && parentIssue && parentIssue?.id
|
workspaceSlug && projectId && parentIssue && parentIssue?.id
|
||||||
@ -84,7 +82,6 @@ export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
|
|||||||
handleIssuesLoader={handleIssuesLoader}
|
handleIssuesLoader={handleIssuesLoader}
|
||||||
copyText={copyText}
|
copyText={copyText}
|
||||||
handleIssueCrudOperation={handleIssueCrudOperation}
|
handleIssueCrudOperation={handleIssueCrudOperation}
|
||||||
setPeekParentId={setPeekParentId}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
@ -10,7 +10,6 @@ import { ExistingIssuesListModal } from "components/core";
|
|||||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||||
import { SubIssuesRootList } from "./issues-list";
|
import { SubIssuesRootList } from "./issues-list";
|
||||||
import { ProgressBar } from "./progressbar";
|
import { ProgressBar } from "./progressbar";
|
||||||
import { IssuePeekOverview } from "components/issues/peek-overview";
|
|
||||||
// ui
|
// ui
|
||||||
import { CustomMenu } from "components/ui";
|
import { CustomMenu } from "components/ui";
|
||||||
// hooks
|
// hooks
|
||||||
@ -63,8 +62,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
const [peekParentId, setPeekParentId] = React.useState<string | null>("");
|
|
||||||
|
|
||||||
const [issuesLoader, setIssuesLoader] = React.useState<ISubIssuesRootLoaders>({
|
const [issuesLoader, setIssuesLoader] = React.useState<ISubIssuesRootLoaders>({
|
||||||
visibility: [parentIssue?.id],
|
visibility: [parentIssue?.id],
|
||||||
delete: [],
|
delete: [],
|
||||||
@ -241,7 +238,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
|
|||||||
handleIssuesLoader={handleIssuesLoader}
|
handleIssuesLoader={handleIssuesLoader}
|
||||||
copyText={copyText}
|
copyText={copyText}
|
||||||
handleIssueCrudOperation={handleIssueCrudOperation}
|
handleIssueCrudOperation={handleIssueCrudOperation}
|
||||||
setPeekParentId={setPeekParentId}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -367,13 +363,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<IssuePeekOverview
|
|
||||||
handleMutation={() => peekParentId && peekIssue && mutateSubIssues(peekParentId)}
|
|
||||||
projectId={projectId ?? ""}
|
|
||||||
workspaceSlug={workspaceSlug ?? ""}
|
|
||||||
readOnly={!isEditable}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,48 +1,49 @@
|
|||||||
import { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import useSWR, { mutate } from "swr";
|
|
||||||
|
|
||||||
// hook
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
import useWorkspaceIssuesFilters from "hooks/use-worskpace-issue-filter";
|
|
||||||
import useProjects from "hooks/use-projects";
|
|
||||||
import useUser from "hooks/use-user";
|
|
||||||
import useWorkspaceMembers from "hooks/use-workspace-members";
|
|
||||||
// context
|
// context
|
||||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||||
// services
|
// service
|
||||||
import workspaceService from "services/workspace.service";
|
|
||||||
import projectIssuesServices from "services/issues.service";
|
import projectIssuesServices from "services/issues.service";
|
||||||
// layouts
|
// hooks
|
||||||
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
|
import useProjects from "hooks/use-projects";
|
||||||
|
import useUser from "hooks/use-user";
|
||||||
|
import { useWorkspaceView } from "hooks/use-workspace-view";
|
||||||
|
import useWorkspaceMembers from "hooks/use-workspace-members";
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import { FiltersList, SpreadsheetView } from "components/core";
|
|
||||||
import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation";
|
import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation";
|
||||||
import { WorkspaceIssuesViewOptions } from "components/issues/workspace-views/workspace-issue-view-option";
|
|
||||||
import { CreateUpdateViewModal } from "components/views";
|
|
||||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
|
||||||
// ui
|
|
||||||
import { EmptyState, PrimaryButton } from "components/ui";
|
import { EmptyState, PrimaryButton } from "components/ui";
|
||||||
// icons
|
import { SpreadsheetView, WorkspaceFiltersList } from "components/core";
|
||||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||||
import { CheckCircle } from "lucide-react";
|
import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal";
|
||||||
// images
|
// icon
|
||||||
|
import { PlusIcon } from "components/icons";
|
||||||
|
// image
|
||||||
import emptyView from "public/empty-state/view.svg";
|
import emptyView from "public/empty-state/view.svg";
|
||||||
// fetch-keys
|
// constants
|
||||||
import {
|
import { WORKSPACE_LABELS } from "constants/fetch-keys";
|
||||||
WORKSPACE_LABELS,
|
|
||||||
WORKSPACE_VIEWS_LIST,
|
|
||||||
WORKSPACE_VIEW_DETAILS,
|
|
||||||
WORKSPACE_VIEW_ISSUES,
|
|
||||||
} from "constants/fetch-keys";
|
|
||||||
// constant
|
|
||||||
import { STATE_GROUP } from "constants/project";
|
import { STATE_GROUP } from "constants/project";
|
||||||
// types
|
// types
|
||||||
import { IIssue, IIssueFilterOptions, IView } from "types";
|
import { IIssue, IWorkspaceIssueFilterOptions } from "types";
|
||||||
|
|
||||||
|
export const WorkspaceViewIssues = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, globalViewId } = router.query;
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const { memberRole } = useProjectMyMembership();
|
||||||
|
const { user } = useUser();
|
||||||
|
const { isGuest, isViewer } = useWorkspaceMembers(
|
||||||
|
workspaceSlug?.toString(),
|
||||||
|
Boolean(workspaceSlug)
|
||||||
|
);
|
||||||
|
const { filters, viewIssues, mutateViewIssues, handleFilters } = useWorkspaceView();
|
||||||
|
|
||||||
const WorkspaceView: React.FC = () => {
|
|
||||||
const [createViewModal, setCreateViewModal] = useState<any>(null);
|
const [createViewModal, setCreateViewModal] = useState<any>(null);
|
||||||
|
|
||||||
// create issue modal
|
// create issue modal
|
||||||
@ -61,38 +62,6 @@ const WorkspaceView: React.FC = () => {
|
|||||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
|
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, workspaceViewId } = router.query;
|
|
||||||
|
|
||||||
const { memberRole } = useProjectMyMembership();
|
|
||||||
|
|
||||||
const { user } = useUser();
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const { data: viewDetails, error } = useSWR(
|
|
||||||
workspaceSlug && workspaceViewId ? WORKSPACE_VIEW_DETAILS(workspaceViewId.toString()) : null,
|
|
||||||
workspaceSlug && workspaceViewId
|
|
||||||
? () => workspaceService.getViewDetails(workspaceSlug.toString(), workspaceViewId.toString())
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const { params, filters, setFilters } = useWorkspaceIssuesFilters(
|
|
||||||
workspaceSlug?.toString(),
|
|
||||||
workspaceViewId?.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
const { isGuest, isViewer } = useWorkspaceMembers(
|
|
||||||
workspaceSlug?.toString(),
|
|
||||||
Boolean(workspaceSlug)
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: viewIssues, mutate: mutateIssues } = useSWR(
|
|
||||||
workspaceSlug && viewDetails ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null,
|
|
||||||
workspaceSlug && viewDetails
|
|
||||||
? () => workspaceService.getViewIssues(workspaceSlug.toString(), params)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const { projects: allProjects } = useProjects();
|
const { projects: allProjects } = useProjects();
|
||||||
const joinedProjects = allProjects?.filter((p) => p.is_member);
|
const joinedProjects = allProjects?.filter((p) => p.is_member);
|
||||||
|
|
||||||
@ -103,39 +72,6 @@ const WorkspaceView: React.FC = () => {
|
|||||||
|
|
||||||
const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
|
const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
|
||||||
|
|
||||||
const updateView = async (payload: IIssueFilterOptions) => {
|
|
||||||
const payloadData = {
|
|
||||||
query_data: payload,
|
|
||||||
};
|
|
||||||
|
|
||||||
await workspaceService
|
|
||||||
.updateView(workspaceSlug as string, workspaceViewId as string, payloadData)
|
|
||||||
.then((res) => {
|
|
||||||
mutate<IView[]>(
|
|
||||||
WORKSPACE_VIEWS_LIST(workspaceSlug as string),
|
|
||||||
(prevData) =>
|
|
||||||
prevData?.map((p) => {
|
|
||||||
if (p.id === res.id) return { ...p, ...payloadData };
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "View updated successfully.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "View could not be updated. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeIssueCopy = useCallback(
|
const makeIssueCopy = useCallback(
|
||||||
(issue: IIssue) => {
|
(issue: IIssue) => {
|
||||||
setCreateIssueModal(true);
|
setCreateIssueModal(true);
|
||||||
@ -176,80 +112,51 @@ const WorkspaceView: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nullFilters =
|
const nullFilters =
|
||||||
filters &&
|
filters.filters &&
|
||||||
Object.keys(filters).filter((key) => filters[key as keyof IIssueFilterOptions] === null);
|
Object.keys(filters.filters).filter(
|
||||||
|
(key) =>
|
||||||
|
filters.filters[key as keyof IWorkspaceIssueFilterOptions] === null ||
|
||||||
|
(filters.filters[key as keyof IWorkspaceIssueFilterOptions]?.length ?? 0) <= 0
|
||||||
|
);
|
||||||
|
|
||||||
const areFiltersApplied =
|
const areFiltersApplied =
|
||||||
filters &&
|
filters.filters &&
|
||||||
Object.keys(filters).length > 0 &&
|
Object.keys(filters.filters).length > 0 &&
|
||||||
nullFilters.length !== Object.keys(filters).length;
|
nullFilters.length !== Object.keys(filters.filters).length;
|
||||||
|
|
||||||
const isNotAllowed = isGuest || isViewer;
|
const isNotAllowed = isGuest || isViewer;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceAuthorizationLayout
|
<>
|
||||||
breadcrumbs={
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<CheckCircle className="h-[18px] w-[18px] stroke-[1.5]" />
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
right={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<WorkspaceIssuesViewOptions />
|
|
||||||
<PrimaryButton
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={() => {
|
|
||||||
const e = new KeyboardEvent("keydown", { key: "c" });
|
|
||||||
document.dispatchEvent(e);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
Add Issue
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CreateUpdateIssueModal
|
<CreateUpdateIssueModal
|
||||||
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
|
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
|
||||||
handleClose={() => setCreateIssueModal(false)}
|
handleClose={() => setCreateIssueModal(false)}
|
||||||
prePopulateData={{
|
prePopulateData={{
|
||||||
...preloadedData,
|
...preloadedData,
|
||||||
}}
|
}}
|
||||||
onSubmit={async () => {
|
onSubmit={async () => mutateViewIssues()}
|
||||||
mutateIssues();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<CreateUpdateIssueModal
|
<CreateUpdateIssueModal
|
||||||
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
|
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
|
||||||
handleClose={() => setEditIssueModal(false)}
|
handleClose={() => setEditIssueModal(false)}
|
||||||
data={issueToEdit}
|
data={issueToEdit}
|
||||||
onSubmit={async () => {
|
onSubmit={async () => mutateViewIssues()}
|
||||||
mutateIssues();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<DeleteIssueModal
|
<DeleteIssueModal
|
||||||
handleClose={() => setDeleteIssueModal(false)}
|
handleClose={() => setDeleteIssueModal(false)}
|
||||||
isOpen={deleteIssueModal}
|
isOpen={deleteIssueModal}
|
||||||
data={issueToDelete}
|
data={issueToDelete}
|
||||||
user={user}
|
user={user}
|
||||||
onSubmit={async () => {
|
onSubmit={async () => mutateViewIssues()}
|
||||||
mutateIssues();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<CreateUpdateViewModal
|
<CreateUpdateWorkspaceViewModal
|
||||||
isOpen={createViewModal !== null}
|
isOpen={createViewModal !== null}
|
||||||
handleClose={() => setCreateViewModal(null)}
|
handleClose={() => setCreateViewModal(null)}
|
||||||
viewType="workspace"
|
|
||||||
preLoadedData={createViewModal}
|
preLoadedData={createViewModal}
|
||||||
user={user}
|
|
||||||
/>
|
/>
|
||||||
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
|
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
|
||||||
<div className="h-full w-full border-b border-custom-border-300">
|
<div className="h-full w-full border-b border-custom-border-300">
|
||||||
<WorkspaceViewsNavigation handleAddView={() => setCreateViewModal(true)} />
|
<WorkspaceViewsNavigation handleAddView={() => setCreateViewModal(true)} />
|
||||||
{error ? (
|
{false ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
image={emptyView}
|
image={emptyView}
|
||||||
title="View does not exist"
|
title="View does not exist"
|
||||||
@ -264,15 +171,15 @@ const WorkspaceView: React.FC = () => {
|
|||||||
{areFiltersApplied && (
|
{areFiltersApplied && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
|
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
|
||||||
<FiltersList
|
<WorkspaceFiltersList
|
||||||
filters={filters}
|
filters={filters.filters}
|
||||||
setFilters={(updatedFilter) => setFilters(updatedFilter)}
|
setFilters={(updatedFilter) => handleFilters("filters", updatedFilter)}
|
||||||
labels={workspaceLabels}
|
labels={workspaceLabels}
|
||||||
members={workspaceMembers?.map((m) => m.member)}
|
members={workspaceMembers?.map((m) => m.member)}
|
||||||
stateGroup={STATE_GROUP}
|
stateGroup={STATE_GROUP}
|
||||||
project={joinedProjects}
|
project={joinedProjects}
|
||||||
clearAllFilters={() =>
|
clearAllFilters={() =>
|
||||||
setFilters({
|
handleFilters("filters", {
|
||||||
assignees: null,
|
assignees: null,
|
||||||
created_by: null,
|
created_by: null,
|
||||||
labels: null,
|
labels: null,
|
||||||
@ -287,17 +194,22 @@ const WorkspaceView: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (workspaceViewId) {
|
if (globalViewId) {
|
||||||
updateView(filters);
|
handleFilters("filters", filters.filters, true);
|
||||||
|
setToastAlert({
|
||||||
|
title: "View updated",
|
||||||
|
message: "Your view has been updated",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
} else
|
} else
|
||||||
setCreateViewModal({
|
setCreateViewModal({
|
||||||
query: filters,
|
query: filters.filters,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 text-sm"
|
className="flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
{!workspaceViewId && <PlusIcon className="h-4 w-4" />}
|
{!globalViewId && <PlusIcon className="h-4 w-4" />}
|
||||||
{workspaceViewId ? "Update" : "Save"} view
|
{globalViewId ? "Update" : "Save"} view
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
{<div className="mt-3 border-t border-custom-border-200" />}
|
{<div className="mt-3 border-t border-custom-border-200" />}
|
||||||
@ -305,7 +217,7 @@ const WorkspaceView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<SpreadsheetView
|
<SpreadsheetView
|
||||||
spreadsheetIssues={viewIssues}
|
spreadsheetIssues={viewIssues}
|
||||||
mutateIssues={mutateIssues}
|
mutateIssues={mutateViewIssues}
|
||||||
handleIssueAction={handleIssueAction}
|
handleIssueAction={handleIssueAction}
|
||||||
disableUserActions={isNotAllowed ?? false}
|
disableUserActions={isNotAllowed ?? false}
|
||||||
user={user}
|
user={user}
|
||||||
@ -315,8 +227,6 @@ const WorkspaceView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</WorkspaceAuthorizationLayout>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WorkspaceView;
|
|
236
web/components/issues/workspace-views/workspace-all-issue.tsx
Normal file
236
web/components/issues/workspace-views/workspace-all-issue.tsx
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// hook
|
||||||
|
import useUser from "hooks/use-user";
|
||||||
|
import useWorkspaceMembers from "hooks/use-workspace-members";
|
||||||
|
import useProjects from "hooks/use-projects";
|
||||||
|
import { useWorkspaceView } from "hooks/use-workspace-view";
|
||||||
|
// context
|
||||||
|
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||||
|
// services
|
||||||
|
import workspaceService from "services/workspace.service";
|
||||||
|
import projectIssuesServices from "services/issues.service";
|
||||||
|
// components
|
||||||
|
import { SpreadsheetView, WorkspaceFiltersList } from "components/core";
|
||||||
|
import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation";
|
||||||
|
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||||
|
import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal";
|
||||||
|
// ui
|
||||||
|
import { PrimaryButton } from "components/ui";
|
||||||
|
// icons
|
||||||
|
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||||
|
// fetch-keys
|
||||||
|
import { WORKSPACE_LABELS, WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys";
|
||||||
|
// constants
|
||||||
|
import { STATE_GROUP } from "constants/project";
|
||||||
|
// types
|
||||||
|
import { IIssue, IWorkspaceIssueFilterOptions } from "types";
|
||||||
|
|
||||||
|
export const WorkspaceAllIssue = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, globalViewId } = router.query;
|
||||||
|
|
||||||
|
const [createViewModal, setCreateViewModal] = useState<any>(null);
|
||||||
|
|
||||||
|
// create issue modal
|
||||||
|
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||||
|
const [preloadedData, setPreloadedData] = useState<
|
||||||
|
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
// update issue modal
|
||||||
|
const [editIssueModal, setEditIssueModal] = useState(false);
|
||||||
|
const [issueToEdit, setIssueToEdit] = useState<
|
||||||
|
(IIssue & { actionType: "edit" | "delete" }) | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
// delete issue modal
|
||||||
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
|
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
|
||||||
|
|
||||||
|
const { user } = useUser();
|
||||||
|
const { memberRole } = useProjectMyMembership();
|
||||||
|
|
||||||
|
const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
|
||||||
|
|
||||||
|
const { data: workspaceLabels } = useSWR(
|
||||||
|
workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
|
||||||
|
workspaceSlug ? () => projectIssuesServices.getWorkspaceLabels(workspaceSlug.toString()) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { filters, handleFilters } = useWorkspaceView();
|
||||||
|
|
||||||
|
const params: any = {
|
||||||
|
assignees: filters?.filters?.assignees ? filters?.filters?.assignees.join(",") : undefined,
|
||||||
|
subscriber: filters?.filters?.subscriber ? filters?.filters?.subscriber.join(",") : undefined,
|
||||||
|
state_group: filters?.filters?.state_group
|
||||||
|
? filters?.filters?.state_group.join(",")
|
||||||
|
: undefined,
|
||||||
|
priority: filters?.filters?.priority ? filters?.filters?.priority.join(",") : undefined,
|
||||||
|
labels: filters?.filters?.labels ? filters?.filters?.labels.join(",") : undefined,
|
||||||
|
created_by: filters?.filters?.created_by ? filters?.filters?.created_by.join(",") : undefined,
|
||||||
|
start_date: filters?.filters?.start_date ? filters?.filters?.start_date.join(",") : undefined,
|
||||||
|
target_date: filters?.filters?.target_date
|
||||||
|
? filters?.filters?.target_date.join(",")
|
||||||
|
: undefined,
|
||||||
|
project: filters?.filters?.project ? filters?.filters?.project.join(",") : undefined,
|
||||||
|
sub_issue: false,
|
||||||
|
type: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: viewIssues, mutate: mutateViewIssues } = useSWR(
|
||||||
|
workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null,
|
||||||
|
workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeIssueCopy = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setCreateIssueModal(true);
|
||||||
|
|
||||||
|
setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" });
|
||||||
|
},
|
||||||
|
[setCreateIssueModal, setPreloadedData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditIssue = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setEditIssueModal(true);
|
||||||
|
setIssueToEdit({
|
||||||
|
...issue,
|
||||||
|
actionType: "edit",
|
||||||
|
cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null,
|
||||||
|
module: issue.issue_module ? issue.issue_module.module : null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setEditIssueModal, setIssueToEdit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteIssue = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setDeleteIssueModal(true);
|
||||||
|
setIssueToDelete(issue);
|
||||||
|
},
|
||||||
|
[setDeleteIssueModal, setIssueToDelete]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIssueAction = useCallback(
|
||||||
|
(issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => {
|
||||||
|
if (action === "copy") makeIssueCopy(issue);
|
||||||
|
else if (action === "edit") handleEditIssue(issue);
|
||||||
|
else if (action === "delete") handleDeleteIssue(issue);
|
||||||
|
},
|
||||||
|
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const nullFilters =
|
||||||
|
filters.filters &&
|
||||||
|
Object.keys(filters.filters).filter(
|
||||||
|
(key) =>
|
||||||
|
filters.filters[key as keyof IWorkspaceIssueFilterOptions] === null ||
|
||||||
|
(filters.filters[key as keyof IWorkspaceIssueFilterOptions]?.length ?? 0) <= 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const areFiltersApplied =
|
||||||
|
filters.filters &&
|
||||||
|
Object.keys(filters.filters).length > 0 &&
|
||||||
|
nullFilters.length !== Object.keys(filters.filters).length;
|
||||||
|
|
||||||
|
const { projects: allProjects } = useProjects();
|
||||||
|
const joinedProjects = allProjects?.filter((p) => p.is_member);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateUpdateIssueModal
|
||||||
|
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
|
||||||
|
handleClose={() => setCreateIssueModal(false)}
|
||||||
|
prePopulateData={{
|
||||||
|
...preloadedData,
|
||||||
|
}}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateViewIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CreateUpdateIssueModal
|
||||||
|
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
|
||||||
|
handleClose={() => setEditIssueModal(false)}
|
||||||
|
data={issueToEdit}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateViewIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DeleteIssueModal
|
||||||
|
handleClose={() => setDeleteIssueModal(false)}
|
||||||
|
isOpen={deleteIssueModal}
|
||||||
|
data={issueToDelete}
|
||||||
|
user={user}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateViewIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CreateUpdateWorkspaceViewModal
|
||||||
|
isOpen={createViewModal !== null}
|
||||||
|
handleClose={() => setCreateViewModal(null)}
|
||||||
|
preLoadedData={createViewModal}
|
||||||
|
/>
|
||||||
|
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
|
||||||
|
<div className="h-full w-full border-b border-custom-border-300">
|
||||||
|
<WorkspaceViewsNavigation handleAddView={() => setCreateViewModal(true)} />
|
||||||
|
<div className="h-full w-full flex flex-col">
|
||||||
|
{areFiltersApplied && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
|
||||||
|
<WorkspaceFiltersList
|
||||||
|
filters={filters.filters}
|
||||||
|
setFilters={(updatedFilter) => handleFilters("filters", updatedFilter)}
|
||||||
|
labels={workspaceLabels}
|
||||||
|
members={workspaceMembers?.map((m) => m.member)}
|
||||||
|
stateGroup={STATE_GROUP}
|
||||||
|
project={joinedProjects}
|
||||||
|
clearAllFilters={() =>
|
||||||
|
handleFilters("filters", {
|
||||||
|
assignees: null,
|
||||||
|
created_by: null,
|
||||||
|
labels: null,
|
||||||
|
priority: null,
|
||||||
|
state_group: null,
|
||||||
|
start_date: null,
|
||||||
|
target_date: null,
|
||||||
|
subscriber: null,
|
||||||
|
project: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
if (globalViewId) handleFilters("filters", filters.filters, true);
|
||||||
|
else
|
||||||
|
setCreateViewModal({
|
||||||
|
query: filters.filters,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
{!globalViewId && <PlusIcon className="h-4 w-4" />}
|
||||||
|
{globalViewId ? "Update" : "Save"} view
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
{<div className="mt-3 border-t border-custom-border-200" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<SpreadsheetView
|
||||||
|
spreadsheetIssues={viewIssues}
|
||||||
|
mutateIssues={mutateViewIssues}
|
||||||
|
handleIssueAction={handleIssueAction}
|
||||||
|
disableUserActions={false}
|
||||||
|
user={user}
|
||||||
|
userAuth={memberRole}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,148 @@
|
|||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// hook
|
||||||
|
import useUser from "hooks/use-user";
|
||||||
|
// context
|
||||||
|
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||||
|
// services
|
||||||
|
import workspaceService from "services/workspace.service";
|
||||||
|
// components
|
||||||
|
import { SpreadsheetView } from "components/core";
|
||||||
|
import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation";
|
||||||
|
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||||
|
import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal";
|
||||||
|
// fetch-keys
|
||||||
|
import { WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
|
||||||
|
export const WorkspaceAssignedIssue = () => {
|
||||||
|
const [createViewModal, setCreateViewModal] = useState<any>(null);
|
||||||
|
|
||||||
|
// create issue modal
|
||||||
|
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||||
|
const [preloadedData, setPreloadedData] = useState<
|
||||||
|
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
// update issue modal
|
||||||
|
const [editIssueModal, setEditIssueModal] = useState(false);
|
||||||
|
const [issueToEdit, setIssueToEdit] = useState<
|
||||||
|
(IIssue & { actionType: "edit" | "delete" }) | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
// delete issue modal
|
||||||
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
|
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
const { memberRole } = useProjectMyMembership();
|
||||||
|
|
||||||
|
const params: any = {
|
||||||
|
assignees: user?.id ?? undefined,
|
||||||
|
sub_issue: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: viewIssues, mutate: mutateIssues } = useSWR(
|
||||||
|
workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null,
|
||||||
|
workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeIssueCopy = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setCreateIssueModal(true);
|
||||||
|
|
||||||
|
setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" });
|
||||||
|
},
|
||||||
|
[setCreateIssueModal, setPreloadedData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditIssue = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setEditIssueModal(true);
|
||||||
|
setIssueToEdit({
|
||||||
|
...issue,
|
||||||
|
actionType: "edit",
|
||||||
|
cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null,
|
||||||
|
module: issue.issue_module ? issue.issue_module.module : null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setEditIssueModal, setIssueToEdit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteIssue = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setDeleteIssueModal(true);
|
||||||
|
setIssueToDelete(issue);
|
||||||
|
},
|
||||||
|
[setDeleteIssueModal, setIssueToDelete]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIssueAction = useCallback(
|
||||||
|
(issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => {
|
||||||
|
if (action === "copy") makeIssueCopy(issue);
|
||||||
|
else if (action === "edit") handleEditIssue(issue);
|
||||||
|
else if (action === "delete") handleDeleteIssue(issue);
|
||||||
|
},
|
||||||
|
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateUpdateIssueModal
|
||||||
|
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
|
||||||
|
handleClose={() => setCreateIssueModal(false)}
|
||||||
|
prePopulateData={{
|
||||||
|
...preloadedData,
|
||||||
|
}}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CreateUpdateIssueModal
|
||||||
|
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
|
||||||
|
handleClose={() => setEditIssueModal(false)}
|
||||||
|
data={issueToEdit}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DeleteIssueModal
|
||||||
|
handleClose={() => setDeleteIssueModal(false)}
|
||||||
|
isOpen={deleteIssueModal}
|
||||||
|
data={issueToDelete}
|
||||||
|
user={user}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CreateUpdateWorkspaceViewModal
|
||||||
|
isOpen={createViewModal !== null}
|
||||||
|
handleClose={() => setCreateViewModal(null)}
|
||||||
|
preLoadedData={createViewModal}
|
||||||
|
/>
|
||||||
|
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
|
||||||
|
<div className="h-full w-full border-b border-custom-border-300">
|
||||||
|
<WorkspaceViewsNavigation handleAddView={() => setCreateViewModal(true)} />
|
||||||
|
|
||||||
|
<div className="h-full w-full flex flex-col">
|
||||||
|
<SpreadsheetView
|
||||||
|
spreadsheetIssues={viewIssues}
|
||||||
|
mutateIssues={mutateIssues}
|
||||||
|
handleIssueAction={handleIssueAction}
|
||||||
|
disableUserActions={false}
|
||||||
|
user={user}
|
||||||
|
userAuth={memberRole}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,147 @@
|
|||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// hook
|
||||||
|
import useUser from "hooks/use-user";
|
||||||
|
// context
|
||||||
|
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||||
|
// services
|
||||||
|
import workspaceService from "services/workspace.service";
|
||||||
|
// components
|
||||||
|
import { SpreadsheetView } from "components/core";
|
||||||
|
import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation";
|
||||||
|
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||||
|
import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal";
|
||||||
|
// fetch-keys
|
||||||
|
import { WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
|
||||||
|
export const WorkspaceCreatedIssues = () => {
|
||||||
|
const [createViewModal, setCreateViewModal] = useState<any>(null);
|
||||||
|
|
||||||
|
// create issue modal
|
||||||
|
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||||
|
const [preloadedData, setPreloadedData] = useState<
|
||||||
|
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
// update issue modal
|
||||||
|
const [editIssueModal, setEditIssueModal] = useState(false);
|
||||||
|
const [issueToEdit, setIssueToEdit] = useState<
|
||||||
|
(IIssue & { actionType: "edit" | "delete" }) | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
// delete issue modal
|
||||||
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
|
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { user } = useUser();
|
||||||
|
const { memberRole } = useProjectMyMembership();
|
||||||
|
|
||||||
|
const params: any = {
|
||||||
|
created_by: user?.id ?? undefined,
|
||||||
|
sub_issue: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: viewIssues, mutate: mutateIssues } = useSWR(
|
||||||
|
workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null,
|
||||||
|
workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeIssueCopy = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setCreateIssueModal(true);
|
||||||
|
|
||||||
|
setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" });
|
||||||
|
},
|
||||||
|
[setCreateIssueModal, setPreloadedData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditIssue = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setEditIssueModal(true);
|
||||||
|
setIssueToEdit({
|
||||||
|
...issue,
|
||||||
|
actionType: "edit",
|
||||||
|
cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null,
|
||||||
|
module: issue.issue_module ? issue.issue_module.module : null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setEditIssueModal, setIssueToEdit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteIssue = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setDeleteIssueModal(true);
|
||||||
|
setIssueToDelete(issue);
|
||||||
|
},
|
||||||
|
[setDeleteIssueModal, setIssueToDelete]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIssueAction = useCallback(
|
||||||
|
(issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => {
|
||||||
|
if (action === "copy") makeIssueCopy(issue);
|
||||||
|
else if (action === "edit") handleEditIssue(issue);
|
||||||
|
else if (action === "delete") handleDeleteIssue(issue);
|
||||||
|
},
|
||||||
|
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateUpdateIssueModal
|
||||||
|
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
|
||||||
|
handleClose={() => setCreateIssueModal(false)}
|
||||||
|
prePopulateData={{
|
||||||
|
...preloadedData,
|
||||||
|
}}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CreateUpdateIssueModal
|
||||||
|
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
|
||||||
|
handleClose={() => setEditIssueModal(false)}
|
||||||
|
data={issueToEdit}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DeleteIssueModal
|
||||||
|
handleClose={() => setDeleteIssueModal(false)}
|
||||||
|
isOpen={deleteIssueModal}
|
||||||
|
data={issueToDelete}
|
||||||
|
user={user}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CreateUpdateWorkspaceViewModal
|
||||||
|
isOpen={createViewModal !== null}
|
||||||
|
handleClose={() => setCreateViewModal(null)}
|
||||||
|
preLoadedData={createViewModal}
|
||||||
|
/>
|
||||||
|
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
|
||||||
|
<div className="h-full w-full border-b border-custom-border-300">
|
||||||
|
<WorkspaceViewsNavigation handleAddView={() => setCreateViewModal(true)} />
|
||||||
|
<div className="h-full w-full flex flex-col">
|
||||||
|
<SpreadsheetView
|
||||||
|
spreadsheetIssues={viewIssues}
|
||||||
|
mutateIssues={mutateIssues}
|
||||||
|
handleIssueAction={handleIssueAction}
|
||||||
|
disableUserActions={false}
|
||||||
|
user={user}
|
||||||
|
userAuth={memberRole}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -3,10 +3,9 @@ import React from "react";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
// hooks
|
// hooks
|
||||||
import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter";
|
import { useWorkspaceView } from "hooks/use-workspace-view";
|
||||||
import useWorkspaceIssuesFilters from "hooks/use-worskpace-issue-filter";
|
|
||||||
// components
|
// components
|
||||||
import { MyIssuesSelectFilters } from "components/issues";
|
import { GlobalSelectFilters } from "components/workspace/views/global-select-filters";
|
||||||
// ui
|
// ui
|
||||||
import { Tooltip } from "components/ui";
|
import { Tooltip } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
@ -31,18 +30,13 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
|
|||||||
|
|
||||||
export const WorkspaceIssuesViewOptions: React.FC = () => {
|
export const WorkspaceIssuesViewOptions: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, workspaceViewId } = router.query;
|
const { workspaceSlug, globalViewId } = router.query;
|
||||||
|
|
||||||
const { displayFilters, setDisplayFilters } = useMyIssuesFilters(workspaceSlug?.toString());
|
const { filters, handleFilters } = useWorkspaceView();
|
||||||
|
|
||||||
const { filters, setFilters } = useWorkspaceIssuesFilters(
|
|
||||||
workspaceSlug?.toString(),
|
|
||||||
workspaceViewId?.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
const isWorkspaceViewPath = router.pathname.includes("workspace-views/all-issues");
|
const isWorkspaceViewPath = router.pathname.includes("workspace-views/all-issues");
|
||||||
|
|
||||||
const showFilters = isWorkspaceViewPath || workspaceViewId;
|
const showFilters = isWorkspaceViewPath || globalViewId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -58,12 +52,12 @@ export const WorkspaceIssuesViewOptions: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-100 duration-300 ${
|
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-100 duration-300 ${
|
||||||
displayFilters?.layout === option.type
|
filters.display_filters?.layout === option.type
|
||||||
? "bg-custom-sidebar-background-100 shadow-sm"
|
? "bg-custom-sidebar-background-100 shadow-sm"
|
||||||
: "text-custom-sidebar-text-200"
|
: "text-custom-sidebar-text-200"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisplayFilters({ layout: option.type });
|
handleFilters("display_filters", { layout: option.type }, true);
|
||||||
if (option.type === "spreadsheet")
|
if (option.type === "spreadsheet")
|
||||||
router.push(`/${workspaceSlug}/workspace-views/all-issues`);
|
router.push(`/${workspaceSlug}/workspace-views/all-issues`);
|
||||||
else router.push(`/${workspaceSlug}/workspace-views`);
|
else router.push(`/${workspaceSlug}/workspace-views`);
|
||||||
@ -82,37 +76,38 @@ export const WorkspaceIssuesViewOptions: React.FC = () => {
|
|||||||
|
|
||||||
{showFilters && (
|
{showFilters && (
|
||||||
<>
|
<>
|
||||||
<MyIssuesSelectFilters
|
<GlobalSelectFilters
|
||||||
filters={filters}
|
filters={filters.filters}
|
||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
const key = option.key as keyof typeof filters;
|
const key = option.key as keyof typeof filters.filters;
|
||||||
|
|
||||||
if (key === "start_date" || key === "target_date") {
|
if (key === "start_date" || key === "target_date") {
|
||||||
const valueExists = checkIfArraysHaveSameElements(
|
const valueExists = checkIfArraysHaveSameElements(
|
||||||
filters?.[key] ?? [],
|
filters.filters?.[key] ?? [],
|
||||||
option.value
|
option.value
|
||||||
);
|
);
|
||||||
|
|
||||||
setFilters({
|
handleFilters("filters", {
|
||||||
|
...filters,
|
||||||
[key]: valueExists ? null : option.value,
|
[key]: valueExists ? null : option.value,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const valueExists = filters[key]?.includes(option.value);
|
if (!filters?.filters?.[key]?.includes(option.value))
|
||||||
|
handleFilters("filters", {
|
||||||
if (valueExists)
|
...filters,
|
||||||
setFilters({
|
[key]: [...((filters?.filters?.[key] as any[]) ?? []), option.value],
|
||||||
[option.key]: ((filters[key] ?? []) as any[])?.filter(
|
});
|
||||||
(val) => val !== option.value
|
else {
|
||||||
|
handleFilters("filters", {
|
||||||
|
...filters,
|
||||||
|
[key]: (filters?.filters?.[key] as any[])?.filter(
|
||||||
|
(item) => item !== option.value
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
else
|
}
|
||||||
setFilters({
|
|
||||||
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
direction="left"
|
direction="left"
|
||||||
height="rg"
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -0,0 +1,148 @@
|
|||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// hook
|
||||||
|
import useUser from "hooks/use-user";
|
||||||
|
// context
|
||||||
|
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||||
|
// services
|
||||||
|
import workspaceService from "services/workspace.service";
|
||||||
|
// components
|
||||||
|
import { SpreadsheetView } from "components/core";
|
||||||
|
import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation";
|
||||||
|
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||||
|
import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal";
|
||||||
|
// fetch-keys
|
||||||
|
import { WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
|
||||||
|
export const WorkspaceSubscribedIssues = () => {
|
||||||
|
const [createViewModal, setCreateViewModal] = useState<any>(null);
|
||||||
|
|
||||||
|
// create issue modal
|
||||||
|
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||||
|
const [preloadedData, setPreloadedData] = useState<
|
||||||
|
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
// update issue modal
|
||||||
|
const [editIssueModal, setEditIssueModal] = useState(false);
|
||||||
|
const [issueToEdit, setIssueToEdit] = useState<
|
||||||
|
(IIssue & { actionType: "edit" | "delete" }) | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
// delete issue modal
|
||||||
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
|
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { user } = useUser();
|
||||||
|
const { memberRole } = useProjectMyMembership();
|
||||||
|
|
||||||
|
const params: any = {
|
||||||
|
subscriber: user?.id ?? undefined,
|
||||||
|
sub_issue: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: viewIssues, mutate: mutateIssues } = useSWR(
|
||||||
|
workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null,
|
||||||
|
workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeIssueCopy = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setCreateIssueModal(true);
|
||||||
|
|
||||||
|
setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" });
|
||||||
|
},
|
||||||
|
[setCreateIssueModal, setPreloadedData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditIssue = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setEditIssueModal(true);
|
||||||
|
setIssueToEdit({
|
||||||
|
...issue,
|
||||||
|
actionType: "edit",
|
||||||
|
cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null,
|
||||||
|
module: issue.issue_module ? issue.issue_module.module : null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setEditIssueModal, setIssueToEdit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteIssue = useCallback(
|
||||||
|
(issue: IIssue) => {
|
||||||
|
setDeleteIssueModal(true);
|
||||||
|
setIssueToDelete(issue);
|
||||||
|
},
|
||||||
|
[setDeleteIssueModal, setIssueToDelete]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIssueAction = useCallback(
|
||||||
|
(issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => {
|
||||||
|
if (action === "copy") makeIssueCopy(issue);
|
||||||
|
else if (action === "edit") handleEditIssue(issue);
|
||||||
|
else if (action === "delete") handleDeleteIssue(issue);
|
||||||
|
},
|
||||||
|
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateUpdateIssueModal
|
||||||
|
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
|
||||||
|
handleClose={() => setCreateIssueModal(false)}
|
||||||
|
prePopulateData={{
|
||||||
|
...preloadedData,
|
||||||
|
}}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CreateUpdateIssueModal
|
||||||
|
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
|
||||||
|
handleClose={() => setEditIssueModal(false)}
|
||||||
|
data={issueToEdit}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DeleteIssueModal
|
||||||
|
handleClose={() => setDeleteIssueModal(false)}
|
||||||
|
isOpen={deleteIssueModal}
|
||||||
|
data={issueToDelete}
|
||||||
|
user={user}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateIssues();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CreateUpdateWorkspaceViewModal
|
||||||
|
isOpen={createViewModal !== null}
|
||||||
|
handleClose={() => setCreateViewModal(null)}
|
||||||
|
preLoadedData={createViewModal}
|
||||||
|
/>
|
||||||
|
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
|
||||||
|
<div className="h-full w-full border-b border-custom-border-300">
|
||||||
|
<WorkspaceViewsNavigation handleAddView={() => setCreateViewModal(true)} />
|
||||||
|
|
||||||
|
<div className="h-full w-full flex flex-col">
|
||||||
|
<SpreadsheetView
|
||||||
|
spreadsheetIssues={viewIssues}
|
||||||
|
mutateIssues={mutateIssues}
|
||||||
|
handleIssueAction={handleIssueAction}
|
||||||
|
disableUserActions={false}
|
||||||
|
user={user}
|
||||||
|
userAuth={memberRole}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -10,7 +10,7 @@ import useEstimateOption from "hooks/use-estimate-option";
|
|||||||
// components
|
// components
|
||||||
import { MyIssuesSelectFilters } from "components/issues";
|
import { MyIssuesSelectFilters } from "components/issues";
|
||||||
// ui
|
// ui
|
||||||
import { CustomMenu, CustomSearchSelect, ToggleSwitch, Tooltip } from "components/ui";
|
import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||||
import { FormatListBulletedOutlined, GridViewOutlined } from "@mui/icons-material";
|
import { FormatListBulletedOutlined, GridViewOutlined } from "@mui/icons-material";
|
||||||
@ -21,7 +21,6 @@ import { checkIfArraysHaveSameElements } from "helpers/array.helper";
|
|||||||
import { Properties, TIssueViewOptions } from "types";
|
import { Properties, TIssueViewOptions } from "types";
|
||||||
// constants
|
// constants
|
||||||
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
|
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
|
||||||
import useProjects from "hooks/use-projects";
|
|
||||||
|
|
||||||
const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
|
const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
|
||||||
{
|
{
|
||||||
@ -37,9 +36,6 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
|
|||||||
export const ProfileIssuesViewOptions: React.FC = () => {
|
export const ProfileIssuesViewOptions: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, userId } = router.query;
|
const { workspaceSlug, userId } = router.query;
|
||||||
|
|
||||||
const { projects } = useProjects();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
displayFilters,
|
displayFilters,
|
||||||
setDisplayFilters,
|
setDisplayFilters,
|
||||||
@ -51,28 +47,12 @@ export const ProfileIssuesViewOptions: React.FC = () => {
|
|||||||
|
|
||||||
const { isEstimateActive } = useEstimateOption();
|
const { isEstimateActive } = useEstimateOption();
|
||||||
|
|
||||||
const options = projects?.map((project) => ({
|
|
||||||
value: project.id,
|
|
||||||
query: project.name + " " + project.identifier,
|
|
||||||
content: project.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!router.pathname.includes("assigned") &&
|
!router.pathname.includes("assigned") &&
|
||||||
!router.pathname.includes("created") &&
|
!router.pathname.includes("created") &&
|
||||||
!router.pathname.includes("subscribed")
|
!router.pathname.includes("subscribed")
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
// return (
|
|
||||||
// <CustomSearchSelect
|
|
||||||
// value={projects ?? null}
|
|
||||||
// onChange={(val: string[] | null) => console.log(val)}
|
|
||||||
// label="Filters"
|
|
||||||
// options={options}
|
|
||||||
// position="right"
|
|
||||||
// multiple
|
|
||||||
// />
|
|
||||||
// );
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -287,38 +267,39 @@ export const ProfileIssuesViewOptions: React.FC = () => {
|
|||||||
<div className="space-y-2 py-3">
|
<div className="space-y-2 py-3">
|
||||||
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
|
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
|
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
|
||||||
{Object.keys(displayProperties).map((key) => {
|
{displayProperties &&
|
||||||
if (key === "estimate" && !isEstimateActive) return null;
|
Object.keys(displayProperties).map((key) => {
|
||||||
|
if (key === "estimate" && !isEstimateActive) return null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
displayFilters?.layout === "spreadsheet" &&
|
displayFilters?.layout === "spreadsheet" &&
|
||||||
(key === "attachment_count" ||
|
(key === "attachment_count" ||
|
||||||
key === "link" ||
|
key === "link" ||
|
||||||
key === "sub_issue_count")
|
key === "sub_issue_count")
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
displayFilters?.layout !== "spreadsheet" &&
|
displayFilters?.layout !== "spreadsheet" &&
|
||||||
(key === "created_on" || key === "updated_on")
|
(key === "created_on" || key === "updated_on")
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||||
displayProperties[key as keyof Properties]
|
displayProperties[key as keyof Properties]
|
||||||
? "border-custom-primary bg-custom-primary text-white"
|
? "border-custom-primary bg-custom-primary text-white"
|
||||||
: "border-custom-border-200"
|
: "border-custom-border-200"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setProperties(key as keyof Properties)}
|
onClick={() => setProperties(key as keyof Properties)}
|
||||||
>
|
>
|
||||||
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -393,7 +393,7 @@ export const CreateProjectModal: React.FC<Props> = ({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={options}
|
options={options}
|
||||||
buttonClassName="!px-2 shadow-md"
|
buttonClassName="border-[0.5px] !px-2 shadow-md"
|
||||||
label={
|
label={
|
||||||
<div className="flex items-center justify-center gap-2 py-[1px]">
|
<div className="flex items-center justify-center gap-2 py-[1px]">
|
||||||
{value ? (
|
{value ? (
|
||||||
|
@ -16,9 +16,10 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
|||||||
type Props = {
|
type Props = {
|
||||||
value: any;
|
value: any;
|
||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
|
isDisabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
|
export const MemberSelect: React.FC<Props> = ({ value, onChange, isDisabled = false }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
@ -79,6 +80,7 @@ export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
|
|||||||
position="right"
|
position="right"
|
||||||
width="w-full"
|
width="w-full"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
110
web/components/tiptap/index.tsx
Normal file
110
web/components/tiptap/index.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useImperativeHandle, useRef, forwardRef, useEffect } from "react";
|
||||||
|
import { useEditor, EditorContent, Editor } from "@tiptap/react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
// components
|
||||||
|
import { EditorBubbleMenu } from "./bubble-menu";
|
||||||
|
import { TiptapExtensions } from "./extensions";
|
||||||
|
import { TiptapEditorProps } from "./props";
|
||||||
|
import { ImageResizer } from "./extensions/image-resize";
|
||||||
|
import { TableMenu } from "./table-menu";
|
||||||
|
|
||||||
|
export interface ITipTapRichTextEditor {
|
||||||
|
value: string;
|
||||||
|
noBorder?: boolean;
|
||||||
|
borderOnFocus?: boolean;
|
||||||
|
customClassName?: string;
|
||||||
|
editorContentCustomClassNames?: string;
|
||||||
|
onChange?: (json: any, html: string) => void;
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||||
|
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||||
|
workspaceSlug: string;
|
||||||
|
editable?: boolean;
|
||||||
|
forwardedRef?: any;
|
||||||
|
debouncedUpdatesEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tiptap = (props: ITipTapRichTextEditor) => {
|
||||||
|
const {
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
forwardedRef,
|
||||||
|
editable,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
editorContentCustomClassNames,
|
||||||
|
value,
|
||||||
|
noBorder,
|
||||||
|
workspaceSlug,
|
||||||
|
borderOnFocus,
|
||||||
|
customClassName,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
editable: editable ?? true,
|
||||||
|
editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
|
||||||
|
extensions: TiptapExtensions(workspaceSlug, setIsSubmitting),
|
||||||
|
content: value,
|
||||||
|
onUpdate: async ({ editor }) => {
|
||||||
|
// for instant feedback loop
|
||||||
|
setIsSubmitting?.("submitting");
|
||||||
|
setShouldShowAlert?.(true);
|
||||||
|
if (debouncedUpdatesEnabled) {
|
||||||
|
debouncedUpdates({ onChange, editor });
|
||||||
|
} else {
|
||||||
|
onChange?.(editor.getJSON(), editor.getHTML());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
|
||||||
|
|
||||||
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
|
clearEditor: () => {
|
||||||
|
editorRef.current?.commands.clearContent();
|
||||||
|
},
|
||||||
|
setEditorValue: (content: string) => {
|
||||||
|
editorRef.current?.commands.setContent(content);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(editor.getJSON(), editor.getHTML());
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
|
||||||
|
${noBorder ? "" : "border border-custom-border-200"} ${
|
||||||
|
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
|
||||||
|
} ${customClassName}`;
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
editorRef.current = editor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="tiptap-container"
|
||||||
|
onClick={() => {
|
||||||
|
editor?.chain().focus().run();
|
||||||
|
}}
|
||||||
|
className={`tiptap-editor-container relative cursor-text ${editorClassNames}`}
|
||||||
|
>
|
||||||
|
{editor && <EditorBubbleMenu editor={editor} />}
|
||||||
|
<div className={`${editorContentCustomClassNames}`}>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
<TableMenu editor={editor} />
|
||||||
|
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TipTapEditor = forwardRef<ITipTapRichTextEditor, ITipTapRichTextEditor>((props, ref) => (
|
||||||
|
<Tiptap {...props} forwardedRef={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
TipTapEditor.displayName = "TipTapEditor";
|
||||||
|
|
||||||
|
export { TipTapEditor };
|
@ -19,6 +19,7 @@ export type CustomMenuProps = DropdownProps & {
|
|||||||
|
|
||||||
const CustomMenu = ({
|
const CustomMenu = ({
|
||||||
buttonClassName = "",
|
buttonClassName = "",
|
||||||
|
customButtonClassName = "",
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
customButton,
|
customButton,
|
||||||
@ -40,7 +41,13 @@ const CustomMenu = ({
|
|||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
{customButton ? (
|
{customButton ? (
|
||||||
<Menu.Button as="button" type="button" onClick={menuButtonOnClick}>
|
<Menu.Button
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
onClick={menuButtonOnClick}
|
||||||
|
className={customButtonClassName}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
{customButton}
|
{customButton}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
) : (
|
) : (
|
||||||
|
1
web/components/ui/dropdowns/types.d.ts
vendored
1
web/components/ui/dropdowns/types.d.ts
vendored
@ -1,5 +1,6 @@
|
|||||||
export type DropdownProps = {
|
export type DropdownProps = {
|
||||||
buttonClassName?: string;
|
buttonClassName?: string;
|
||||||
|
customButtonClassName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
customButton?: JSX.Element;
|
customButton?: JSX.Element;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -16,6 +16,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
secondaryButton?: React.ReactNode;
|
secondaryButton?: React.ReactNode;
|
||||||
isFullScreen?: boolean;
|
isFullScreen?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmptyState: React.FC<Props> = ({
|
export const EmptyState: React.FC<Props> = ({
|
||||||
@ -25,6 +26,7 @@ export const EmptyState: React.FC<Props> = ({
|
|||||||
primaryButton,
|
primaryButton,
|
||||||
secondaryButton,
|
secondaryButton,
|
||||||
isFullScreen = true,
|
isFullScreen = true,
|
||||||
|
disabled = false,
|
||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
className={`h-full w-full mx-auto grid place-items-center p-8 ${
|
className={`h-full w-full mx-auto grid place-items-center p-8 ${
|
||||||
@ -37,7 +39,11 @@ export const EmptyState: React.FC<Props> = ({
|
|||||||
{description && <p className="text-custom-text-300 mb-7 sm:mb-8">{description}</p>}
|
{description && <p className="text-custom-text-300 mb-7 sm:mb-8">{description}</p>}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{primaryButton && (
|
{primaryButton && (
|
||||||
<PrimaryButton className="flex items-center gap-1.5" onClick={primaryButton.onClick}>
|
<PrimaryButton
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
onClick={primaryButton.onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
{primaryButton.icon}
|
{primaryButton.icon}
|
||||||
{primaryButton.text}
|
{primaryButton.text}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
@ -21,7 +21,7 @@ export const ToggleSwitch: React.FC<Props> = (props) => {
|
|||||||
size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10"
|
size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10"
|
||||||
} flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
|
} flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||||
value ? "bg-custom-primary-100" : "bg-gray-700"
|
value ? "bg-custom-primary-100" : "bg-gray-700"
|
||||||
} ${className || ""}`}
|
} ${className || ""} ${disabled ? "cursor-not-allowed" : ""}`}
|
||||||
>
|
>
|
||||||
<span className="sr-only">{label}</span>
|
<span className="sr-only">{label}</span>
|
||||||
<span
|
<span
|
||||||
@ -36,7 +36,7 @@ export const ToggleSwitch: React.FC<Props> = (props) => {
|
|||||||
? "translate-x-4"
|
? "translate-x-4"
|
||||||
: "translate-x-5") + " bg-white"
|
: "translate-x-5") + " bg-white"
|
||||||
: "translate-x-0.5 bg-custom-background-90"
|
: "translate-x-0.5 bg-custom-background-90"
|
||||||
}`}
|
} ${disabled ? "cursor-not-allowed" : ""}`}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
@ -8,7 +8,6 @@ import { mutate } from "swr";
|
|||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
// services
|
// services
|
||||||
import viewsService from "services/views.service";
|
import viewsService from "services/views.service";
|
||||||
import workspaceService from "services/workspace.service";
|
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// ui
|
// ui
|
||||||
@ -18,17 +17,16 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
|||||||
// types
|
// types
|
||||||
import type { ICurrentUserResponse, IView } from "types";
|
import type { ICurrentUserResponse, IView } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { VIEWS_LIST, WORKSPACE_VIEWS_LIST } from "constants/fetch-keys";
|
import { VIEWS_LIST } from "constants/fetch-keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
viewType: "project" | "workspace";
|
|
||||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
data: IView | null;
|
data: IView | null;
|
||||||
user: ICurrentUserResponse | undefined;
|
user: ICurrentUserResponse | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteViewModal: React.FC<Props> = ({ isOpen, data, setIsOpen, viewType, user }) => {
|
export const DeleteViewModal: React.FC<Props> = ({ isOpen, data, setIsOpen, user }) => {
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -43,64 +41,33 @@ export const DeleteViewModal: React.FC<Props> = ({ isOpen, data, setIsOpen, view
|
|||||||
|
|
||||||
const handleDeletion = async () => {
|
const handleDeletion = async () => {
|
||||||
setIsDeleteLoading(true);
|
setIsDeleteLoading(true);
|
||||||
|
if (!workspaceSlug || !data || !projectId) return;
|
||||||
|
|
||||||
if (viewType === "project") {
|
await viewsService
|
||||||
if (!workspaceSlug || !data || !projectId) return;
|
.deleteView(workspaceSlug as string, projectId as string, data.id, user)
|
||||||
|
.then(() => {
|
||||||
|
mutate<IView[]>(VIEWS_LIST(projectId as string), (views) =>
|
||||||
|
views?.filter((view) => view.id !== data.id)
|
||||||
|
);
|
||||||
|
|
||||||
await viewsService
|
handleClose();
|
||||||
.deleteView(workspaceSlug as string, projectId as string, data.id, user)
|
|
||||||
.then(() => {
|
|
||||||
mutate<IView[]>(VIEWS_LIST(projectId as string), (views) =>
|
|
||||||
views?.filter((view) => view.id !== data.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
handleClose();
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
setToastAlert({
|
title: "Success!",
|
||||||
type: "success",
|
message: "View deleted successfully.",
|
||||||
title: "Success!",
|
|
||||||
message: "View deleted successfully.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "View could not be deleted. Please try again.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
});
|
});
|
||||||
} else {
|
})
|
||||||
if (!workspaceSlug || !data) return;
|
.catch(() => {
|
||||||
|
setToastAlert({
|
||||||
await workspaceService
|
type: "error",
|
||||||
.deleteView(workspaceSlug as string, data.id)
|
title: "Error!",
|
||||||
.then(() => {
|
message: "View could not be deleted. Please try again.",
|
||||||
mutate<IView[]>(WORKSPACE_VIEWS_LIST(workspaceSlug as string), (views) =>
|
|
||||||
views?.filter((view) => view.id !== data.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
handleClose();
|
|
||||||
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "View deleted successfully.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "View could not be deleted. Please try again.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
});
|
});
|
||||||
}
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -10,8 +10,6 @@ import { useForm } from "react-hook-form";
|
|||||||
import stateService from "services/state.service";
|
import stateService from "services/state.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useProjectMembers from "hooks/use-project-members";
|
import useProjectMembers from "hooks/use-project-members";
|
||||||
import useProjects from "hooks/use-projects";
|
|
||||||
import useWorkspaceMembers from "hooks/use-workspace-members";
|
|
||||||
// components
|
// components
|
||||||
import { FiltersList } from "components/core";
|
import { FiltersList } from "components/core";
|
||||||
import { SelectFilters } from "components/views";
|
import { SelectFilters } from "components/views";
|
||||||
@ -24,14 +22,13 @@ import { getStatesList } from "helpers/state.helper";
|
|||||||
import { IQuery, IView } from "types";
|
import { IQuery, IView } from "types";
|
||||||
import issuesService from "services/issues.service";
|
import issuesService from "services/issues.service";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { PROJECT_ISSUE_LABELS, STATES_LIST, WORKSPACE_LABELS } from "constants/fetch-keys";
|
import { PROJECT_ISSUE_LABELS, STATES_LIST } from "constants/fetch-keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
handleFormSubmit: (values: IView) => Promise<void>;
|
handleFormSubmit: (values: IView) => Promise<void>;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
status: boolean;
|
status: boolean;
|
||||||
data?: IView | null;
|
data?: IView | null;
|
||||||
viewType?: "workspace" | "project";
|
|
||||||
preLoadedData?: Partial<IView> | null;
|
preLoadedData?: Partial<IView> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -45,7 +42,6 @@ export const ViewForm: React.FC<Props> = ({
|
|||||||
handleClose,
|
handleClose,
|
||||||
status,
|
status,
|
||||||
data,
|
data,
|
||||||
viewType,
|
|
||||||
preLoadedData,
|
preLoadedData,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -81,26 +77,8 @@ export const ViewForm: React.FC<Props> = ({
|
|||||||
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
|
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: workspaceLabels } = useSWR(
|
|
||||||
workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
|
|
||||||
workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelOptions = viewType === "workspace" ? workspaceLabels : labels;
|
|
||||||
|
|
||||||
const { members } = useProjectMembers(workspaceSlug?.toString(), projectId?.toString());
|
const { members } = useProjectMembers(workspaceSlug?.toString(), projectId?.toString());
|
||||||
|
|
||||||
const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
|
|
||||||
|
|
||||||
const memberOptions =
|
|
||||||
viewType === "workspace"
|
|
||||||
? workspaceMembers?.map((m) => m.member)
|
|
||||||
: members?.map((m) => m.member);
|
|
||||||
|
|
||||||
const { projects: allProjects } = useProjects();
|
|
||||||
const joinedProjects = allProjects?.filter((p) => p.is_member);
|
|
||||||
|
|
||||||
const handleCreateUpdateView = async (formData: IView) => {
|
const handleCreateUpdateView = async (formData: IView) => {
|
||||||
await handleFormSubmit(formData);
|
await handleFormSubmit(formData);
|
||||||
|
|
||||||
@ -113,14 +91,12 @@ export const ViewForm: React.FC<Props> = ({
|
|||||||
setValue("query", {
|
setValue("query", {
|
||||||
assignees: null,
|
assignees: null,
|
||||||
created_by: null,
|
created_by: null,
|
||||||
subscriber: null,
|
|
||||||
labels: null,
|
labels: null,
|
||||||
priority: null,
|
priority: null,
|
||||||
state: null,
|
state: null,
|
||||||
state_group: null,
|
|
||||||
start_date: null,
|
start_date: null,
|
||||||
target_date: null,
|
target_date: null,
|
||||||
project: null,
|
type: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -209,10 +185,9 @@ export const ViewForm: React.FC<Props> = ({
|
|||||||
<div>
|
<div>
|
||||||
<FiltersList
|
<FiltersList
|
||||||
filters={filters}
|
filters={filters}
|
||||||
labels={labelOptions}
|
labels={labels}
|
||||||
members={memberOptions}
|
members={members?.map((m) => m.member)}
|
||||||
states={states}
|
states={states}
|
||||||
project={joinedProjects}
|
|
||||||
clearAllFilters={clearAllFilters}
|
clearAllFilters={clearAllFilters}
|
||||||
setFilters={(query: any) => {
|
setFilters={(query: any) => {
|
||||||
setValue("query", {
|
setValue("query", {
|
||||||
|
@ -8,7 +8,6 @@ import { mutate } from "swr";
|
|||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
// services
|
// services
|
||||||
import viewsService from "services/views.service";
|
import viewsService from "services/views.service";
|
||||||
import workspaceService from "services/workspace.service";
|
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
@ -16,11 +15,10 @@ import { ViewForm } from "components/views";
|
|||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IView } from "types";
|
import { ICurrentUserResponse, IView } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { VIEWS_LIST, WORKSPACE_VIEWS_LIST } from "constants/fetch-keys";
|
import { VIEWS_LIST } from "constants/fetch-keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
viewType: "project" | "workspace";
|
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
data?: IView | null;
|
data?: IView | null;
|
||||||
preLoadedData?: Partial<IView> | null;
|
preLoadedData?: Partial<IView> | null;
|
||||||
@ -29,7 +27,6 @@ type Props = {
|
|||||||
|
|
||||||
export const CreateUpdateViewModal: React.FC<Props> = ({
|
export const CreateUpdateViewModal: React.FC<Props> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
viewType,
|
|
||||||
handleClose,
|
handleClose,
|
||||||
data,
|
data,
|
||||||
preLoadedData,
|
preLoadedData,
|
||||||
@ -49,48 +46,25 @@ export const CreateUpdateViewModal: React.FC<Props> = ({
|
|||||||
...payload,
|
...payload,
|
||||||
query_data: payload.query,
|
query_data: payload.query,
|
||||||
};
|
};
|
||||||
|
await viewsService
|
||||||
|
.createView(workspaceSlug as string, projectId as string, payload, user)
|
||||||
|
.then(() => {
|
||||||
|
mutate(VIEWS_LIST(projectId as string));
|
||||||
|
handleClose();
|
||||||
|
|
||||||
if (viewType === "project") {
|
setToastAlert({
|
||||||
await viewsService
|
type: "success",
|
||||||
.createView(workspaceSlug as string, projectId as string, payload, user)
|
title: "Success!",
|
||||||
.then(() => {
|
message: "View created successfully.",
|
||||||
mutate(VIEWS_LIST(projectId as string));
|
|
||||||
handleClose();
|
|
||||||
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "View created successfully.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "View could not be created. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} else {
|
})
|
||||||
await workspaceService
|
.catch(() => {
|
||||||
.createView(workspaceSlug as string, payload)
|
setToastAlert({
|
||||||
.then(() => {
|
type: "error",
|
||||||
mutate(WORKSPACE_VIEWS_LIST(workspaceSlug as string));
|
title: "Error!",
|
||||||
handleClose();
|
message: "View could not be created. Please try again.",
|
||||||
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "View created successfully.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "View could not be created. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateView = async (payload: IView) => {
|
const updateView = async (payload: IView) => {
|
||||||
@ -98,79 +72,41 @@ export const CreateUpdateViewModal: React.FC<Props> = ({
|
|||||||
...payload,
|
...payload,
|
||||||
query_data: payload.query,
|
query_data: payload.query,
|
||||||
};
|
};
|
||||||
if (viewType === "project") {
|
await viewsService
|
||||||
await viewsService
|
.updateView(workspaceSlug as string, projectId as string, data?.id ?? "", payloadData, user)
|
||||||
.updateView(workspaceSlug as string, projectId as string, data?.id ?? "", payloadData, user)
|
.then((res) => {
|
||||||
.then((res) => {
|
mutate<IView[]>(
|
||||||
mutate<IView[]>(
|
VIEWS_LIST(projectId as string),
|
||||||
VIEWS_LIST(projectId as string),
|
(prevData) =>
|
||||||
(prevData) =>
|
prevData?.map((p) => {
|
||||||
prevData?.map((p) => {
|
if (p.id === res.id) return { ...p, ...payloadData };
|
||||||
if (p.id === res.id) return { ...p, ...payloadData };
|
|
||||||
|
|
||||||
return p;
|
return p;
|
||||||
}),
|
}),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "success",
|
type: "success",
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
message: "View updated successfully.",
|
message: "View updated successfully.",
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "View could not be updated. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} else {
|
})
|
||||||
await workspaceService
|
.catch(() => {
|
||||||
.updateView(workspaceSlug as string, data?.id ?? "", payloadData)
|
setToastAlert({
|
||||||
.then((res) => {
|
type: "error",
|
||||||
mutate<IView[]>(
|
title: "Error!",
|
||||||
WORKSPACE_VIEWS_LIST(workspaceSlug as string),
|
message: "View could not be updated. Please try again.",
|
||||||
(prevData) =>
|
|
||||||
prevData?.map((p) => {
|
|
||||||
if (p.id === res.id) return { ...p, ...payloadData };
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
onClose();
|
|
||||||
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "View updated successfully.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "View could not be updated. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = async (formData: IView) => {
|
const handleFormSubmit = async (formData: IView) => {
|
||||||
if (viewType === "project") {
|
if (!workspaceSlug || !projectId) return;
|
||||||
if (!workspaceSlug || !projectId) return;
|
|
||||||
|
|
||||||
if (!data) await createView(formData);
|
if (!data) await createView(formData);
|
||||||
else await updateView(formData);
|
else await updateView(formData);
|
||||||
} else {
|
|
||||||
if (!workspaceSlug) return;
|
|
||||||
|
|
||||||
if (!data) await createView(formData);
|
|
||||||
else await updateView(formData);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -205,7 +141,6 @@ export const CreateUpdateViewModal: React.FC<Props> = ({
|
|||||||
handleClose={handleClose}
|
handleClose={handleClose}
|
||||||
status={data ? true : false}
|
status={data ? true : false}
|
||||||
data={data}
|
data={data}
|
||||||
viewType={viewType}
|
|
||||||
preLoadedData={preLoadedData}
|
preLoadedData={preLoadedData}
|
||||||
/>
|
/>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
@ -4,9 +4,6 @@ import { useRouter } from "next/router";
|
|||||||
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
// hook
|
|
||||||
import useProjects from "hooks/use-projects";
|
|
||||||
import useWorkspaceMembers from "hooks/use-workspace-members";
|
|
||||||
// services
|
// services
|
||||||
import stateService from "services/state.service";
|
import stateService from "services/state.service";
|
||||||
import projectService from "services/project.service";
|
import projectService from "services/project.service";
|
||||||
@ -21,16 +18,11 @@ import { PriorityIcon, StateGroupIcon } from "components/icons";
|
|||||||
import { getStatesList } from "helpers/state.helper";
|
import { getStatesList } from "helpers/state.helper";
|
||||||
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
|
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
|
||||||
// types
|
// types
|
||||||
import { IIssueFilterOptions, TStateGroups } from "types";
|
import { IIssueFilterOptions } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import {
|
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fetch-keys";
|
||||||
PROJECT_ISSUE_LABELS,
|
|
||||||
PROJECT_MEMBERS,
|
|
||||||
STATES_LIST,
|
|
||||||
WORKSPACE_LABELS,
|
|
||||||
} from "constants/fetch-keys";
|
|
||||||
// constants
|
// constants
|
||||||
import { GROUP_CHOICES, PRIORITIES } from "constants/project";
|
import { PRIORITIES } from "constants/project";
|
||||||
import { DATE_FILTER_OPTIONS } from "constants/filters";
|
import { DATE_FILTER_OPTIONS } from "constants/filters";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -56,7 +48,7 @@ export const SelectFilters: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, workspaceViewId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
const { data: states } = useSWR(
|
const { data: states } = useSWR(
|
||||||
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
|
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
|
||||||
@ -66,20 +58,6 @@ export const SelectFilters: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
const statesList = getStatesList(states);
|
const statesList = getStatesList(states);
|
||||||
|
|
||||||
const workspaceViewPathName = [
|
|
||||||
"workspace-views",
|
|
||||||
"workspace-views/all-issues",
|
|
||||||
"workspace-views/assigned",
|
|
||||||
"workspace-views/created",
|
|
||||||
"workspace-views/subscribed",
|
|
||||||
];
|
|
||||||
|
|
||||||
const isWorkspaceViewPath = workspaceViewPathName.some((pathname) =>
|
|
||||||
router.pathname.includes(pathname)
|
|
||||||
);
|
|
||||||
|
|
||||||
const isWorkspaceView = isWorkspaceViewPath || workspaceViewId;
|
|
||||||
|
|
||||||
const { data: members } = useSWR(
|
const { data: members } = useSWR(
|
||||||
projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||||
workspaceSlug && projectId
|
workspaceSlug && projectId
|
||||||
@ -87,8 +65,6 @@ export const SelectFilters: React.FC<Props> = ({
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
|
|
||||||
|
|
||||||
const { data: issueLabels } = useSWR(
|
const { data: issueLabels } = useSWR(
|
||||||
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
|
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
|
||||||
workspaceSlug && projectId
|
workspaceSlug && projectId
|
||||||
@ -96,14 +72,6 @@ export const SelectFilters: React.FC<Props> = ({
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: workspaceLabels } = useSWR(
|
|
||||||
workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
|
|
||||||
workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null
|
|
||||||
);
|
|
||||||
|
|
||||||
const { projects: allProjects } = useProjects();
|
|
||||||
const joinedProjects = allProjects?.filter((p) => p.is_member);
|
|
||||||
|
|
||||||
const projectFilterOption = [
|
const projectFilterOption = [
|
||||||
{
|
{
|
||||||
id: "priority",
|
id: "priority",
|
||||||
@ -283,226 +251,6 @@ export const SelectFilters: React.FC<Props> = ({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const workspaceFilterOption = [
|
|
||||||
{
|
|
||||||
id: "project",
|
|
||||||
label: "Project",
|
|
||||||
value: joinedProjects,
|
|
||||||
hasChildren: true,
|
|
||||||
children: joinedProjects?.map((project) => ({
|
|
||||||
id: project.id,
|
|
||||||
label: <div className="flex items-center gap-2">{project.name}</div>,
|
|
||||||
value: {
|
|
||||||
key: "project",
|
|
||||||
value: project.id,
|
|
||||||
},
|
|
||||||
selected: filters?.project?.includes(project.id),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "state_group",
|
|
||||||
label: "State groups",
|
|
||||||
value: GROUP_CHOICES,
|
|
||||||
hasChildren: true,
|
|
||||||
children: [
|
|
||||||
...Object.keys(GROUP_CHOICES).map((key) => ({
|
|
||||||
id: key,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StateGroupIcon stateGroup={key as TStateGroups} />
|
|
||||||
{GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "state_group",
|
|
||||||
value: key,
|
|
||||||
},
|
|
||||||
selected: filters?.state?.includes(key),
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "labels",
|
|
||||||
label: "Labels",
|
|
||||||
value: workspaceLabels,
|
|
||||||
hasChildren: true,
|
|
||||||
children: workspaceLabels?.map((label) => ({
|
|
||||||
id: label.id,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="h-2 w-2 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{label.name}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "labels",
|
|
||||||
value: label.id,
|
|
||||||
},
|
|
||||||
selected: filters?.labels?.includes(label.id),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "priority",
|
|
||||||
label: "Priority",
|
|
||||||
value: PRIORITIES,
|
|
||||||
hasChildren: true,
|
|
||||||
children: PRIORITIES.map((priority) => ({
|
|
||||||
id: priority === null ? "null" : priority,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2 capitalize">
|
|
||||||
<PriorityIcon priority={priority} />
|
|
||||||
{priority ?? "None"}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "priority",
|
|
||||||
value: priority === null ? "null" : priority,
|
|
||||||
},
|
|
||||||
selected: filters?.priority?.includes(priority === null ? "null" : priority),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "created_by",
|
|
||||||
label: "Created by",
|
|
||||||
value: workspaceMembers,
|
|
||||||
hasChildren: true,
|
|
||||||
children: workspaceMembers?.map((member) => ({
|
|
||||||
id: member.member.id,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar user={member.member} />
|
|
||||||
{member.member.display_name}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "created_by",
|
|
||||||
value: member.member.id,
|
|
||||||
},
|
|
||||||
selected: filters?.created_by?.includes(member.member.id),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "assignees",
|
|
||||||
label: "Assignees",
|
|
||||||
value: workspaceMembers,
|
|
||||||
hasChildren: true,
|
|
||||||
children: workspaceMembers?.map((member) => ({
|
|
||||||
id: member.member.id,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar user={member.member} />
|
|
||||||
{member.member.display_name}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "assignees",
|
|
||||||
value: member.member.id,
|
|
||||||
},
|
|
||||||
selected: filters?.assignees?.includes(member.member.id),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "subscriber",
|
|
||||||
label: "Subscriber",
|
|
||||||
value: workspaceMembers,
|
|
||||||
hasChildren: true,
|
|
||||||
children: workspaceMembers?.map((member) => ({
|
|
||||||
id: member.member.id,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar user={member.member} />
|
|
||||||
{member.member.display_name}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "subscriber",
|
|
||||||
value: member.member.id,
|
|
||||||
},
|
|
||||||
selected: filters?.subscriber?.includes(member.member.id),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "start_date",
|
|
||||||
label: "Start date",
|
|
||||||
value: DATE_FILTER_OPTIONS,
|
|
||||||
hasChildren: true,
|
|
||||||
children: [
|
|
||||||
...DATE_FILTER_OPTIONS.map((option) => ({
|
|
||||||
id: option.name,
|
|
||||||
label: option.name,
|
|
||||||
value: {
|
|
||||||
key: "start_date",
|
|
||||||
value: option.value,
|
|
||||||
},
|
|
||||||
selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], option.value),
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
id: "custom",
|
|
||||||
label: "Custom",
|
|
||||||
value: "custom",
|
|
||||||
element: (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsDateFilterModalOpen(true);
|
|
||||||
setDateFilterType({
|
|
||||||
title: "Start date",
|
|
||||||
type: "start_date",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
|
||||||
>
|
|
||||||
Custom
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "target_date",
|
|
||||||
label: "Due date",
|
|
||||||
value: DATE_FILTER_OPTIONS,
|
|
||||||
hasChildren: true,
|
|
||||||
children: [
|
|
||||||
...DATE_FILTER_OPTIONS.map((option) => ({
|
|
||||||
id: option.name,
|
|
||||||
label: option.name,
|
|
||||||
value: {
|
|
||||||
key: "target_date",
|
|
||||||
value: option.value,
|
|
||||||
},
|
|
||||||
selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], option.value),
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
id: "custom",
|
|
||||||
label: "Custom",
|
|
||||||
value: "custom",
|
|
||||||
element: (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsDateFilterModalOpen(true);
|
|
||||||
setDateFilterType({
|
|
||||||
title: "Due date",
|
|
||||||
type: "target_date",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
|
||||||
>
|
|
||||||
Custom
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const filterOption = isWorkspaceView ? workspaceFilterOption : projectFilterOption;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDateFilterModalOpen && (
|
{isDateFilterModalOpen && (
|
||||||
@ -520,7 +268,7 @@ export const SelectFilters: React.FC<Props> = ({
|
|||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
direction={direction}
|
direction={direction}
|
||||||
height={height}
|
height={height}
|
||||||
options={filterOption}
|
options={projectFilterOption}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -21,17 +21,11 @@ import { truncateText } from "helpers/string.helper";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
view: IView;
|
view: IView;
|
||||||
viewType: "project" | "workspace";
|
|
||||||
handleEditView: () => void;
|
handleEditView: () => void;
|
||||||
handleDeleteView: () => void;
|
handleDeleteView: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SingleViewItem: React.FC<Props> = ({
|
export const SingleViewItem: React.FC<Props> = ({ view, handleEditView, handleDeleteView }) => {
|
||||||
view,
|
|
||||||
viewType,
|
|
||||||
handleEditView,
|
|
||||||
handleDeleteView,
|
|
||||||
}) => {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
@ -87,10 +81,7 @@ export const SingleViewItem: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewRedirectionUrl =
|
const viewRedirectionUrl = `/${workspaceSlug}/projects/${projectId}/views/${view.id}`;
|
||||||
viewType === "project"
|
|
||||||
? `/${workspaceSlug}/projects/${projectId}/views/${view.id}`
|
|
||||||
: `/${workspaceSlug}/workspace-views/${view.id}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group hover:bg-custom-background-90 border-b border-custom-border-200">
|
<div className="group hover:bg-custom-background-90 border-b border-custom-border-200">
|
||||||
@ -125,31 +116,29 @@ export const SingleViewItem: React.FC<Props> = ({
|
|||||||
filters
|
filters
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{viewType === "project" ? (
|
{view.is_favorite ? (
|
||||||
view.is_favorite ? (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
handleRemoveFromFavorites();
|
||||||
handleRemoveFromFavorites();
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||||
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
</button>
|
||||||
</button>
|
) : (
|
||||||
) : (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
handleAddToFavorites();
|
||||||
handleAddToFavorites();
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<StarIcon className="h-4 w-4 " color="rgb(var(--color-text-200))" />
|
||||||
<StarIcon className="h-4 w-4 " color="rgb(var(--color-text-200))" />
|
</button>
|
||||||
</button>
|
)}
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
<CustomMenu width="auto" ellipsis>
|
<CustomMenu width="auto" ellipsis>
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={(e: any) => {
|
onClick={(e: any) => {
|
||||||
|
@ -1,24 +1,23 @@
|
|||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
// headless ui
|
|
||||||
import { Transition } from "@headlessui/react";
|
import { Transition } from "@headlessui/react";
|
||||||
|
|
||||||
|
// mobx store
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
// hooks
|
// hooks
|
||||||
import useTheme from "hooks/use-theme";
|
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// icons
|
// icons
|
||||||
import { Bolt, HelpOutlineOutlined, WestOutlined } from "@mui/icons-material";
|
import { Bolt, HelpOutlineOutlined, WestOutlined } from "@mui/icons-material";
|
||||||
import { ChatBubbleOvalLeftEllipsisIcon } from "@heroicons/react/24/outline";
|
import { DiscordIcon } from "components/icons";
|
||||||
import { DocumentIcon, DiscordIcon, GithubIcon } from "components/icons";
|
import { FileText, Github, MessagesSquare } from "lucide-react";
|
||||||
// mobx store
|
// assets
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import packageJson from "package.json";
|
||||||
|
|
||||||
const helpOptions = [
|
const helpOptions = [
|
||||||
{
|
{
|
||||||
name: "Documentation",
|
name: "Documentation",
|
||||||
href: "https://docs.plane.so/",
|
href: "https://docs.plane.so/",
|
||||||
Icon: DocumentIcon,
|
Icon: FileText,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Join our Discord",
|
name: "Join our Discord",
|
||||||
@ -28,13 +27,13 @@ const helpOptions = [
|
|||||||
{
|
{
|
||||||
name: "Report a bug",
|
name: "Report a bug",
|
||||||
href: "https://github.com/makeplane/plane/issues/new/choose",
|
href: "https://github.com/makeplane/plane/issues/new/choose",
|
||||||
Icon: GithubIcon,
|
Icon: Github,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Chat with us",
|
name: "Chat with us",
|
||||||
href: null,
|
href: null,
|
||||||
onClick: () => (window as any).$crisp.push(["do", "chat:show"]),
|
onClick: () => (window as any).$crisp.push(["do", "chat:show"]),
|
||||||
Icon: ChatBubbleOvalLeftEllipsisIcon,
|
Icon: MessagesSquare,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -123,37 +122,44 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = ({ setS
|
|||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-2 ${
|
className={`absolute bottom-2 min-w-[10rem] ${
|
||||||
store?.theme?.sidebarCollapsed ? "left-full" : "left-[-75px]"
|
store?.theme?.sidebarCollapsed ? "left-full" : "-left-[75px]"
|
||||||
} space-y-2 rounded-sm bg-custom-background-80 p-1 shadow-md`}
|
} rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs whitespace-nowrap divide-y divide-custom-border-200`}
|
||||||
ref={helpOptionsRef}
|
ref={helpOptionsRef}
|
||||||
>
|
>
|
||||||
{helpOptions.map(({ name, Icon, href, onClick }) => {
|
<div className="space-y-1 pb-2">
|
||||||
if (href)
|
{helpOptions.map(({ name, Icon, href, onClick }) => {
|
||||||
return (
|
if (href)
|
||||||
<Link href={href} key={name}>
|
return (
|
||||||
<a
|
<Link href={href} key={name}>
|
||||||
target="_blank"
|
<a
|
||||||
className="flex items-center gap-x-2 whitespace-nowrap rounded-md px-2 py-1 text-xs hover:bg-custom-background-90"
|
target="_blank"
|
||||||
|
className="flex items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80"
|
||||||
|
>
|
||||||
|
<div className="grid place-items-center flex-shrink-0">
|
||||||
|
<Icon className="text-custom-text-200 h-3.5 w-3.5" size={14} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs">{name}</span>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
onClick={onClick ?? undefined}
|
||||||
|
className="flex w-full items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80"
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 text-custom-text-200" />
|
<div className="grid place-items-center flex-shrink-0">
|
||||||
<span className="text-sm">{name}</span>
|
<Icon className="text-custom-text-200 h-3.5 w-3.5" size={14} />
|
||||||
</a>
|
</div>
|
||||||
</Link>
|
<span className="text-xs">{name}</span>
|
||||||
);
|
</button>
|
||||||
else
|
);
|
||||||
return (
|
})}
|
||||||
<button
|
</div>
|
||||||
key={name}
|
<div className="px-2 pt-2 pb-1 text-[10px]">Version: v{packageJson.version}</div>
|
||||||
type="button"
|
|
||||||
onClick={onClick ? onClick : undefined}
|
|
||||||
className="flex w-full items-center gap-x-2 whitespace-nowrap rounded-md px-2 py-1 text-xs hover:bg-custom-background-90"
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 text-custom-sidebar-text-200" />
|
|
||||||
<span className="text-sm">{name}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
@ -34,7 +34,7 @@ const workspaceLinks = (workspaceSlug: string) => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: TaskAltOutlined,
|
Icon: TaskAltOutlined,
|
||||||
name: "Issues",
|
name: "All Issues",
|
||||||
href: `/${workspaceSlug}/workspace-views/all-issues`,
|
href: `/${workspaceSlug}/workspace-views/all-issues`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user