Merge branch 'develop' into comment-editor

This commit is contained in:
Palanikannan1437 2023-10-04 21:50:09 +05:30
commit 0cf5ad684b
139 changed files with 4958 additions and 3324 deletions

77
.github/workflows/create-sync-pr.yml vendored Normal file
View 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

View File

@ -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 }}

View File

@ -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

View File

@ -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"

View File

@ -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
] ]

View File

@ -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

View 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,
)

View File

@ -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,
)

View File

@ -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(

View File

@ -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)

View File

@ -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,
)

View File

@ -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,

View File

@ -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)

View File

@ -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),
),
]

View File

@ -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),
]

View File

@ -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),
]

View File

@ -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),
]

View File

@ -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),
),
]

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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": [

View File

@ -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}>

View File

@ -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": {

View File

@ -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": {

View File

@ -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
View File

@ -0,0 +1 @@
# UI Package

View File

@ -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",

View File

@ -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

View File

@ -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,
}); });

View File

@ -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>

View File

@ -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

View File

@ -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 />}</>
)}
</>
);
}); });

View File

@ -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 <></>;
}; };

View File

@ -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
View 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;

View File

@ -5,4 +5,4 @@ import { LoginView } from "components/views";
const LoginPage = () => <LoginView />; const LoginPage = () => <LoginView />;
export default LoginPage; export default LoginPage;

View 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;
});
}
}

View File

@ -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();

View File

@ -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;
} }
}; };
} }

View File

@ -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",

View File

@ -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>
)}
</> </>
); );
}; };

View 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>
</>
);
};

View File

@ -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

View File

@ -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) {

View File

@ -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";

View File

@ -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) => (

View File

@ -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) => (

View File

@ -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}
/> />

View File

@ -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"

View File

@ -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";

View 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>
);
};

View File

@ -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

View File

@ -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 && (

View File

@ -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}
/> />

View File

@ -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>
</>
); );
}; };

View File

@ -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";

View File

@ -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"

View File

@ -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>
);
};

View File

@ -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">

View File

@ -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}
/> />

View File

@ -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) => (

View File

@ -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}

View File

@ -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">

View File

@ -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}

View File

@ -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(() => {

View File

@ -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({

View File

@ -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",

View File

@ -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">

View File

@ -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

View File

@ -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;

View File

@ -75,10 +75,8 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
})), })),
], ],
}) })
.then((response) => { .then(() => {
submitChanges({ submitChanges();
related_issues: [...watch("related_issues"), ...(response ?? [])],
});
}); });
handleClose(); handleClose();

View File

@ -75,10 +75,8 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
})), })),
], ],
}) })
.then((response) => { .then(() => {
submitChanges({ submitChanges();
related_issues: [...watch("related_issues"), ...(response ?? [])],
});
}); });
handleClose(); handleClose();

View File

@ -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}

View File

@ -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>

View File

@ -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}
/> />
))} ))}

View File

@ -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>
); );
}; };

View File

@ -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;

View 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>
</>
);
};

View File

@ -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>
</>
);
};

View File

@ -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>
</>
);
};

View File

@ -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"
/> />
</> </>
)} )}

View File

@ -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>
</>
);
};

View File

@ -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>

View File

@ -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 ? (

View File

@ -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}
/> />
); );
}; };

View 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 };

View File

@ -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>
) : ( ) : (

View File

@ -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;

View File

@ -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>

View File

@ -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>
); );

View File

@ -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 (

View File

@ -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", {

View File

@ -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>

View File

@ -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}
/> />
</> </>
); );

View File

@ -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) => {

View File

@ -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>

View File

@ -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