fix: merge conflicts

This commit is contained in:
Aaryan Khandelwal 2023-10-02 13:09:59 +05:30
commit 4f33c4bea3
186 changed files with 8859 additions and 2444 deletions

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

@ -8,8 +8,8 @@ Before submitting a new issue, please search the [issues](https://github.com/mak
While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like: While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like:
- 3rd-party libraries being used and their versions - 3rd-party libraries being used and their versions
- a use-case that fails - a use-case that fails
Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved. Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved.
@ -19,10 +19,10 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
### Requirements ### Requirements
- Node.js version v16.18.0 - Node.js version v16.18.0
- Python version 3.8+ - Python version 3.8+
- Postgres version v14 - Postgres version v14
- Redis version v6.2.7 - Redis version v6.2.7
### Setup the project ### Setup the project
@ -30,6 +30,48 @@ The project is a monorepo, with backend api and frontend in a single repo.
The backend is a django project which is kept inside apiserver The backend is a django project which is kept inside apiserver
1. Clone the repo
```bash
git clone https://github.com/makeplane/plane
cd plane
chmod +x setup.sh
```
2. Run setup.sh
```bash
./setup.sh
```
3. Define `NEXT_PUBLIC_API_BASE_URL=http://localhost` in **web/.env** and **space/.env** file
```bash
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./web/.env
```
```bash
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./space/.env
```
4. Run Docker compose up
```bash
docker compose up -d
```
5. Install dependencies
```bash
yarn install
```
6. Run the web app in development mode
```bash
yarn dev
```
## Missing a Feature? ## Missing a Feature?
If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository. If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository.
@ -39,8 +81,8 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
To ensure consistency throughout the source code, please keep these rules in mind as you are working: To ensure consistency throughout the source code, please keep these rules in mind as you are working:
- All features or bug fixes must be tested by one or more specs (unit-tests). - All features or bug fixes must be tested by one or more specs (unit-tests).
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier. - We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
## Need help? Questions and suggestions ## Need help? Questions and suggestions
@ -48,11 +90,11 @@ Questions, suggestions, and thoughts are most welcome. We can also be reached in
## Ways to contribute ## Ways to contribute
- Try Plane Cloud and the self hosting platform and give feedback - Try Plane Cloud and the self hosting platform and give feedback
- Add new integrations - Add new integrations
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose) - Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
- Share your thoughts and suggestions with us - Share your thoughts and suggestions with us
- Help create tutorials and blog posts - Help create tutorials and blog posts
- Request a feature by submitting a proposal - Request a feature by submitting a proposal
- Report a bug - Report a bug
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations. - **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.

View File

@ -58,8 +58,17 @@ class WorkspaceEntityPermission(BasePermission):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS:
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
).exists()
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
member=request.user, workspace__slug=view.workspace_slug member=request.user,
workspace__slug=view.workspace_slug,
role__in=[Owner, Admin],
).exists() ).exists()

View File

@ -34,7 +34,6 @@ class CycleSerializer(BaseSerializer):
unstarted_issues = serializers.IntegerField(read_only=True) unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True)
assignees = serializers.SerializerMethodField(read_only=True) assignees = serializers.SerializerMethodField(read_only=True)
labels = serializers.SerializerMethodField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True) total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True) completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True) started_estimates = serializers.IntegerField(read_only=True)
@ -50,11 +49,10 @@ class CycleSerializer(BaseSerializer):
members = [ members = [
{ {
"avatar": assignee.avatar, "avatar": assignee.avatar,
"first_name": assignee.first_name,
"display_name": assignee.display_name, "display_name": assignee.display_name,
"id": assignee.id, "id": assignee.id,
} }
for issue_cycle in obj.issue_cycle.all() for issue_cycle in obj.issue_cycle.prefetch_related("issue__assignees").all()
for assignee in issue_cycle.issue.assignees.all() for assignee in issue_cycle.issue.assignees.all()
] ]
# Use a set comprehension to return only the unique objects # Use a set comprehension to return only the unique objects
@ -64,24 +62,6 @@ class CycleSerializer(BaseSerializer):
unique_list = [dict(item) for item in unique_objects] unique_list = [dict(item) for item in unique_objects]
return unique_list return unique_list
def get_labels(self, obj):
labels = [
{
"name": label.name,
"color": label.color,
"id": label.id,
}
for issue_cycle in obj.issue_cycle.all()
for label in issue_cycle.issue.labels.all()
]
# Use a set comprehension to return only the unique objects
unique_objects = {frozenset(item.items()) for item in labels}
# Convert the set back to a list of dictionaries
unique_list = [dict(item) for item in unique_objects]
return unique_list
class Meta: class Meta:
model = Cycle model = Cycle

View File

@ -80,7 +80,7 @@ class CycleViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)), issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -102,48 +102,84 @@ class CycleViewSet(BaseViewSet):
.select_related("workspace") .select_related("workspace")
.select_related("owned_by") .select_related("owned_by")
.annotate(is_favorite=Exists(subquery)) .annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle")) .annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(
"issue_cycle__issue__state__group", "issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"), filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
) )
) )
.annotate( .annotate(
cancelled_issues=Count( cancelled_issues=Count(
"issue_cycle__issue__state__group", "issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"), filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
) )
) )
.annotate( .annotate(
started_issues=Count( started_issues=Count(
"issue_cycle__issue__state__group", "issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"), filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
) )
) )
.annotate( .annotate(
unstarted_issues=Count( unstarted_issues=Count(
"issue_cycle__issue__state__group", "issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"), filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
) )
) )
.annotate( .annotate(
backlog_issues=Count( backlog_issues=Count(
"issue_cycle__issue__state__group", "issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="backlog"), filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
) )
) )
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
.annotate( .annotate(
completed_estimates=Sum( completed_estimates=Sum(
"issue_cycle__issue__estimate_point", "issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="completed"), filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
) )
) )
.annotate( .annotate(
started_estimates=Sum( started_estimates=Sum(
"issue_cycle__issue__estimate_point", "issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"), filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
) )
) )
.prefetch_related( .prefetch_related(
@ -196,17 +232,30 @@ class CycleViewSet(BaseViewSet):
.annotate(assignee_id=F("assignees__id")) .annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar")) .annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar") .values("display_name", "assignee_id", "avatar")
.annotate(total_issues=Count("assignee_id")) .annotate(
total_issues=Count(
"assignee_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(
"assignee_id", "assignee_id",
filter=Q(completed_at__isnull=False), filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.annotate( .annotate(
pending_issues=Count( pending_issues=Count(
"assignee_id", "assignee_id",
filter=Q(completed_at__isnull=True), filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.order_by("display_name") .order_by("display_name")
@ -222,17 +271,30 @@ class CycleViewSet(BaseViewSet):
.annotate(color=F("labels__color")) .annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id")) .annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id") .values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id")) .annotate(
total_issues=Count(
"label_id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(
"label_id", "label_id",
filter=Q(completed_at__isnull=False), filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.annotate( .annotate(
pending_issues=Count( pending_issues=Count(
"label_id", "label_id",
filter=Q(completed_at__isnull=True), filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.order_by("label_name") .order_by("label_name")
@ -385,17 +447,30 @@ class CycleViewSet(BaseViewSet):
.values( .values(
"first_name", "last_name", "assignee_id", "avatar", "display_name" "first_name", "last_name", "assignee_id", "avatar", "display_name"
) )
.annotate(total_issues=Count("assignee_id")) .annotate(
total_issues=Count(
"assignee_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(
"assignee_id", "assignee_id",
filter=Q(completed_at__isnull=False), filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.annotate( .annotate(
pending_issues=Count( pending_issues=Count(
"assignee_id", "assignee_id",
filter=Q(completed_at__isnull=True), filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.order_by("first_name", "last_name") .order_by("first_name", "last_name")
@ -412,17 +487,30 @@ class CycleViewSet(BaseViewSet):
.annotate(color=F("labels__color")) .annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id")) .annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id") .values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id")) .annotate(
total_issues=Count(
"label_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(
"label_id", "label_id",
filter=Q(completed_at__isnull=False), filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.annotate( .annotate(
pending_issues=Count( pending_issues=Count(
"label_id", "label_id",
filter=Q(completed_at__isnull=True), filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.order_by("label_name") .order_by("label_name")
@ -488,7 +576,7 @@ class CycleIssueViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)), issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -664,7 +752,7 @@ class CycleIssueViewSet(BaseViewSet):
), ),
} }
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
# Return all Cycle Issues # Return all Cycle Issues

View File

@ -384,7 +384,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
sort_order=largest_sort_order, sort_order=largest_sort_order,
start_date=issue_data.get("start_date", None), start_date=issue_data.get("start_date", None),
target_date=issue_data.get("target_date", None), target_date=issue_data.get("target_date", None),
priority=issue_data.get("priority", None), priority=issue_data.get("priority", "none"),
created_by=request.user, created_by=request.user,
) )
) )

View File

@ -173,12 +173,12 @@ class InboxIssueViewSet(BaseViewSet):
) )
# Check for valid priority # Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [ if not request.data.get("issue", {}).get("priority", "none") in [
"low", "low",
"medium", "medium",
"high", "high",
"urgent", "urgent",
None, "none",
]: ]:
return Response( return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
@ -213,7 +213,7 @@ class InboxIssueViewSet(BaseViewSet):
issue_id=str(issue.id), issue_id=str(issue.id),
project_id=str(project_id), project_id=str(project_id),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
# create an inbox issue # create an inbox issue
InboxIssue.objects.create( InboxIssue.objects.create(
@ -278,7 +278,7 @@ class InboxIssueViewSet(BaseViewSet):
IssueSerializer(current_instance).data, IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
issue_serializer.save() issue_serializer.save()
else: else:
@ -370,6 +370,11 @@ class InboxIssueViewSet(BaseViewSet):
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id): if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
# Check the issue status
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id).delete()
inbox_issue.delete() inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except InboxIssue.DoesNotExist: except InboxIssue.DoesNotExist:
@ -480,12 +485,12 @@ class InboxIssuePublicViewSet(BaseViewSet):
) )
# Check for valid priority # Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [ if not request.data.get("issue", {}).get("priority", "none") in [
"low", "low",
"medium", "medium",
"high", "high",
"urgent", "urgent",
None, "none",
]: ]:
return Response( return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
@ -520,7 +525,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
issue_id=str(issue.id), issue_id=str(issue.id),
project_id=str(project_id), project_id=str(project_id),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
# create an inbox issue # create an inbox issue
InboxIssue.objects.create( InboxIssue.objects.create(
@ -585,7 +590,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
IssueSerializer(current_instance).data, IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
issue_serializer.save() issue_serializer.save()
return Response(issue_serializer.data, status=status.HTTP_200_OK) return Response(issue_serializer.data, status=status.HTTP_200_OK)

View File

@ -24,7 +24,6 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError from django.db import IntegrityError
from django.conf import settings
from django.db import IntegrityError from django.db import IntegrityError
# Third Party imports # Third Party imports
@ -58,7 +57,6 @@ from plane.api.serializers import (
IssuePublicSerializer, IssuePublicSerializer,
) )
from plane.api.permissions import ( from plane.api.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission, ProjectEntityPermission,
WorkSpaceAdminPermission, WorkSpaceAdminPermission,
ProjectMemberPermission, ProjectMemberPermission,
@ -130,7 +128,7 @@ class IssueViewSet(BaseViewSet):
current_instance=json.dumps( current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return super().perform_update(serializer) return super().perform_update(serializer)
@ -151,7 +149,7 @@ class IssueViewSet(BaseViewSet):
current_instance=json.dumps( current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -318,7 +316,7 @@ class IssueViewSet(BaseViewSet):
issue_id=str(serializer.data.get("id", None)), issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id), project_id=str(project_id),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -330,7 +328,12 @@ class IssueViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, pk=None): def retrieve(self, request, slug, project_id, pk=None):
try: try:
issue = Issue.issue_objects.get( issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
).get(
workspace__slug=slug, project_id=project_id, pk=pk workspace__slug=slug, project_id=project_id, pk=pk
) )
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
@ -572,7 +575,7 @@ class IssueCommentViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id")), issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")), project_id=str(self.kwargs.get("project_id")),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
def perform_update(self, serializer): def perform_update(self, serializer):
@ -591,7 +594,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data, IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return super().perform_update(serializer) return super().perform_update(serializer)
@ -613,7 +616,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data, IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -709,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(
@ -897,7 +908,7 @@ class IssueLinkViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id")), issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")), project_id=str(self.kwargs.get("project_id")),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
def perform_update(self, serializer): def perform_update(self, serializer):
@ -916,7 +927,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data, IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return super().perform_update(serializer) return super().perform_update(serializer)
@ -938,7 +949,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data, IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -1017,7 +1028,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
serializer.data, serializer.data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1040,7 +1051,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
issue_id=str(self.kwargs.get("issue_id", None)), issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -1243,7 +1254,7 @@ class IssueArchiveViewSet(BaseViewSet):
issue_id=str(issue.id), issue_id=str(issue.id),
project_id=str(project_id), project_id=str(project_id),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
@ -1448,7 +1459,7 @@ class IssueReactionViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)), issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
def destroy(self, request, slug, project_id, issue_id, reaction_code): def destroy(self, request, slug, project_id, issue_id, reaction_code):
@ -1472,7 +1483,7 @@ class IssueReactionViewSet(BaseViewSet):
"identifier": str(issue_reaction.id), "identifier": str(issue_reaction.id),
} }
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
issue_reaction.delete() issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -1521,7 +1532,7 @@ class CommentReactionViewSet(BaseViewSet):
issue_id=None, issue_id=None,
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
def destroy(self, request, slug, project_id, comment_id, reaction_code): def destroy(self, request, slug, project_id, comment_id, reaction_code):
@ -1546,7 +1557,7 @@ class CommentReactionViewSet(BaseViewSet):
"comment_id": str(comment_id), "comment_id": str(comment_id),
} }
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
comment_reaction.delete() comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -1643,7 +1654,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
issue_id=str(issue_id), issue_id=str(issue_id),
project_id=str(project_id), project_id=str(project_id),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
if not ProjectMember.objects.filter( if not ProjectMember.objects.filter(
project_id=project_id, project_id=project_id,
@ -1693,7 +1704,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
IssueCommentSerializer(comment).data, IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1727,7 +1738,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
IssueCommentSerializer(comment).data, IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
comment.delete() comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -1802,7 +1813,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)), issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1847,7 +1858,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
"identifier": str(issue_reaction.id), "identifier": str(issue_reaction.id),
} }
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
issue_reaction.delete() issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -1921,7 +1932,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
issue_id=None, issue_id=None,
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1973,7 +1984,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
"comment_id": str(comment_id), "comment_id": str(comment_id),
} }
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
comment_reaction.delete() comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -2037,7 +2048,7 @@ class IssueVotePublicViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)), issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
serializer = IssueVoteSerializer(issue_vote) serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
@ -2072,7 +2083,7 @@ class IssueVotePublicViewSet(BaseViewSet):
"identifier": str(issue_vote.id), "identifier": str(issue_vote.id),
} }
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
issue_vote.delete() issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -2106,7 +2117,7 @@ class IssueRelationViewSet(BaseViewSet):
IssueRelationSerializer(current_instance).data, IssueRelationSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -2140,7 +2151,7 @@ class IssueRelationViewSet(BaseViewSet):
issue_id=str(issue_id), issue_id=str(issue_id),
project_id=str(project_id), project_id=str(project_id),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
if relation == "blocking": if relation == "blocking":
@ -2395,27 +2406,6 @@ class IssueDraftViewSet(BaseViewSet):
] ]
serializer_class = IssueFlatSerializer serializer_class = IssueFlatSerializer
model = Issue model = Issue
def perform_update(self, serializer):
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
type="issue_draft.activity.updated",
requested_data=requested_data,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch = int(timezone.now().timestamp())
)
return super().perform_update(serializer)
def perform_destroy(self, instance): def perform_destroy(self, instance):
@ -2434,6 +2424,7 @@ class IssueDraftViewSet(BaseViewSet):
current_instance=json.dumps( current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
), ),
epoch=int(timezone.now().timestamp())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -2597,7 +2588,7 @@ class IssueDraftViewSet(BaseViewSet):
issue_id=str(serializer.data.get("id", None)), issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id), project_id=str(project_id),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -2608,6 +2599,47 @@ class IssueDraftViewSet(BaseViewSet):
) )
def partial_update(self, request, slug, project_id, pk):
try:
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
serializer = IssueSerializer(
issue, data=request.data, partial=True
)
if serializer.is_valid():
if(request.data.get("is_draft") is not None and not request.data.get("is_draft")):
serializer.save(created_at=timezone.now(), updated_at=timezone.now())
else:
serializer.save()
issue_activity.delay(
type="issue_draft.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(issue).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Issue.DoesNotExist:
return Response(
{"error": "Issue does not exists"},
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 retrieve(self, request, slug, project_id, pk=None): def retrieve(self, request, slug, project_id, pk=None):
try: try:
issue = Issue.objects.get( issue = Issue.objects.get(

View File

@ -40,6 +40,7 @@ from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
class ModuleViewSet(BaseViewSet): class ModuleViewSet(BaseViewSet):
model = Module model = Module
permission_classes = [ permission_classes = [
@ -78,35 +79,63 @@ class ModuleViewSet(BaseViewSet):
queryset=ModuleLink.objects.select_related("module", "created_by"), queryset=ModuleLink.objects.select_related("module", "created_by"),
) )
) )
.annotate(total_issues=Count("issue_module")) .annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
),
)
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(
"issue_module__issue__state__group", "issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="completed"), filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
) )
) )
.annotate( .annotate(
cancelled_issues=Count( cancelled_issues=Count(
"issue_module__issue__state__group", "issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="cancelled"), filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
) )
) )
.annotate( .annotate(
started_issues=Count( started_issues=Count(
"issue_module__issue__state__group", "issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="started"), filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
) )
) )
.annotate( .annotate(
unstarted_issues=Count( unstarted_issues=Count(
"issue_module__issue__state__group", "issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="unstarted"), filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
) )
) )
.annotate( .annotate(
backlog_issues=Count( backlog_issues=Count(
"issue_module__issue__state__group", "issue_module__issue__state__group",
filter=Q(issue_module__issue__state__group="backlog"), filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
) )
) )
.order_by(order_by, "name") .order_by(order_by, "name")
@ -130,7 +159,7 @@ class ModuleViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)), issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -179,18 +208,36 @@ class ModuleViewSet(BaseViewSet):
.annotate(assignee_id=F("assignees__id")) .annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name")) .annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar")) .annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name") .values(
.annotate(total_issues=Count("assignee_id")) "first_name", "last_name", "assignee_id", "avatar", "display_name"
)
.annotate(
total_issues=Count(
"assignee_id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(
"assignee_id", "assignee_id",
filter=Q(completed_at__isnull=False), filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.annotate( .annotate(
pending_issues=Count( pending_issues=Count(
"assignee_id", "assignee_id",
filter=Q(completed_at__isnull=True), filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.order_by("first_name", "last_name") .order_by("first_name", "last_name")
@ -206,17 +253,33 @@ class ModuleViewSet(BaseViewSet):
.annotate(color=F("labels__color")) .annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id")) .annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id") .values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id")) .annotate(
total_issues=Count(
"label_id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(
"label_id", "label_id",
filter=Q(completed_at__isnull=False), filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.annotate( .annotate(
pending_issues=Count( pending_issues=Count(
"label_id", "label_id",
filter=Q(completed_at__isnull=True), filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
) )
) )
.order_by("label_name") .order_by("label_name")
@ -279,7 +342,7 @@ class ModuleIssueViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)), issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -447,7 +510,7 @@ class ModuleIssueViewSet(BaseViewSet):
), ),
} }
), ),
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
return Response( return Response(
@ -494,7 +557,6 @@ class ModuleLinkViewSet(BaseViewSet):
class ModuleFavoriteViewSet(BaseViewSet): class ModuleFavoriteViewSet(BaseViewSet):
serializer_class = ModuleFavoriteSerializer serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite model = ModuleFavorite

View File

@ -1094,7 +1094,7 @@ class ProjectMemberEndpoint(BaseAPIView):
project_id=project_id, project_id=project_id,
workspace__slug=slug, workspace__slug=slug,
member__is_bot=False, member__is_bot=False,
).select_related("project", "member") ).select_related("project", "member", "workspace")
serializer = ProjectMemberSerializer(project_members, many=True) serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:

View File

@ -61,7 +61,7 @@ class GlobalViewViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace") .select_related("workspace")
.order_by("-created_at") .order_by(self.request.GET.get("order_by", "-created_at"))
.distinct() .distinct()
) )

View File

@ -1239,13 +1239,21 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
.annotate( .annotate(
created_issues=Count( created_issues=Count(
"project_issue", "project_issue",
filter=Q(project_issue__created_by_id=user_id), filter=Q(
project_issue__created_by_id=user_id,
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
),
) )
) )
.annotate( .annotate(
assigned_issues=Count( assigned_issues=Count(
"project_issue", "project_issue",
filter=Q(project_issue__assignees__in=[user_id]), filter=Q(
project_issue__assignees__in=[user_id],
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
),
) )
) )
.annotate( .annotate(
@ -1254,6 +1262,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
filter=Q( filter=Q(
project_issue__completed_at__isnull=False, project_issue__completed_at__isnull=False,
project_issue__assignees__in=[user_id], project_issue__assignees__in=[user_id],
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
), ),
) )
) )
@ -1267,6 +1277,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
"started", "started",
], ],
project_issue__assignees__in=[user_id], project_issue__assignees__in=[user_id],
project_issue__archived_at__isnull=True,
project_issue__is_draft=False,
), ),
) )
) )
@ -1317,6 +1329,11 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
def get(self, request, slug, user_id): def get(self, request, slug, user_id):
try: try:
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = ( issue_queryset = (
Issue.issue_objects.filter( Issue.issue_objects.filter(

View File

@ -32,7 +32,7 @@ def delete_old_s3_link():
else: else:
s3 = boto3.client( s3 = boto3.client(
"s3", "s3",
region_name="ap-south-1", region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID, aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"), config=Config(signature_version="s3v4"),

View File

@ -121,36 +121,20 @@ def track_priority(
epoch epoch
): ):
if current_instance.get("priority") != requested_data.get("priority"): if current_instance.get("priority") != requested_data.get("priority"):
if requested_data.get("priority") == None: issue_activities.append(
issue_activities.append( IssueActivity(
IssueActivity( issue_id=issue_id,
issue_id=issue_id, actor=actor,
actor=actor, verb="updated",
verb="updated", old_value=current_instance.get("priority"),
old_value=current_instance.get("priority"), new_value=requested_data.get("priority"),
new_value=None, field="priority",
field="priority", project=project,
project=project, workspace=project.workspace,
workspace=project.workspace, comment=f"updated the priority to {requested_data.get('priority')}",
comment=f"updated the priority to None", epoch=epoch,
epoch=epoch,
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=requested_data.get("priority"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"updated the priority to {requested_data.get('priority')}",
epoch=epoch,
)
) )
)
# Track chnages in state of the issue # Track chnages in state of the issue
@ -1405,7 +1389,7 @@ def issue_activity(
): ):
issue_subscribers = issue_subscribers + [issue.created_by_id] issue_subscribers = issue_subscribers + [issue.created_by_id]
for subscriber in issue_subscribers: for subscriber in list(set(issue_subscribers)):
for issue_activity in issue_activities_created: for issue_activity in issue_activities_created:
bulk_notifications.append( bulk_notifications.append(
Notification( Notification(

View File

@ -58,28 +58,31 @@ def archive_old_issues():
# Check if Issues # Check if Issues
if issues: if issues:
# Set the archive time to current time
archive_at = timezone.now()
issues_to_update = [] issues_to_update = []
for issue in issues: for issue in issues:
issue.archived_at = timezone.now() issue.archived_at = archive_at
issues_to_update.append(issue) issues_to_update.append(issue)
# Bulk Update the issues and log the activity # Bulk Update the issues and log the activity
if issues_to_update: if issues_to_update:
updated_issues = Issue.objects.bulk_update( Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100 issues_to_update, ["archived_at"], batch_size=100
) )
[ [
issue_activity.delay( issue_activity.delay(
type="issue.activity.updated", type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(issue.archived_at)}), requested_data=json.dumps({"archived_at": str(archive_at)}),
actor_id=str(project.created_by_id), actor_id=str(project.created_by_id),
issue_id=issue.id, issue_id=issue.id,
project_id=project_id, project_id=project_id,
current_instance=None, current_instance=None,
subscriber=False, subscriber=False,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
for issue in updated_issues for issue in issues_to_update
] ]
return return
except Exception as e: except Exception as e:
@ -139,7 +142,7 @@ def close_old_issues():
# Bulk Update the issues and log the activity # Bulk Update the issues and log the activity
if issues_to_update: if issues_to_update:
updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
[ [
issue_activity.delay( issue_activity.delay(
type="issue.activity.updated", type="issue.activity.updated",
@ -149,9 +152,9 @@ def close_old_issues():
project_id=project_id, project_id=project_id,
current_instance=None, current_instance=None,
subscriber=False, subscriber=False,
epoch = int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp())
) )
for issue in updated_issues for issue in issues_to_update
] ]
return return
except Exception as e: except Exception as e:

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

@ -26,19 +26,19 @@ def workspace_member_props(old_props):
"calendar_date_range": old_props.get("calendarDateRange", ""), "calendar_date_range": old_props.get("calendarDateRange", ""),
}, },
"display_properties": { "display_properties": {
"assignee": old_props.get("properties", {}).get("assignee",None), "assignee": old_props.get("properties", {}).get("assignee", True),
"attachment_count": old_props.get("properties", {}).get("attachment_count", None), "attachment_count": old_props.get("properties", {}).get("attachment_count", True),
"created_on": old_props.get("properties", {}).get("created_on", None), "created_on": old_props.get("properties", {}).get("created_on", True),
"due_date": old_props.get("properties", {}).get("due_date", None), "due_date": old_props.get("properties", {}).get("due_date", True),
"estimate": old_props.get("properties", {}).get("estimate", None), "estimate": old_props.get("properties", {}).get("estimate", True),
"key": old_props.get("properties", {}).get("key", None), "key": old_props.get("properties", {}).get("key", True),
"labels": old_props.get("properties", {}).get("labels", None), "labels": old_props.get("properties", {}).get("labels", True),
"link": old_props.get("properties", {}).get("link", None), "link": old_props.get("properties", {}).get("link", True),
"priority": old_props.get("properties", {}).get("priority", None), "priority": old_props.get("properties", {}).get("priority", True),
"start_date": old_props.get("properties", {}).get("start_date", None), "start_date": old_props.get("properties", {}).get("start_date", True),
"state": old_props.get("properties", {}).get("state", None), "state": old_props.get("properties", {}).get("state", True),
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", None), "sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", True),
"updated_on": old_props.get("properties", {}).get("updated_on", None), "updated_on": old_props.get("properties", {}).get("updated_on", True),
}, },
} }
return new_props return new_props

View File

@ -1,24 +0,0 @@
# Generated by Django 4.2.3 on 2023-09-15 06:55
from django.db import migrations
def update_issue_activity(apps, schema_editor):
IssueActivityModel = apps.get_model("db", "IssueActivity")
updated_issue_activity = []
for obj in IssueActivityModel.objects.all():
if obj.field == "blocks":
obj.field = "blocked_by"
updated_issue_activity.append(obj)
IssueActivityModel.objects.bulk_update(updated_issue_activity, ["field"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('db', '0044_auto_20230913_0709'),
]
operations = [
migrations.RunPython(update_issue_activity),
]

View File

@ -1,28 +1,47 @@
# Generated by Django 4.2.3 on 2023-09-19 14:21 # Generated by Django 4.2.5 on 2023-09-29 10:14
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import plane.db.models.workspace
import uuid import uuid
def update_epoch(apps, schema_editor): def update_issue_activity_priority(apps, schema_editor):
IssueActivity = apps.get_model('db', 'IssueActivity') IssueActivity = apps.get_model("db", "IssueActivity")
updated_issue_activity = [] updated_issue_activity = []
for obj in IssueActivity.objects.all(): for obj in IssueActivity.objects.filter(field="priority"):
obj.epoch = int(obj.created_at.timestamp()) # 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) updated_issue_activity.append(obj)
IssueActivity.objects.bulk_update(updated_issue_activity, ["epoch"], batch_size=100) 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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('db', '0045_auto_20230915_0655'), ('db', '0044_auto_20230913_0709'),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='GlobalView', name='GlobalView',
fields=[ fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
@ -33,6 +52,7 @@ class Migration(migrations.Migration):
('query', models.JSONField(verbose_name='View Query')), ('query', models.JSONField(verbose_name='View Query')),
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)), ('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
('query_data', models.JSONField(default=dict)), ('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')), ('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')), ('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')), ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')),
@ -44,10 +64,16 @@ class Migration(migrations.Migration):
'ordering': ('-created_at',), 'ordering': ('-created_at',),
}, },
), ),
migrations.AddField(
model_name='workspacemember',
name='issue_props',
field=models.JSONField(default=plane.db.models.workspace.get_issue_props),
),
migrations.AddField( migrations.AddField(
model_name='issueactivity', model_name='issueactivity',
name='epoch', name='epoch',
field=models.FloatField(null=True), field=models.FloatField(null=True),
), ),
migrations.RunPython(update_epoch), migrations.RunPython(update_issue_activity_priority),
migrations.RunPython(update_issue_activity_blocked),
] ]

View File

@ -17,12 +17,23 @@ class GlobalView(BaseModel):
default=1, choices=((0, "Private"), (1, "Public")) default=1, choices=((0, "Private"), (1, "Public"))
) )
query_data = models.JSONField(default=dict) query_data = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta: class Meta:
verbose_name = "Global View" verbose_name = "Global View"
verbose_name_plural = "Global Views" verbose_name_plural = "Global Views"
db_table = "global_views" db_table = "global_views"
ordering = ("-created_at",) ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
largest_sort_order = GlobalView.objects.filter(
workspace=self.workspace
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(GlobalView, self).save(*args, **kwargs)
def __str__(self): def __str__(self):
"""Return name of the View""" """Return name of the View"""

View File

@ -29,7 +29,7 @@ def get_default_props():
}, },
"display_filters": { "display_filters": {
"group_by": None, "group_by": None,
"order_by": '-created_at', "order_by": "-created_at",
"type": None, "type": None,
"sub_issue": True, "sub_issue": True,
"show_empty_groups": True, "show_empty_groups": True,
@ -54,6 +54,15 @@ def get_default_props():
} }
def get_issue_props():
return {
"subscribed": True,
"assigned": True,
"created": True,
"all_issues": True,
}
class Workspace(BaseModel): class Workspace(BaseModel):
name = models.CharField(max_length=80, verbose_name="Workspace Name") name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.URLField(verbose_name="Logo", blank=True, null=True) logo = models.URLField(verbose_name="Logo", blank=True, null=True)
@ -89,6 +98,7 @@ class WorkspaceMember(BaseModel):
company_role = models.TextField(null=True, blank=True) company_role = models.TextField(null=True, blank=True)
view_props = models.JSONField(default=get_default_props) view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props) default_props = models.JSONField(default=get_default_props)
issue_props = models.JSONField(default=get_issue_props)
class Meta: class Meta:
unique_together = ["workspace", "member"] unique_together = ["workspace", "member"]

View File

@ -74,10 +74,10 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
sorted_data = grouped_data sorted_data = grouped_data
if temp_axis == "priority": if temp_axis == "priority":
order = ["low", "medium", "high", "urgent", "None"] order = ["low", "medium", "high", "urgent", "none"]
sorted_data = {key: grouped_data[key] for key in order if key in grouped_data} sorted_data = {key: grouped_data[key] for key in order if key in grouped_data}
else: else:
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0]))) sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "none", x[0])))
return sorted_data return sorted_data

View File

@ -40,9 +40,6 @@ def filter_priority(params, filter, method):
priorities = params.get("priority").split(",") priorities = params.get("priority").split(",")
if len(priorities) and "" not in priorities: if len(priorities) and "" not in priorities:
filter["priority__in"] = priorities filter["priority__in"] = priorities
else:
if params.get("priority", None) and len(params.get("priority")):
filter["priority__in"] = params.get("priority")
return filter return filter
@ -166,17 +163,17 @@ def filter_target_date(params, filter, method):
for query in target_dates: for query in target_dates:
target_date_query = query.split(";") target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query: if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gt"] = target_date_query[0] filter["target_date__gte"] = target_date_query[0]
else: else:
filter["target_date__lt"] = target_date_query[0] filter["target_date__lte"] = target_date_query[0]
else: else:
if params.get("target_date", None) and len(params.get("target_date")): if params.get("target_date", None) and len(params.get("target_date")):
for query in params.get("target_date"): for query in params.get("target_date"):
target_date_query = query.split(";") target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query: if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gt"] = target_date_query[0] filter["target_date__gte"] = target_date_query[0]
else: else:
filter["target_date__lt"] = target_date_query[0] filter["target_date__lte"] = target_date_query[0]
return filter return filter

View File

@ -1,113 +1,61 @@
version: "3.8" version: "3.8"
x-api-and-worker-env:
&api-and-worker-env
DEBUG: ${DEBUG}
SENTRY_DSN: ${SENTRY_DSN}
DJANGO_SETTINGS_MODULE: plane.settings.selfhosted
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
REDIS_URL: redis://plane-redis:6379/
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
EMAIL_PORT: ${EMAIL_PORT}
EMAIL_FROM: ${EMAIL_FROM}
EMAIL_USE_TLS: ${EMAIL_USE_TLS}
EMAIL_USE_SSL: ${EMAIL_USE_SSL}
AWS_REGION: ${AWS_REGION}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
WEB_URL: ${WEB_URL}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
DISABLE_COLLECTSTATIC: 1
DOCKERIZED: 1
OPENAI_API_BASE: ${OPENAI_API_BASE}
OPENAI_API_KEY: ${OPENAI_API_KEY}
GPT_ENGINE: ${GPT_ENGINE}
SECRET_KEY: ${SECRET_KEY}
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
USE_MINIO: ${USE_MINIO}
ENABLE_SIGNUP: ${ENABLE_SIGNUP}
services: services:
plane-web: web:
container_name: planefrontend container_name: web
image: makeplane/plane-frontend:latest image: makeplane/plane-frontend:latest
restart: always restart: always
command: /usr/local/bin/start.sh web/server.js web command: /usr/local/bin/start.sh web/server.js web
env_file: env_file:
- .env - ./web/.env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
NEXT_PUBLIC_GITHUB_ID: "0"
NEXT_PUBLIC_SENTRY_DSN: "0"
NEXT_PUBLIC_ENABLE_OAUTH: "0"
NEXT_PUBLIC_ENABLE_SENTRY: "0"
NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0"
NEXT_PUBLIC_TRACK_EVENTS: "0"
depends_on: depends_on:
- plane-api - api
- plane-worker - worker
plane-deploy: space:
container_name: planedeploy container_name: space
image: makeplane/plane-deploy:latest image: makeplane/plane-space:latest
restart: always restart: always
command: /usr/local/bin/start.sh space/server.js space command: /usr/local/bin/start.sh space/server.js space
env_file: env_file:
- .env - ./space/.env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
depends_on: depends_on:
- plane-api - api
- plane-worker - worker
- plane-web - web
plane-api: api:
container_name: planebackend container_name: api
image: makeplane/plane-backend:latest image: makeplane/plane-backend:latest
restart: always restart: always
command: ./bin/takeoff command: ./bin/takeoff
env_file: env_file:
- .env - ./apiserver/.env
environment:
<<: *api-and-worker-env
depends_on: depends_on:
- plane-db - plane-db
- plane-redis - plane-redis
plane-worker: worker:
container_name: planebgworker container_name: bgworker
image: makeplane/plane-backend:latest image: makeplane/plane-backend:latest
restart: always restart: always
command: ./bin/worker command: ./bin/worker
env_file: env_file:
- .env - ./apiserver/.env
environment:
<<: *api-and-worker-env
depends_on: depends_on:
- plane-api - api
- plane-db - plane-db
- plane-redis - plane-redis
plane-beat-worker: beat-worker:
container_name: planebeatworker container_name: beatworker
image: makeplane/plane-backend:latest image: makeplane/plane-backend:latest
restart: always restart: always
command: ./bin/beat command: ./bin/beat
env_file: env_file:
- .env - ./apiserver/.env
environment:
<<: *api-and-worker-env
depends_on: depends_on:
- plane-api - api
- plane-db - plane-db
- plane-redis - plane-redis
@ -157,8 +105,8 @@ services:
- plane-minio - plane-minio
# Comment this if you already have a reverse proxy running # Comment this if you already have a reverse proxy running
plane-proxy: proxy:
container_name: planeproxy container_name: proxy
image: makeplane/plane-proxy:latest image: makeplane/plane-proxy:latest
ports: ports:
- ${NGINX_PORT}:80 - ${NGINX_PORT}:80
@ -168,8 +116,9 @@ services:
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
depends_on: depends_on:
- plane-web - web
- plane-api - api
- space
volumes: volumes:
pgdata: pgdata:

View File

@ -1,8 +1,8 @@
version: "3.8" version: "3.8"
services: services:
plane-web: web:
container_name: planefrontend container_name: web
build: build:
context: . context: .
dockerfile: ./web/Dockerfile.web dockerfile: ./web/Dockerfile.web
@ -11,11 +11,11 @@ services:
restart: always restart: always
command: /usr/local/bin/start.sh web/server.js web command: /usr/local/bin/start.sh web/server.js web
depends_on: depends_on:
- plane-api - api
- plane-worker - worker
plane-deploy: space:
container_name: planedeploy container_name: space
build: build:
context: . context: .
dockerfile: ./space/Dockerfile.space dockerfile: ./space/Dockerfile.space
@ -24,12 +24,12 @@ services:
restart: always restart: always
command: /usr/local/bin/start.sh space/server.js space command: /usr/local/bin/start.sh space/server.js space
depends_on: depends_on:
- plane-api - api
- plane-worker - worker
- plane-web - web
plane-api: api:
container_name: planebackend container_name: api
build: build:
context: ./apiserver context: ./apiserver
dockerfile: Dockerfile.api dockerfile: Dockerfile.api
@ -43,8 +43,8 @@ services:
- plane-db - plane-db
- plane-redis - plane-redis
plane-worker: worker:
container_name: planebgworker container_name: bgworker
build: build:
context: ./apiserver context: ./apiserver
dockerfile: Dockerfile.api dockerfile: Dockerfile.api
@ -55,12 +55,12 @@ services:
env_file: env_file:
- ./apiserver/.env - ./apiserver/.env
depends_on: depends_on:
- plane-api - api
- plane-db - plane-db
- plane-redis - plane-redis
plane-beat-worker: beat-worker:
container_name: planebeatworker container_name: beatworker
build: build:
context: ./apiserver context: ./apiserver
dockerfile: Dockerfile.api dockerfile: Dockerfile.api
@ -71,7 +71,7 @@ services:
env_file: env_file:
- ./apiserver/.env - ./apiserver/.env
depends_on: depends_on:
- plane-api - api
- plane-db - plane-db
- plane-redis - plane-redis
@ -118,8 +118,8 @@ services:
- plane-minio - plane-minio
# Comment this if you already have a reverse proxy running # Comment this if you already have a reverse proxy running
plane-proxy: proxy:
container_name: planeproxy container_name: proxy
build: build:
context: ./nginx context: ./nginx
dockerfile: Dockerfile dockerfile: Dockerfile
@ -130,8 +130,9 @@ services:
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
depends_on: depends_on:
- plane-web - web
- plane-api - api
- space
volumes: volumes:
pgdata: pgdata:

View File

@ -1,29 +1,36 @@
events { } events {
}
http { http {
sendfile on; sendfile on;
server { server {
listen 80; listen 80;
root /www/data/; root /www/data/;
access_log /var/log/nginx/access.log; access_log /var/log/nginx/access.log;
client_max_body_size ${FILE_SIZE_LIMIT}; client_max_body_size ${FILE_SIZE_LIMIT};
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "interest-cohort=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / { location / {
proxy_pass http://planefrontend:3000/; proxy_pass http://web:3000/;
} }
location /api/ { location /api/ {
proxy_pass http://planebackend:8000/api/; proxy_pass http://api:8000/api/;
} }
location /spaces/ { location /spaces/ {
proxy_pass http://planedeploy:3000/spaces/; rewrite ^/spaces/?$ /spaces/login break;
proxy_pass http://space:3000/spaces/;
} }
location /${BUCKET_NAME}/ { location /${BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/uploads/; proxy_pass http://plane-minio:9000/uploads/;
} }
} }
} }

View File

@ -1,4 +1,2 @@
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
# Flag to toggle OAuth # Flag to toggle OAuth
NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_ENABLE_OAUTH=0

View File

@ -33,7 +33,7 @@ export const SignInView = observer(() => {
const onSignInSuccess = (response: any) => { const onSignInSuccess = (response: any) => {
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false; const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/"; const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/login";
userStore.setCurrentUser(response?.user); userStore.setCurrentUser(response?.user);
@ -41,7 +41,7 @@ export const SignInView = observer(() => {
router.push(`/onboarding?next_path=${nextPath}`); router.push(`/onboarding?next_path=${nextPath}`);
return; return;
} }
router.push((nextPath ?? "/").toString()); router.push((nextPath ?? "/login").toString());
}; };
const handleGoogleSignIn = async ({ clientId, credential }: any) => { const handleGoogleSignIn = async ({ clientId, credential }: any) => {

View File

@ -1 +1 @@
export * from "./home"; export * from "./login";

View File

@ -4,7 +4,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
// components // components
import { SignInView, UserLoggedIn } from "components/accounts"; import { SignInView, UserLoggedIn } from "components/accounts";
export const HomeView = observer(() => { export const LoginView = observer(() => {
const { user: userStore } = useMobxStore(); const { user: userStore } = useMobxStore();
if (!userStore.currentUser) return <SignInView />; if (!userStore.currentUser) return <SignInView />;

View File

@ -1,8 +0,0 @@
import React from "react";
// components
import { HomeView } from "components/views";
const HomePage = () => <HomeView />;
export default HomePage;

View File

@ -0,0 +1,8 @@
import React from "react";
// components
import { LoginView } from "components/views";
const LoginPage = () => <LoginView />;
export default LoginPage;

View File

@ -1,24 +1,4 @@
# Extra image domains that need to be added for Next Image
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
# GitHub App ID for GitHub OAuth
NEXT_PUBLIC_GITHUB_ID=""
# GitHub App Name for GitHub Integration
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Sentry DSN for error monitoring
NEXT_PUBLIC_SENTRY_DSN=""
# Enable/Disable OAUTH - default 0 for selfhosted instance # Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_ENABLE_OAUTH=0
# Enable/Disable Sentry
NEXT_PUBLIC_ENABLE_SENTRY=0
# Enable/Disable session recording
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
# Enable/Disable event tracking
NEXT_PUBLIC_TRACK_EVENTS=0
# Slack Client ID for Slack Integration
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# Public boards deploy URL # Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL="http://localhost:3000/spaces" NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"

View File

@ -9,7 +9,6 @@ import { findStringWithMostCharacters } from "helpers/array.helper";
import { generateBarColor } from "helpers/analytics.helper"; import { generateBarColor } from "helpers/analytics.helper";
// types // types
import { IAnalyticsParams, IAnalyticsResponse } from "types"; import { IAnalyticsParams, IAnalyticsResponse } from "types";
// constants
type Props = { type Props = {
analytics: IAnalyticsResponse; analytics: IAnalyticsResponse;

View File

@ -15,17 +15,19 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
<div className="divide-y divide-custom-border-200"> <div className="divide-y divide-custom-border-200">
<div> <div>
<h6 className="px-3 text-base font-medium">Pending issues</h6> <h6 className="px-3 text-base font-medium">Pending issues</h6>
{defaultAnalytics.pending_issue_user.length > 0 ? ( {defaultAnalytics.pending_issue_user && defaultAnalytics.pending_issue_user.length > 0 ? (
<BarGraph <BarGraph
data={defaultAnalytics.pending_issue_user} data={defaultAnalytics.pending_issue_user}
indexBy="assignees__display_name" indexBy="assignees__id"
keys={["count"]} keys={["count"]}
height="250px" height="250px"
colors={() => `#f97316`} colors={() => `#f97316`}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)} customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) =>
d.count > 0 ? d.count : 50
)}
tooltip={(datum) => { tooltip={(datum) => {
const assignee = defaultAnalytics.pending_issue_user.find( const assignee = defaultAnalytics.pending_issue_user.find(
(a) => a.assignees__display_name === `${datum.indexValue}` (a) => a.assignees__id === `${datum.indexValue}`
); );
return ( return (
@ -39,10 +41,9 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
}} }}
axisBottom={{ axisBottom={{
renderTick: (datum) => { renderTick: (datum) => {
const avatar = const assignee = defaultAnalytics.pending_issue_user[datum.tickIndex] ?? "";
defaultAnalytics.pending_issue_user[datum.tickIndex]?.assignees__avatar ?? "";
if (avatar && avatar !== "") if (assignee && assignee?.assignees__avatar && assignee?.assignees__avatar !== "")
return ( return (
<g transform={`translate(${datum.x},${datum.y})`}> <g transform={`translate(${datum.x},${datum.y})`}>
<image <image
@ -50,7 +51,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
y={10} y={10}
width={16} width={16}
height={16} height={16}
xlinkHref={avatar} xlinkHref={assignee?.assignees__avatar}
style={{ clipPath: "circle(50%)" }} style={{ clipPath: "circle(50%)" }}
/> />
</g> </g>
@ -60,7 +61,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
<g transform={`translate(${datum.x},${datum.y})`}> <g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" /> <circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff"> <text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{datum.value ? `${datum.value}`.toUpperCase()[0] : "?"} {datum.value ? `${assignee.assignees__display_name}`.toUpperCase()[0] : "?"}
</text> </text>
</g> </g>
); );

View File

@ -1,15 +1,41 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr";
// hook
import useEstimateOption from "hooks/use-estimate-option";
// services
import issuesService from "services/issues.service";
// icons // icons
import { Icon, Tooltip } from "components/ui"; import { Icon, Tooltip } from "components/ui";
import { CopyPlus } from "lucide-react"; import {
import { Squares2X2Icon } from "@heroicons/react/24/outline"; TagIcon,
import { BlockedIcon, BlockerIcon, RelatedIcon } from "components/icons"; CopyPlus,
Calendar,
Link2Icon,
RocketIcon,
Users2Icon,
ArchiveIcon,
PaperclipIcon,
ContrastIcon,
TriangleIcon,
LayoutGridIcon,
SignalMediumIcon,
MessageSquareIcon,
} from "lucide-react";
import {
BlockedIcon,
BlockerIcon,
RelatedIcon,
StackedLayersHorizontalIcon,
} from "components/icons";
// helpers // helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper"; import { capitalizeFirstLetter } from "helpers/string.helper";
// types // types
import { IIssueActivity } from "types"; import { IIssueActivity } from "types";
// fetch-keys
import { WORKSPACE_LABELS } from "constants/fetch-keys";
const IssueLink = ({ activity }: { activity: IIssueActivity }) => { const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const router = useRouter(); const router = useRouter();
@ -30,7 +56,7 @@ const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
{activity.issue_detail {activity.issue_detail
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
: "Issue"} : "Issue"}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</Tooltip> </Tooltip>
); );
@ -52,6 +78,38 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => {
); );
}; };
const LabelPill = ({ labelId }: { labelId: string }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: labels } = useSWR(
workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null
);
return (
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: labels?.find((l) => l.id === labelId)?.color ?? "#000000",
}}
aria-hidden="true"
/>
);
};
const EstimatePoint = ({ point }: { point: string }) => {
const { estimateValue, isEstimateActive } = useEstimateOption(Number(point));
const currentPoint = Number(point) + 1;
return (
<span className="font-medium text-custom-text-100">
{isEstimateActive
? estimateValue
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
</span>
);
};
const activityDetails: { const activityDetails: {
[key: string]: { [key: string]: {
message: ( message: (
@ -91,14 +149,14 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="group" className="!text-2xl" aria-hidden="true" />, icon: <Users2Icon size={12} color="#6b7280" aria-hidden="true" />,
}, },
archived_at: { archived_at: {
message: (activity) => { message: (activity) => {
if (activity.new_value === "restore") return "restored the issue."; if (activity.new_value === "restore") return "restored the issue.";
else return "archived the issue."; else return "archived the issue.";
}, },
icon: <Icon iconName="archive" className="!text-2xl" aria-hidden="true" />, icon: <ArchiveIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
attachment: { attachment: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -113,7 +171,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
attachment attachment
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
{showIssue && ( {showIssue && (
<> <>
@ -137,7 +195,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="attach_file" className="!text-2xl" aria-hidden="true" />, icon: <PaperclipIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
blocking: { blocking: {
message: (activity) => { message: (activity) => {
@ -228,7 +286,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.new_value} {activity.new_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
@ -243,7 +301,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.new_value} {activity.new_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
@ -258,12 +316,12 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.old_value} {activity.old_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
}, },
icon: <Icon iconName="contrast" className="!text-2xl" aria-hidden="true" />, icon: <ContrastIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
description: { description: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -278,7 +336,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="chat" className="!text-2xl" aria-hidden="true" />, icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
estimate_point: { estimate_point: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -298,8 +356,7 @@ const activityDetails: {
else else
return ( return (
<> <>
set the estimate point to{" "} set the estimate point to <EstimatePoint point={activity.new_value} />
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && ( {showIssue && (
<> <>
{" "} {" "}
@ -310,14 +367,14 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="change_history" className="!text-2xl" aria-hidden="true" />, icon: <TriangleIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
issue: { issue: {
message: (activity) => { message: (activity) => {
if (activity.verb === "created") return "created the issue."; if (activity.verb === "created") return "created the issue.";
else return "deleted an issue."; else return "deleted an issue.";
}, },
icon: <Icon iconName="stack" className="!text-2xl" aria-hidden="true" />, icon: <StackedLayersHorizontalIcon width={12} height={12} color="#6b7280" aria-hidden="true" />,
}, },
labels: { labels: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -325,14 +382,8 @@ const activityDetails: {
return ( return (
<> <>
added a new label{" "} added a new label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs"> <span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span <LabelPill labelId={activity.new_identifier ?? ""} />
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="font-medium text-custom-text-100">{activity.new_value}</span> <span className="font-medium text-custom-text-100">{activity.new_value}</span>
</span> </span>
{showIssue && ( {showIssue && (
@ -348,13 +399,7 @@ const activityDetails: {
<> <>
removed the label{" "} removed the label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs"> <span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span <LabelPill labelId={activity.old_identifier ?? ""} />
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="font-medium text-custom-text-100">{activity.old_value}</span> <span className="font-medium text-custom-text-100">{activity.old_value}</span>
</span> </span>
{showIssue && ( {showIssue && (
@ -366,7 +411,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="sell" className="!text-2xl" aria-hidden="true" />, icon: <TagIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
link: { link: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -381,7 +426,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
link link
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
{showIssue && ( {showIssue && (
<> <>
@ -403,7 +448,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
link link
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
{showIssue && ( {showIssue && (
<> <>
@ -425,7 +470,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
link link
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
{showIssue && ( {showIssue && (
<> <>
@ -437,7 +482,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="link" className="!text-2xl" aria-hidden="true" />, icon: <Link2Icon size={12} color="#6b7280" aria-hidden="true" />,
}, },
modules: { modules: {
message: (activity, showIssue, workspaceSlug) => { message: (activity, showIssue, workspaceSlug) => {
@ -452,7 +497,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.new_value} {activity.new_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
@ -467,7 +512,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.new_value} {activity.new_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
@ -482,12 +527,12 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.old_value} {activity.old_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
}, },
icon: <Icon iconName="dataset" className="!text-2xl" aria-hidden="true" />, icon: <Icon iconName="dataset" className="!text-xs !text-[#6b7280]" aria-hidden="true" />,
}, },
name: { name: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -502,7 +547,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="chat" className="!text-2xl" aria-hidden="true" />, icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
parent: { parent: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -535,7 +580,13 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="supervised_user_circle" className="!text-2xl" aria-hidden="true" />, icon: (
<Icon
iconName="supervised_user_circle"
className="!text-xs !text-[#6b7280]"
aria-hidden="true"
/>
),
}, },
priority: { priority: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -553,7 +604,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="signal_cellular_alt" className="!text-2xl" aria-hidden="true" />, icon: <SignalMediumIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
start_date: { start_date: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -587,7 +638,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="calendar_today" className="!text-2xl" aria-hidden="true" />, icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
}, },
state: { state: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -603,7 +654,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Squares2X2Icon className="h-6 w-6 text-custom-sidebar-200" aria-hidden="true" />, icon: <LayoutGridIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
target_date: { target_date: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -637,7 +688,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="calendar_today" className="!text-2xl" aria-hidden="true" />, icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
}, },
}; };

View File

@ -167,7 +167,9 @@ export const FiltersList: React.FC<Props> = ({
className="cursor-pointer" className="cursor-pointer"
onClick={() => onClick={() =>
setFilters({ setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId), subscriber: filters.subscriber?.filter(
(p: any) => p !== memberId
),
}) })
} }
> >

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

@ -67,7 +67,7 @@ export const IssuesFilterView: React.FC = () => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query; const { workspaceSlug, projectId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues"); const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname.includes("draft-issues"); const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues";
const { const {
displayFilters, displayFilters,
@ -93,7 +93,9 @@ export const IssuesFilterView: React.FC = () => {
<Tooltip <Tooltip
key={option.type} key={option.type}
tooltipContent={ tooltipContent={
<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span> <span className="capitalize">
{replaceUnderscoreIfSnakeCase(option.type)} Layout
</span>
} }
position="bottom" position="bottom"
> >
@ -228,6 +230,9 @@ export const IssuesFilterView: React.FC = () => {
return null; return null;
if (option.key === "project") return null; if (option.key === "project") return null;
if (isDraftIssues && option.key === "state_detail.group")
return null;
return ( return (
<CustomMenu.MenuItem <CustomMenu.MenuItem
key={option.key} key={option.key}
@ -272,33 +277,35 @@ export const IssuesFilterView: React.FC = () => {
</div> </div>
</div> </div>
)} )}
<div className="flex items-center justify-between"> {!isArchivedIssues && (
<h4 className="text-custom-text-200">Issue type</h4> <div className="flex items-center justify-between">
<div className="w-28"> <h4 className="text-custom-text-200">Issue type</h4>
<CustomMenu <div className="w-28">
label={ <CustomMenu
FILTER_ISSUE_OPTIONS.find( label={
(option) => option.key === displayFilters.type FILTER_ISSUE_OPTIONS.find(
)?.name ?? "Select" (option) => option.key === displayFilters.type
} )?.name ?? "Select"
className="!w-full" }
buttonClassName="w-full" className="!w-full"
> buttonClassName="w-full"
{FILTER_ISSUE_OPTIONS.map((option) => ( >
<CustomMenu.MenuItem {FILTER_ISSUE_OPTIONS.map((option) => (
key={option.key} <CustomMenu.MenuItem
onClick={() => key={option.key}
setDisplayFilters({ onClick={() =>
type: option.key, setDisplayFilters({
}) type: option.key,
} })
> }
{option.name} >
</CustomMenu.MenuItem> {option.name}
))} </CustomMenu.MenuItem>
</CustomMenu> ))}
</CustomMenu>
</div>
</div> </div>
</div> )}
{displayFilters.layout !== "calendar" && {displayFilters.layout !== "calendar" &&
displayFilters.layout !== "spreadsheet" && ( displayFilters.layout !== "spreadsheet" && (
@ -318,7 +325,7 @@ export const IssuesFilterView: React.FC = () => {
displayFilters.layout !== "spreadsheet" && displayFilters.layout !== "spreadsheet" &&
displayFilters.layout !== "gantt_chart" && ( displayFilters.layout !== "gantt_chart" && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4> <h4 className="text-custom-text-200">Show empty groups</h4>
<div className="w-28"> <div className="w-28">
<ToggleSwitch <ToggleSwitch
value={displayFilters.show_empty_groups ?? true} value={displayFilters.show_empty_groups ?? true}

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

@ -12,6 +12,7 @@ import stateService from "services/state.service";
// hooks // hooks
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
import { useProjectMyMembership } from "contexts/project-member.context"; import { useProjectMyMembership } from "contexts/project-member.context";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// components // components
import { import {
AllLists, AllLists,
@ -86,6 +87,8 @@ export const AllViews: React.FC<Props> = ({
const { groupedIssues, isEmpty, displayFilters } = viewProps; const { groupedIssues, isEmpty, displayFilters } = viewProps;
const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
const { data: stateGroups } = useSWR( const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug workspaceSlug
@ -174,6 +177,8 @@ export const AllViews: React.FC<Props> = ({
) : displayFilters?.layout === "spreadsheet" ? ( ) : displayFilters?.layout === "spreadsheet" ? (
<SpreadsheetView <SpreadsheetView
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
spreadsheetIssues={spreadsheetIssues}
mutateIssues={mutateIssues}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null} openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
disableUserActions={disableUserActions} disableUserActions={disableUserActions}
user={user} user={user}

View File

@ -76,7 +76,7 @@ export const AllBoards: React.FC<Props> = ({
readOnly={disableUserActions} readOnly={disableUserActions}
/> />
{groupedIssues ? ( {groupedIssues ? (
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8"> <div className="horizontal-scroll-enable flex h-full gap-x-4 p-8 bg-custom-background-90">
{Object.keys(groupedIssues).map((singleGroup, index) => { {Object.keys(groupedIssues).map((singleGroup, index) => {
const currentState = const currentState =
displayFilters?.group_by === "state" displayFilters?.group_by === "state"

View File

@ -20,7 +20,7 @@ import { renderEmoji } from "helpers/emoji.helper";
// types // types
import { IIssueViewProps, IState, TIssuePriorities, TStateGroups } from "types"; import { IIssueViewProps, IState, TIssuePriorities, TStateGroups } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, WORKSPACE_LABELS } from "constants/fetch-keys";
// constants // constants
import { STATE_GROUP_COLORS } from "constants/state"; import { STATE_GROUP_COLORS } from "constants/state";
@ -59,6 +59,15 @@ export const BoardHeader: React.FC<Props> = ({
: null : null
); );
const { data: workspaceLabels } = useSWR(
workspaceSlug && displayFilters?.group_by === "labels"
? WORKSPACE_LABELS(workspaceSlug.toString())
: null,
workspaceSlug && displayFilters?.group_by === "labels"
? () => issuesService.getWorkspaceLabels(workspaceSlug.toString())
: null
);
const { data: members } = useSWR( const { data: members } = useSWR(
workspaceSlug && workspaceSlug &&
projectId && projectId &&
@ -82,7 +91,10 @@ export const BoardHeader: React.FC<Props> = ({
title = addSpaceIfCamelCase(currentState?.name ?? ""); title = addSpaceIfCamelCase(currentState?.name ?? "");
break; break;
case "labels": case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None"; title =
[...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.name ?? "None";
break; break;
case "project": case "project":
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None"; title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
@ -137,7 +149,9 @@ export const BoardHeader: React.FC<Props> = ({
break; break;
case "labels": case "labels":
const labelColor = const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000"; [...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.color ?? "#000000";
icon = ( icon = (
<span <span
className="h-3.5 w-3.5 flex-shrink-0 rounded-full" className="h-3.5 w-3.5 flex-shrink-0 rounded-full"

View File

@ -2,3 +2,4 @@ export * from "./all-boards";
export * from "./board-header"; export * from "./board-header";
export * from "./single-board"; export * from "./single-board";
export * from "./single-issue"; export * from "./single-issue";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,62 @@
import { useEffect } from "react";
// react hook form
import { useFormContext } from "react-hook-form";
// components
import { InlineCreateIssueFormWrapper } from "components/core";
// hooks
import useProjectDetails from "hooks/use-project-details";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<div>
<h4 className="text-sm font-medium leading-5 text-custom-text-300">
{projectDetails?.identifier ?? "..."}
</h4>
<input
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 pl-0 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
);
};
export const BoardInlineCreateIssueForm: React.FC<Props> = (props) => (
<>
<InlineCreateIssueFormWrapper
className="flex flex-col border-[0.5px] border-custom-border-100 justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px] mb-3 rounded bg-custom-background-100 shadow-custom-shadow-sm"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
{props.isOpen && (
<p className="text-xs ml-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
</>
);

View File

@ -6,7 +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 { BoardHeader, SingleBoardIssue } from "components/core"; import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core";
// ui // ui
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
// icons // icons
@ -34,31 +34,39 @@ type Props = {
viewProps: IIssueViewProps; viewProps: IIssueViewProps;
}; };
export const SingleBoard: React.FC<Props> = ({ export const SingleBoard: React.FC<Props> = (props) => {
addIssueToGroup, const {
currentState, addIssueToGroup,
groupTitle, currentState,
disableUserActions, groupTitle,
disableAddIssueOption = false, disableUserActions,
dragDisabled, disableAddIssueOption = false,
handleIssueAction, dragDisabled,
handleDraftIssueAction, handleIssueAction,
handleTrashBox, handleDraftIssueAction,
openIssuesListModal, handleTrashBox,
handleMyIssueOpen, openIssuesListModal,
removeIssue, handleMyIssueOpen,
user, removeIssue,
userAuth, user,
viewProps, userAuth,
}) => { viewProps,
} = props;
// collapse/expand // collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
const { displayFilters, groupedIssues } = viewProps; const { displayFilters, groupedIssues } = viewProps;
const router = useRouter(); const router = useRouter();
const { cycleId, moduleId } = router.query; const { cycleId, moduleId } = router.query;
const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues";
const isProfileIssuesPage = router.pathname.split("/")[2] === "profile";
const isDraftIssuesPage = router.pathname.split("/")[4] === "draft-issues";
const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
// Check if it has at least 4 tickets since it is enough to accommodate the Calendar height // Check if it has at least 4 tickets since it is enough to accommodate the Calendar height
@ -67,6 +75,27 @@ export const SingleBoard: React.FC<Props> = ({
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
const scrollToBottom = () => {
const boardListElement = document.getElementById(`board-list-${groupTitle}`);
// timeout is needed because the animation
// takes time to complete & we can scroll only after that
const timeoutId = setTimeout(() => {
if (boardListElement)
boardListElement.scrollBy({
top: boardListElement.scrollHeight,
left: 0,
behavior: "smooth",
});
clearTimeout(timeoutId);
}, 10);
};
const onCreateClick = () => {
setIsInlineCreateIssueFormOpen(true);
scrollToBottom();
};
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"}`}>
<BoardHeader <BoardHeader
@ -115,6 +144,7 @@ export const SingleBoard: React.FC<Props> = ({
</> </>
)} )}
<div <div
id={`board-list-${groupTitle}`}
className={`pt-3 ${ className={`pt-3 ${
hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : "" hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : ""
} `} } `}
@ -134,6 +164,7 @@ export const SingleBoard: React.FC<Props> = ({
type={type} type={type}
index={index} index={index}
issue={issue} issue={issue}
projectId={issue.project_detail.id}
groupTitle={groupTitle} groupTitle={groupTitle}
editIssue={() => handleIssueAction(issue, "edit")} editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")} makeIssueCopy={() => handleIssueAction(issue, "copy")}
@ -169,6 +200,20 @@ export const SingleBoard: React.FC<Props> = ({
> >
<>{provided.placeholder}</> <>{provided.placeholder}</>
</span> </span>
<BoardInlineCreateIssueForm
isOpen={isInlineCreateIssueFormOpen}
handleClose={() => setIsInlineCreateIssueFormOpen(false)}
onSuccess={() => scrollToBottom()}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by! === "labels"
? "labels_list"
: displayFilters?.group_by!]:
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
}}
/>
</div> </div>
{displayFilters?.group_by !== "created_by" && ( {displayFilters?.group_by !== "created_by" && (
<div> <div>
@ -177,7 +222,11 @@ export const SingleBoard: React.FC<Props> = ({
<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={addIssueToGroup} onClick={() => {
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
addIssueToGroup();
} else onCreateClick();
}}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Add Issue Add Issue
@ -197,7 +246,7 @@ export const SingleBoard: React.FC<Props> = ({
position="left" position="left"
noBorder noBorder
> >
<CustomMenu.MenuItem onClick={addIssueToGroup}> <CustomMenu.MenuItem onClick={() => onCreateClick()}>
Create new Create new
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{openIssuesListModal && ( {openIssuesListModal && (

View File

@ -56,6 +56,7 @@ type Props = {
provided: DraggableProvided; provided: DraggableProvided;
snapshot: DraggableStateSnapshot; snapshot: DraggableStateSnapshot;
issue: IIssue; issue: IIssue;
projectId: string;
groupTitle?: string; groupTitle?: string;
index: number; index: number;
editIssue: () => void; editIssue: () => void;
@ -77,6 +78,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
provided, provided,
snapshot, snapshot,
issue, issue,
projectId,
index, index,
editIssue, editIssue,
makeIssueCopy, makeIssueCopy,
@ -104,7 +106,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
const { displayFilters, properties, mutateIssues } = viewProps; const { displayFilters, properties, mutateIssues } = viewProps;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, cycleId, moduleId } = router.query;
const isDraftIssue = router.pathname.includes("draft-issues"); const isDraftIssue = router.pathname.includes("draft-issues");
@ -452,6 +454,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
<StateSelect <StateSelect
value={issue.state_detail} value={issue.state_detail}
onChange={handleStateChange} onChange={handleStateChange}
projectId={projectId}
hideDropdownArrow hideDropdownArrow
disabled={isNotAllowed} disabled={isNotAllowed}
/> />
@ -479,6 +482,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
{properties.labels && issue.labels.length > 0 && ( {properties.labels && issue.labels.length > 0 && (
<LabelSelect <LabelSelect
value={issue.labels} value={issue.labels}
projectId={projectId}
onChange={handleLabelChange} onChange={handleLabelChange}
labelsDetails={issue.label_details} labelsDetails={issue.label_details}
hideDropdownArrow hideDropdownArrow
@ -489,6 +493,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
{properties.assignee && ( {properties.assignee && (
<MembersSelect <MembersSelect
value={issue.assignees} value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange} onChange={handleAssigneeChange}
membersDetails={issue.assignee_details} membersDetails={issue.assignee_details}
hideDropdownArrow hideDropdownArrow

View File

@ -5,25 +5,12 @@ import { Popover, Transition } from "@headlessui/react";
// ui // ui
import { CustomMenu, ToggleSwitch } from "components/ui"; import { CustomMenu, ToggleSwitch } from "components/ui";
// icons // icons
import { import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "@heroicons/react/24/outline";
// helpers // helpers
import { import {
addMonths,
addSevenDaysToDate,
formatDate, formatDate,
getCurrentWeekEndDate,
getCurrentWeekStartDate,
isSameMonth, isSameMonth,
isSameYear, isSameYear,
lastDayOfWeek,
startOfWeek,
subtract7DaysToDate,
subtractMonths,
updateDateWithMonth, updateDateWithMonth,
updateDateWithYear, updateDateWithYear,
} from "helpers/calendar.helper"; } from "helpers/calendar.helper";
@ -31,190 +18,136 @@ import {
import { MONTHS_LIST, YEARS_LIST } from "constants/calendar"; import { MONTHS_LIST, YEARS_LIST } from "constants/calendar";
type Props = { type Props = {
isMonthlyView: boolean;
setIsMonthlyView: React.Dispatch<React.SetStateAction<boolean>>;
currentDate: Date; currentDate: Date;
setCurrentDate: React.Dispatch<React.SetStateAction<Date>>; setCurrentDate: React.Dispatch<React.SetStateAction<Date>>;
showWeekEnds: boolean; showWeekEnds: boolean;
setShowWeekEnds: React.Dispatch<React.SetStateAction<boolean>>; setShowWeekEnds: React.Dispatch<React.SetStateAction<boolean>>;
changeDateRange: (startDate: Date, endDate: Date) => void;
}; };
export const CalendarHeader: React.FC<Props> = ({ export const CalendarHeader: React.FC<Props> = ({
setIsMonthlyView,
isMonthlyView,
currentDate, currentDate,
setCurrentDate, setCurrentDate,
showWeekEnds, showWeekEnds,
setShowWeekEnds, setShowWeekEnds,
changeDateRange, }) => (
}) => { <div className="mb-4 flex items-center justify-between">
const updateDate = (date: Date) => { <div className="relative flex h-full w-full items-center justify-start gap-2 text-sm ">
setCurrentDate(date); <Popover className="flex h-full items-center justify-start rounded-lg">
{({ open }) => (
<>
<Popover.Button>
<div className="flex items-center justify-center gap-2 text-2xl font-semibold text-custom-text-100">
<span>{formatDate(currentDate, "Month")}</span>{" "}
<span>{formatDate(currentDate, "yyyy")}</span>
</div>
</Popover.Button>
changeDateRange(startOfWeek(date), lastDayOfWeek(date)); <Transition
}; as={React.Fragment}
enter="transition ease-out duration-200"
return ( enterFrom="opacity-0 translate-y-1"
<div className="mb-4 flex items-center justify-between"> enterTo="opacity-100 translate-y-0"
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm "> leave="transition ease-in duration-150"
<Popover className="flex h-full items-center justify-start rounded-lg"> leaveFrom="opacity-100 translate-y-0"
{({ open }) => ( leaveTo="opacity-0 translate-y-1"
<> >
<Popover.Button> <Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-custom-background-80 shadow-lg">
<div className="flex items-center justify-center gap-2 text-2xl font-semibold text-custom-text-100"> <div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
<span>{formatDate(currentDate, "Month")}</span>{" "} {YEARS_LIST.map((year) => (
<span>{formatDate(currentDate, "yyyy")}</span> <button
onClick={() => setCurrentDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-custom-text-100"
: "text-xs text-custom-text-200 "
} hover:text-sm hover:font-medium hover:text-custom-text-100`}
>
{year.label}
</button>
))}
</div> </div>
</Popover.Button> <div className="grid grid-cols-4 border-t border-custom-border-200 px-2">
{MONTHS_LIST.map((month) => (
<button
onClick={() =>
setCurrentDate(updateDateWithMonth(`${month.value}`, currentDate))
}
className={`px-2 py-2 text-xs text-custom-text-200 hover:font-medium hover:text-custom-text-100 ${
isSameMonth(`${month.value}`, currentDate)
? "font-medium text-custom-text-100"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<Transition <div className="flex items-center gap-2">
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-custom-background-80 shadow-lg">
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
{YEARS_LIST.map((year) => (
<button
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-custom-text-100"
: "text-xs text-custom-text-200 "
} hover:text-sm hover:font-medium hover:text-custom-text-100`}
>
{year.label}
</button>
))}
</div>
<div className="grid grid-cols-4 border-t border-custom-border-200 px-2">
{MONTHS_LIST.map((month) => (
<button
onClick={() =>
updateDate(updateDateWithMonth(`${month.value}`, currentDate))
}
className={`px-2 py-2 text-xs text-custom-text-200 hover:font-medium hover:text-custom-text-100 ${
isSameMonth(`${month.value}`, currentDate)
? "font-medium text-custom-text-100"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<div className="flex items-center gap-2">
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(subtractMonths(currentDate, 1));
} else {
setCurrentDate(subtract7DaysToDate(currentDate));
changeDateRange(
getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
getCurrentWeekEndDate(subtract7DaysToDate(currentDate))
);
}
}}
>
<ChevronLeftIcon className="h-4 w-4" />
</button>
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(addMonths(currentDate, 1));
} else {
setCurrentDate(addSevenDaysToDate(currentDate));
changeDateRange(
getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
getCurrentWeekEndDate(addSevenDaysToDate(currentDate))
);
}
}}
>
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex w-full items-center justify-end gap-2">
<button <button
className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none" className="cursor-pointer"
onClick={() => { onClick={() => {
if (isMonthlyView) { const previousMonthYear =
updateDate(new Date()); currentDate.getMonth() === 0
} else { ? currentDate.getFullYear() - 1
setCurrentDate(new Date()); : currentDate.getFullYear();
changeDateRange( const previousMonthMonth =
getCurrentWeekStartDate(new Date()), currentDate.getMonth() === 0 ? 11 : currentDate.getMonth() - 1;
getCurrentWeekEndDate(new Date())
); const previousMonthFirstDate = new Date(previousMonthYear, previousMonthMonth, 1);
}
setCurrentDate(previousMonthFirstDate);
}} }}
> >
Today <ChevronLeftIcon className="h-4 w-4" />
</button> </button>
<button
className="cursor-pointer"
onClick={() => {
const nextMonthYear =
currentDate.getMonth() === 11
? currentDate.getFullYear() + 1
: currentDate.getFullYear();
const nextMonthMonth = (currentDate.getMonth() + 1) % 12;
<CustomMenu const nextMonthFirstDate = new Date(nextMonthYear, nextMonthMonth, 1);
customButton={
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none "> setCurrentDate(nextMonthFirstDate);
{isMonthlyView ? "Monthly" : "Weekly"} }}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
> >
<CustomMenu.MenuItem <ChevronRightIcon className="h-4 w-4" />
onClick={() => { </button>
setIsMonthlyView(true);
changeDateRange(startOfWeek(currentDate), lastDayOfWeek(currentDate));
}}
className="w-52 text-sm text-custom-text-200"
>
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
<span className="flex items-center gap-2">Monthly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-100" : "opacity-0"}`}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(false);
changeDateRange(
getCurrentWeekStartDate(currentDate),
getCurrentWeekEndDate(currentDate)
);
}}
className="w-52 text-sm text-custom-text-200"
>
<div className="flex w-full items-center justify-between gap-2">
<span className="flex items-center gap-2">Weekly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-0" : "opacity-100"}`}
/>
</div>
</CustomMenu.MenuItem>
<div className="mt-1 flex w-52 items-center justify-between border-t border-custom-border-200 py-2 px-1 text-sm text-custom-text-200">
<h4>Show weekends</h4>
<ToggleSwitch value={showWeekEnds} onChange={() => setShowWeekEnds(!showWeekEnds)} />
</div>
</CustomMenu>
</div> </div>
</div> </div>
);
}; <div className="flex w-full items-center justify-end gap-2">
<button
className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none"
onClick={() => setCurrentDate(new Date())}
>
Today
</button>
<CustomMenu
customButton={
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none">
Options
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
>
<div className="flex w-52 items-center justify-between px-1 text-sm text-custom-text-200">
<h4>Show weekends</h4>
<ToggleSwitch value={showWeekEnds} onChange={() => setShowWeekEnds(!showWeekEnds)} />
</div>
</CustomMenu>
</div>
</div>
);
export default CalendarHeader; export default CalendarHeader;

View File

@ -1,10 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd"; import { DragDropContext, DropResult } from "react-beautiful-dnd";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
@ -50,31 +46,27 @@ export const CalendarView: React.FC<Props> = ({
userAuth, userAuth,
}) => { }) => {
const [showWeekEnds, setShowWeekEnds] = useState(false); const [showWeekEnds, setShowWeekEnds] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date());
const [isMonthlyView, setIsMonthlyView] = useState(true); const { calendarIssues, mutateIssues, params, activeMonthDate, setActiveMonthDate } =
useCalendarIssuesView();
const [calendarDates, setCalendarDates] = useState<ICalendarRange>({ const [calendarDates, setCalendarDates] = useState<ICalendarRange>({
startDate: startOfWeek(currentDate), startDate: startOfWeek(activeMonthDate),
endDate: lastDayOfWeek(currentDate), endDate: lastDayOfWeek(activeMonthDate),
}); });
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { calendarIssues, mutateIssues, params, displayFilters, setDisplayFilters } = const currentViewDays = showWeekEnds
useCalendarIssuesView(); ? eachDayOfInterval({
start: calendarDates.startDate,
const totalDate = eachDayOfInterval({ end: calendarDates.endDate,
start: calendarDates.startDate, })
end: calendarDates.endDate, : weekDayInterval({
}); start: calendarDates.startDate,
end: calendarDates.endDate,
const onlyWeekDays = weekDayInterval({ });
start: calendarDates.startDate,
end: calendarDates.endDate,
});
const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays;
const currentViewDaysData = currentViewDays.map((date: Date) => { const currentViewDaysData = currentViewDays.map((date: Date) => {
const filterIssue = const filterIssue =
@ -148,27 +140,12 @@ export const CalendarView: React.FC<Props> = ({
.then(() => mutate(fetchKey)); .then(() => mutate(fetchKey));
}; };
const changeDateRange = (startDate: Date, endDate: Date) => {
setCalendarDates({
startDate,
endDate,
});
setDisplayFilters({
calendar_date_range: `${renderDateFormat(startDate)};after,${renderDateFormat(
endDate
)};before`,
});
};
useEffect(() => { useEffect(() => {
if (!displayFilters || displayFilters.calendar_date_range === "") setCalendarDates({
setDisplayFilters({ startDate: startOfWeek(activeMonthDate),
calendar_date_range: `${renderDateFormat( endDate: lastDayOfWeek(activeMonthDate),
startOfWeek(currentDate) });
)};after,${renderDateFormat(lastDayOfWeek(currentDate))};before`, }, [activeMonthDate]);
});
}, [currentDate, displayFilters, setDisplayFilters]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
@ -183,15 +160,15 @@ export const CalendarView: React.FC<Props> = ({
{calendarIssues ? ( {calendarIssues ? (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<div className="h-full rounded-lg p-8 text-custom-text-200"> <div
id={`calendar-view-${cycleId ?? moduleId ?? viewId ?? ""}`}
className="h-full rounded-lg p-8 text-custom-text-200"
>
<CalendarHeader <CalendarHeader
isMonthlyView={isMonthlyView}
setIsMonthlyView={setIsMonthlyView}
showWeekEnds={showWeekEnds} showWeekEnds={showWeekEnds}
setShowWeekEnds={setShowWeekEnds} setShowWeekEnds={setShowWeekEnds}
currentDate={currentDate} currentDate={activeMonthDate}
setCurrentDate={setCurrentDate} setCurrentDate={setActiveMonthDate}
changeDateRange={changeDateRange}
/> />
<div <div
@ -202,30 +179,15 @@ export const CalendarView: React.FC<Props> = ({
{weeks.map((date, index) => ( {weeks.map((date, index) => (
<div <div
key={index} key={index}
className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200 ${ className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200`}
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`}
> >
<span> <span>{formatDate(date, "eee").substring(0, 3)}</span>
{isMonthlyView
? formatDate(date, "eee").substring(0, 3)
: formatDate(date, "eee")}
</span>
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
</div> </div>
))} ))}
</div> </div>
<div <div
className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${ className={`grid h-full auto-rows-min ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5" showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `} } `}
> >
@ -236,7 +198,6 @@ export const CalendarView: React.FC<Props> = ({
date={date} date={date}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate} addIssueToDate={addIssueToDate}
isMonthlyView={isMonthlyView}
showWeekEnds={showWeekEnds} showWeekEnds={showWeekEnds}
user={user} user={user}
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}

View File

@ -2,3 +2,4 @@ export * from "./calendar-header";
export * from "./calendar"; export * from "./calendar";
export * from "./single-date"; export * from "./single-date";
export * from "./single-issue"; export * from "./single-issue";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from "react";
// next
import { useRouter } from "next/router";
// react hook form
import { useFormContext } from "react-hook-form";
import { InlineCreateIssueFormWrapper } from "components/core";
// hooks
import useProjectDetails from "hooks/use-project-details";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
dependencies: any[];
};
const useCheckIfThereIsSpaceOnRight = (ref: React.RefObject<HTMLDivElement>, deps: any[]) => {
const [isThereSpaceOnRight, setIsThereSpaceOnRight] = useState(true);
const router = useRouter();
const { moduleId, cycleId, viewId } = router.query;
const container = document.getElementById(`calendar-view-${cycleId ?? moduleId ?? viewId}`);
useEffect(() => {
if (!ref.current) return;
const { right } = ref.current.getBoundingClientRect();
const width = right;
const innerWidth = container?.getBoundingClientRect().width ?? window.innerWidth;
if (width > innerWidth) setIsThereSpaceOnRight(false);
else setIsThereSpaceOnRight(true);
}, [ref, deps, container]);
return isThereSpaceOnRight;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<h4 className="text-sm font-medium leading-5 text-custom-text-400">
{projectDetails?.identifier ?? "..."}
</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full pr-2 py-2.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const CalendarInlineCreateIssueForm: React.FC<Props> = (props) => {
const { isOpen, dependencies } = props;
const ref = useRef<HTMLDivElement>(null);
const isSpaceOnRight = useCheckIfThereIsSpaceOnRight(ref, dependencies);
return (
<>
<div
ref={ref}
className={`absolute top-10 transition-all z-20 w-full max-w-[calc(100%-1.25rem)] ${
isOpen ? "opacity-100 scale-100" : "opacity-0 pointer-events-none scale-95"
} right-2.5`}
>
<InlineCreateIssueFormWrapper
{...props}
className="flex w-full px-1.5 border-[0.5px] border-custom-border-100 rounded z-50 items-center gap-x-2 bg-custom-background-100 shadow-custom-shadow-sm transition-opacity"
>
<InlineInput />
</InlineCreateIssueFormWrapper>
</div>
{/* Added to make any other element as outside click. This will make input also to be outside. */}
{isOpen && <div className="w-screen h-screen fixed inset-0 z-10" />}
</>
);
};

View File

@ -1,10 +1,14 @@
import React, { useState } from "react"; import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// react-beautiful-dnd // react-beautiful-dnd
import { Draggable } from "react-beautiful-dnd"; import { Draggable } from "react-beautiful-dnd";
// component // component
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { SingleCalendarIssue } from "./single-issue"; import { SingleCalendarIssue } from "./single-issue";
import { CalendarInlineCreateIssueForm } from "./inline-create-issue-form";
// icons // icons
import { PlusSmallIcon } from "@heroicons/react/24/outline"; import { PlusSmallIcon } from "@heroicons/react/24/outline";
// helper // helper
@ -20,23 +24,21 @@ type Props = {
issues: IIssue[]; issues: IIssue[];
}; };
addIssueToDate: (date: string) => void; addIssueToDate: (date: string) => void;
isMonthlyView: boolean;
showWeekEnds: boolean; showWeekEnds: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
isNotAllowed: boolean; isNotAllowed: boolean;
}; };
export const SingleCalendarDate: React.FC<Props> = ({ export const SingleCalendarDate: React.FC<Props> = (props) => {
handleIssueAction, const { handleIssueAction, date, index, showWeekEnds, user, isNotAllowed } = props;
date,
index, const router = useRouter();
addIssueToDate, const { cycleId, moduleId } = router.query;
isMonthlyView,
showWeekEnds,
user,
isNotAllowed,
}) => {
const [showAllIssues, setShowAllIssues] = useState(false); const [showAllIssues, setShowAllIssues] = useState(false);
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
const [formPosition, setFormPosition] = useState({ x: 0, y: 0 });
const totalIssues = date.issues.length; const totalIssues = date.issues.length;
@ -48,8 +50,6 @@ export const SingleCalendarDate: React.FC<Props> = ({
ref={provided.innerRef} ref={provided.innerRef}
{...provided.droppableProps} {...provided.droppableProps}
className={`group relative flex min-h-[150px] flex-col gap-1.5 border-t border-custom-border-200 p-2.5 text-left text-sm font-medium hover:bg-custom-background-90 ${ className={`group relative flex min-h-[150px] flex-col gap-1.5 border-t border-custom-border-200 p-2.5 text-left text-sm font-medium hover:bg-custom-background-90 ${
isMonthlyView ? "" : "pt-9"
} ${
showWeekEnds showWeekEnds
? (index + 1) % 7 === 0 ? (index + 1) % 7 === 0
? "" ? ""
@ -59,48 +59,66 @@ export const SingleCalendarDate: React.FC<Props> = ({
: "border-r" : "border-r"
}`} }`}
> >
{isMonthlyView && <span>{formatDate(new Date(date.date), "d")}</span>} <>
{totalIssues > 0 && <span>{formatDate(new Date(date.date), "d")}</span>
date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => ( {totalIssues > 0 &&
<Draggable key={issue.id} draggableId={issue.id} index={index}> date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => (
{(provided, snapshot) => ( <Draggable key={issue.id} draggableId={issue.id} index={index}>
<SingleCalendarIssue {(provided, snapshot) => (
key={index} <SingleCalendarIssue
index={index} key={index}
provided={provided} index={index}
snapshot={snapshot} provided={provided}
issue={issue} snapshot={snapshot}
handleEditIssue={() => handleIssueAction(issue, "edit")} issue={issue}
handleDeleteIssue={() => handleIssueAction(issue, "delete")} projectId={issue.project_detail.id}
user={user} handleEditIssue={() => handleIssueAction(issue, "edit")}
isNotAllowed={isNotAllowed} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
/> user={user}
)} isNotAllowed={isNotAllowed}
</Draggable> />
))} )}
{totalIssues > 4 && ( </Draggable>
<button ))}
type="button"
className="w-min whitespace-nowrap rounded-md border border-custom-border-200 bg-custom-background-80 px-1.5 py-1 text-xs"
onClick={() => setShowAllIssues((prevData) => !prevData)}
>
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
</button>
)}
<div <CalendarInlineCreateIssueForm
className={`absolute top-2 right-2 flex items-center justify-center rounded-md bg-custom-background-80 p-1 text-xs text-custom-text-200 opacity-0 group-hover:opacity-100`} isOpen={isCreateIssueFormOpen}
> dependencies={[showWeekEnds]}
<button handleClose={() => setIsCreateIssueFormOpen(false)}
className="flex items-center justify-center gap-1 text-center" prePopulatedData={{
onClick={() => addIssueToDate(date.date)} target_date: date.date,
> ...(cycleId && { cycle: cycleId.toString() }),
<PlusSmallIcon className="h-4 w-4 text-custom-text-200" /> ...(moduleId && { module: moduleId.toString() }),
Add issue }}
</button> />
</div>
{provided.placeholder} {totalIssues > 4 && (
<button
type="button"
className="w-min whitespace-nowrap rounded-md border border-custom-border-200 bg-custom-background-80 px-1.5 py-1 text-xs"
onClick={() => setShowAllIssues((prevData) => !prevData)}
>
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
</button>
)}
<div
className={`absolute top-2 right-2 flex items-center justify-center rounded-md bg-custom-background-80 p-1 text-xs text-custom-text-200 opacity-0 group-hover:opacity-100`}
>
<button
onClick={(e) => {
setIsCreateIssueFormOpen(true);
setFormPosition({ x: e.clientX, y: e.clientY });
}}
className="flex items-center justify-center gap-1 text-center"
>
<PlusSmallIcon className="h-4 w-4 text-custom-text-200" />
Add issue
</button>
</div>
{provided.placeholder}
</>
</div> </div>
)} )}
</StrictModeDroppable> </StrictModeDroppable>

View File

@ -41,6 +41,7 @@ type Props = {
provided: DraggableProvided; provided: DraggableProvided;
snapshot: DraggableStateSnapshot; snapshot: DraggableStateSnapshot;
issue: IIssue; issue: IIssue;
projectId: string;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
isNotAllowed: boolean; isNotAllowed: boolean;
}; };
@ -52,11 +53,12 @@ export const SingleCalendarIssue: React.FC<Props> = ({
provided, provided,
snapshot, snapshot,
issue, issue,
projectId,
user, user,
isNotAllowed, isNotAllowed,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { workspaceSlug, cycleId, moduleId, viewId } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -310,6 +312,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
{properties.state && ( {properties.state && (
<StateSelect <StateSelect
value={issue.state_detail} value={issue.state_detail}
projectId={projectId}
onChange={handleStateChange} onChange={handleStateChange}
hideDropdownArrow hideDropdownArrow
disabled={isNotAllowed} disabled={isNotAllowed}
@ -334,6 +337,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
{properties.labels && issue.labels.length > 0 && ( {properties.labels && issue.labels.length > 0 && (
<LabelSelect <LabelSelect
value={issue.labels} value={issue.labels}
projectId={projectId}
onChange={handleLabelChange} onChange={handleLabelChange}
labelsDetails={issue.label_details} labelsDetails={issue.label_details}
hideDropdownArrow hideDropdownArrow
@ -345,6 +349,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
{properties.assignee && ( {properties.assignee && (
<MembersSelect <MembersSelect
value={issue.assignees} value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange} onChange={handleAssigneeChange}
membersDetails={issue.assignee_details} membersDetails={issue.assignee_details}
hideDropdownArrow hideDropdownArrow

View File

@ -0,0 +1,62 @@
import { useEffect } from "react";
// react hook form
import { useFormContext } from "react-hook-form";
// hooks
import useProjectDetails from "hooks/use-project-details";
// components
import { InlineCreateIssueFormWrapper } from "components/core";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<div className="w-[14px] h-[14px] rounded-full border border-custom-border-1000 flex-shrink-0" />
<h4 className="text-sm text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const GanttInlineCreateIssueForm: React.FC<Props> = (props) => (
<>
<InlineCreateIssueFormWrapper
className="flex py-3 px-4 border-[0.5px] border-custom-border-100 mr-2.5 items-center rounded gap-x-2 bg-custom-background-100 shadow-custom-shadow-sm"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
{props.isOpen && (
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
</>
);

View File

@ -5,3 +5,4 @@ export * from "./list-view";
export * from "./spreadsheet-view"; export * from "./spreadsheet-view";
export * from "./all-views"; export * from "./all-views";
export * from "./issues-view"; export * from "./issues-view";
export * from "./inline-issue-create-wrapper";

View File

@ -0,0 +1,273 @@
import { useEffect, useRef } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// react hook form
import { useForm, FormProvider } from "react-hook-form";
// headless ui
import { Transition } from "@headlessui/react";
// services
import modulesService from "services/modules.service";
import issuesService from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
import useKeypress from "hooks/use-keypress";
import useIssuesView from "hooks/use-issues-view";
import useMyIssues from "hooks/my-issues/use-my-issues";
import useGanttChartIssues from "hooks/gantt-chart/issue-view";
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// helpers
import { getFetchKeysForIssueMutation } from "helpers/string.helper";
// fetch-keys
import {
USER_ISSUE,
SUB_ISSUES,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS,
CYCLE_DETAILS,
MODULE_DETAILS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
} from "constants/fetch-keys";
// types
import { IIssue } from "types";
const defaultValues: Partial<IIssue> = {
name: "",
};
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
className?: string;
children?: React.ReactNode;
};
export const addIssueToCycle = async (
workspaceSlug: string,
projectId: string,
issueId: string,
cycleId: string,
user: any,
params: any
) => {
if (!workspaceSlug || !projectId) return;
await issuesService
.addIssueToCycle(
workspaceSlug as string,
projectId.toString(),
cycleId,
{
issues: [issueId],
},
user
)
.then(() => {
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId, params));
mutate(CYCLE_DETAILS(cycleId as string));
}
});
};
export const addIssueToModule = async (
workspaceSlug: string,
projectId: string,
issueId: string,
moduleId: string,
user: any,
params: any
) => {
await modulesService
.addIssuesToModule(
workspaceSlug as string,
projectId.toString(),
moduleId as string,
{
issues: [issueId],
},
user
)
.then(() => {
if (moduleId) {
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
mutate(MODULE_DETAILS(moduleId as string));
}
});
};
export const InlineCreateIssueFormWrapper: React.FC<Props> = (props) => {
const { isOpen, handleClose, onSuccess, prePopulatedData, children, className } = props;
const ref = useRef<HTMLFormElement>(null);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues";
const { user } = useUser();
const { setToastAlert } = useToast();
const { displayFilters, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView();
const { ...viewGanttParams } = params;
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { params: ganttParams } = useGanttChartIssues(
workspaceSlug?.toString(),
projectId?.toString()
);
const method = useForm<IIssue>({ defaultValues });
const {
reset,
handleSubmit,
getValues,
formState: { errors, isSubmitting },
} = method;
useOutsideClickDetector(ref, handleClose);
useKeypress("Escape", handleClose);
useEffect(() => {
const values = getValues();
if (prePopulatedData) reset({ ...defaultValues, ...values, ...prePopulatedData });
}, [reset, prePopulatedData, getValues]);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
useEffect(() => {
if (!errors) return;
Object.keys(errors).forEach((key) => {
const error = errors[key as keyof IIssue];
setToastAlert({
type: "error",
title: "Error!",
message: error?.message?.toString() || "Some error occurred. Please try again.",
});
});
}, [errors, setToastAlert]);
const { calendarFetchKey, ganttFetchKey, spreadsheetFetchKey } = getFetchKeysForIssueMutation({
cycleId: cycleId,
moduleId: moduleId,
viewId: viewId,
projectId: projectId?.toString() ?? "",
calendarParams,
spreadsheetParams,
viewGanttParams,
ganttParams,
});
const onSubmitHandler = async (formData: IIssue) => {
if (!workspaceSlug || !projectId || !user || isSubmitting) return;
reset({ ...defaultValues });
await (!isDraftIssues
? issuesService.createIssues(workspaceSlug.toString(), projectId.toString(), formData, user)
: issuesService.createDraftIssue(
workspaceSlug.toString(),
projectId.toString(),
formData,
user
)
)
.then(async (res) => {
await mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params));
if (formData.cycle && formData.cycle !== "")
await addIssueToCycle(
workspaceSlug.toString(),
projectId.toString(),
res.id,
formData.cycle,
user,
params
);
if (formData.module && formData.module !== "")
await addIssueToModule(
workspaceSlug.toString(),
projectId.toString(),
res.id,
formData.module,
user,
params
);
if (isDraftIssues)
await mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(projectId.toString() ?? "", params));
if (displayFilters.layout === "calendar") await mutate(calendarFetchKey);
if (displayFilters.layout === "gantt_chart") await mutate(ganttFetchKey);
if (displayFilters.layout === "spreadsheet") await mutate(spreadsheetFetchKey);
if (groupedIssues) await mutateMyIssues();
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
if (onSuccess) await onSuccess(res);
if (formData.assignees_list?.some((assignee) => assignee === user?.id))
mutate(USER_ISSUE(workspaceSlug as string));
if (formData.parent && formData.parent !== "") mutate(SUB_ISSUES(formData.parent));
})
.catch((err) => {
Object.keys(err || {}).forEach((key) => {
const error = err?.[key];
const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null;
setToastAlert({
type: "error",
title: "Error!",
message: errorTitle || "Some error occurred. Please try again.",
});
});
});
};
return (
<>
<Transition
show={isOpen}
enter="transition ease-in-out duration-200 transform"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-200 transform"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<FormProvider {...method}>
<form ref={ref} className={className} onSubmit={handleSubmit(onSubmitHandler)}>
{children}
</form>
</FormProvider>
</Transition>
</>
);
};

View File

@ -81,7 +81,9 @@ export const IssuesView: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const isDraftIssues = router.asPath.includes("draft-issues");
const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues";
const isArchivedIssues = router.pathname?.split("/")?.[4] === "archived-issues";
const { user } = useUserAuth(); const { user } = useUserAuth();
@ -624,6 +626,7 @@ export const IssuesView: React.FC<Props> = ({
params, params,
properties, properties,
}} }}
disableAddIssueOption={isArchivedIssues}
/> />
</> </>
); );

View File

@ -1,3 +1,4 @@
export * from "./all-lists"; export * from "./all-lists";
export * from "./single-issue"; export * from "./single-issue";
export * from "./single-list"; export * from "./single-list";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,62 @@
import { useEffect } from "react";
// react hook form
import { useFormContext } from "react-hook-form";
// hooks
import useProjectDetails from "hooks/use-project-details";
// components
import { InlineCreateIssueFormWrapper } from "../inline-issue-create-wrapper";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<h4 className="text-sm font-medium leading-5 text-custom-text-400">
{projectDetails?.identifier ?? "..."}
</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 py-3 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const ListInlineCreateIssueForm: React.FC<Props> = (props) => (
<>
<InlineCreateIssueFormWrapper
className="flex border-[0.5px] border-t-0 border-custom-border-100 px-4 items-center gap-x-5 bg-custom-background-100 shadow-custom-shadow-sm z-10"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
{props.isOpen && (
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
</>
);

View File

@ -51,6 +51,7 @@ import {
type Props = { type Props = {
type?: string; type?: string;
issue: IIssue; issue: IIssue;
projectId: string;
groupTitle?: string; groupTitle?: string;
editIssue: () => void; editIssue: () => void;
index: number; index: number;
@ -69,6 +70,7 @@ type Props = {
export const SingleListIssue: React.FC<Props> = ({ export const SingleListIssue: React.FC<Props> = ({
type, type,
issue, issue,
projectId,
editIssue, editIssue,
index, index,
makeIssueCopy, makeIssueCopy,
@ -88,7 +90,7 @@ export const SingleListIssue: React.FC<Props> = ({
const [contextMenuPosition, setContextMenuPosition] = useState<React.MouseEvent | null>(null); const [contextMenuPosition, setContextMenuPosition] = useState<React.MouseEvent | null>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, userId } = router.query; const { workspaceSlug, cycleId, moduleId, userId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues"); const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues"; const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues";
@ -326,7 +328,7 @@ export const SingleListIssue: React.FC<Props> = ({
</ContextMenu> </ContextMenu>
<div <div
className="flex items-center justify-between px-4 py-2.5 gap-10 border-b border-custom-border-200 bg-custom-background-100 last:border-b-0" className="flex items-center justify-between px-4 py-2.5 gap-10 border-b-[0.5px] border-custom-border-100 bg-custom-background-100 last:border-b-0"
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
setContextMenu(true); setContextMenu(true);
@ -350,6 +352,7 @@ export const SingleListIssue: React.FC<Props> = ({
type="button" type="button"
className="truncate text-[0.825rem] text-custom-text-100" className="truncate text-[0.825rem] text-custom-text-100"
onClick={() => { onClick={() => {
if (isArchivedIssues) return router.push(issuePath);
if (!isDraftIssues) openPeekOverview(issue); if (!isDraftIssues) openPeekOverview(issue);
if (isDraftIssues && handleDraftIssueSelect) handleDraftIssueSelect(issue); if (isDraftIssues && handleDraftIssueSelect) handleDraftIssueSelect(issue);
}} }}
@ -376,6 +379,7 @@ export const SingleListIssue: React.FC<Props> = ({
{properties.state && ( {properties.state && (
<StateSelect <StateSelect
value={issue.state_detail} value={issue.state_detail}
projectId={projectId}
onChange={handleStateChange} onChange={handleStateChange}
hideDropdownArrow hideDropdownArrow
disabled={isNotAllowed} disabled={isNotAllowed}
@ -400,6 +404,7 @@ export const SingleListIssue: React.FC<Props> = ({
{properties.labels && ( {properties.labels && (
<LabelSelect <LabelSelect
value={issue.labels} value={issue.labels}
projectId={projectId}
onChange={handleLabelChange} onChange={handleLabelChange}
labelsDetails={issue.label_details} labelsDetails={issue.label_details}
hideDropdownArrow hideDropdownArrow
@ -411,6 +416,7 @@ export const SingleListIssue: React.FC<Props> = ({
{properties.assignee && ( {properties.assignee && (
<MembersSelect <MembersSelect
value={issue.assignees} value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange} onChange={handleAssigneeChange}
membersDetails={issue.assignee_details} membersDetails={issue.assignee_details}
hideDropdownArrow hideDropdownArrow

View File

@ -1,3 +1,6 @@
import { useState } from "react";
// next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
@ -10,7 +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 { SingleListIssue } from "components/core"; import { SingleListIssue, ListInlineCreateIssueForm } from "components/core";
// ui // ui
import { Avatar, CustomMenu } from "components/ui"; import { Avatar, CustomMenu } from "components/ui";
// icons // icons
@ -31,7 +34,7 @@ import {
UserAuth, UserAuth,
} from "types"; } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, WORKSPACE_LABELS } from "constants/fetch-keys";
// constants // constants
import { STATE_GROUP_COLORS } from "constants/state"; import { STATE_GROUP_COLORS } from "constants/state";
@ -51,40 +54,65 @@ type Props = {
viewProps: IIssueViewProps; viewProps: IIssueViewProps;
}; };
export const SingleList: React.FC<Props> = ({ export const SingleList: React.FC<Props> = (props) => {
currentState, const {
groupTitle, currentState,
addIssueToGroup, groupTitle,
handleIssueAction, handleIssueAction,
openIssuesListModal, openIssuesListModal,
handleDraftIssueAction, handleDraftIssueAction,
handleMyIssueOpen, handleMyIssueOpen,
removeIssue, addIssueToGroup,
disableUserActions, removeIssue,
disableAddIssueOption = false, disableUserActions,
user, disableAddIssueOption = false,
userAuth, user,
viewProps, userAuth,
}) => { viewProps,
} = props;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues";
const isProfileIssuesPage = router.pathname.split("/")[2] === "profile";
const isDraftIssuesPage = router.pathname.split("/")[4] === "draft-issues";
const isArchivedIssues = router.pathname.includes("archived-issues"); const isArchivedIssues = router.pathname.includes("archived-issues");
const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
const { displayFilters, groupedIssues } = viewProps; const { displayFilters, groupedIssues } = viewProps;
const { data: issueLabels } = useSWR<IIssueLabels[]>( const { data: issueLabels } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, workspaceSlug && projectId && displayFilters?.group_by === "labels"
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId.toString())
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId && displayFilters?.group_by === "labels"
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
: null
);
const { data: workspaceLabels } = useSWR(
workspaceSlug && displayFilters?.group_by === "labels"
? WORKSPACE_LABELS(workspaceSlug.toString())
: null,
workspaceSlug && displayFilters?.group_by === "labels"
? () => issuesService.getWorkspaceLabels(workspaceSlug.toString())
: null : null
); );
const { data: members } = useSWR( const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, workspaceSlug &&
workspaceSlug && projectId projectId &&
(displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees")
? PROJECT_MEMBERS(projectId as string)
: null,
workspaceSlug &&
projectId &&
(displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees")
? () => projectService.projectMembers(workspaceSlug as string, projectId as string) ? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null : null
); );
@ -99,7 +127,10 @@ export const SingleList: React.FC<Props> = ({
title = addSpaceIfCamelCase(currentState?.name ?? ""); title = addSpaceIfCamelCase(currentState?.name ?? "");
break; break;
case "labels": case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None"; title =
[...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.name ?? "None";
break; break;
case "project": case "project":
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None"; title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
@ -153,7 +184,9 @@ export const SingleList: React.FC<Props> = ({
break; break;
case "labels": case "labels":
const labelColor = const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000"; [...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.color ?? "#000000";
icon = ( icon = (
<span <span
className="h-3 w-3 flex-shrink-0 rounded-full" className="h-3 w-3 flex-shrink-0 rounded-full"
@ -206,8 +239,12 @@ export const SingleList: React.FC<Props> = ({
!disableAddIssueOption && ( !disableAddIssueOption && (
<button <button
type="button" type="button"
className="p-1 text-custom-text-200 hover:bg-custom-background-80" className="p-1 text-custom-text-200 hover:bg-custom-background-80"
onClick={addIssueToGroup} onClick={() => {
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
addIssueToGroup();
} else setIsCreateIssueFormOpen(true);
}}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
@ -224,7 +261,9 @@ export const SingleList: React.FC<Props> = ({
position="right" position="right"
noBorder noBorder
> >
<CustomMenu.MenuItem onClick={addIssueToGroup}>Create new</CustomMenu.MenuItem> <CustomMenu.MenuItem onClick={() => setIsCreateIssueFormOpen(true)}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && ( {openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}> <CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue Add an existing issue
@ -250,6 +289,7 @@ export const SingleList: React.FC<Props> = ({
key={issue.id} key={issue.id}
type={type} type={type}
issue={issue} issue={issue}
projectId={issue.project_detail.id}
groupTitle={groupTitle} groupTitle={groupTitle}
index={index} index={index}
editIssue={() => handleIssueAction(issue, "edit")} editIssue={() => handleIssueAction(issue, "edit")}
@ -284,6 +324,34 @@ export const SingleList: React.FC<Props> = ({
) : ( ) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div> <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!]: groupTitle,
}}
/>
{!disableAddIssueOption && !isCreateIssueFormOpen && (
// TODO: add border here
<div className="w-full bg-custom-background-100 px-6 py-3 border-b border-custom-border-100">
<button
type="button"
onClick={() => {
if (isDraftIssuesPage || 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> </Disclosure.Panel>
</Transition> </Transition>
</div> </div>

View File

@ -0,0 +1,72 @@
import React from "react";
import { useRouter } from "next/router";
// components
import { MembersSelect } from "components/project";
// services
import trackEventServices from "services/track-event.service";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const AssigneeColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const handleAssigneeChange = (data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data);
partialUpdateIssue({ assignees_list: data }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
user
);
};
return (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-100">
{properties.assignee && (
<MembersSelect
value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange}
membersDetails={issue.assignee_details}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
</span>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-assignee-column";
export * from "./assignee-column";

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { AssigneeColumn } from "components/core";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetAssigneeColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
expandedIssues,
properties,
user,
isNotAllowed,
}) => {
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
return (
<div>
<AssigneeColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetAssigneeColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -0,0 +1,34 @@
import React from "react";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
// helper
import { renderLongDetailDateFormat } from "helpers/date-time.helper";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const CreatedOnColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-100">
{properties.created_on && (
<div className="flex items-center text-xs cursor-default text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
{renderLongDetailDateFormat(issue.created_at)}
</div>
)}
</span>
</div>
);

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-created-on-column";
export * from "./created-on-column";

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { CreatedOnColumn } from "components/core";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetCreatedOnColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
expandedIssues,
properties,
user,
isNotAllowed,
}) => {
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
return (
<div>
<CreatedOnColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetCreatedOnColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from "react";
// components
import { ViewDueDateSelect } from "components/issues";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const DueDateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-100">
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</span>
</div>
);

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-due-date-column";
export * from "./due-date-column";

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { DueDateColumn } from "components/core";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetDueDateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
expandedIssues,
properties,
user,
isNotAllowed,
}) => {
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
return (
<div>
<DueDateColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetDueDateColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from "react";
// components
import { ViewEstimateSelect } from "components/issues";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const EstimateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-100">
{properties.estimate && (
<ViewEstimateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</span>
</div>
);

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-estimate-column";
export * from "./estimate-column";

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { EstimateColumn } from "components/core";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetEstimateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
expandedIssues,
properties,
user,
isNotAllowed,
}) => {
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
return (
<div>
<EstimateColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetEstimateColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -1,4 +1,13 @@
export * from "./assignee-column";
export * from "./created-on-column";
export * from "./due-date-column";
export * from "./estimate-column";
export * from "./issue-column";
export * from "./label-column";
export * from "./priority-column";
export * from "./start-date-column";
export * from "./state-column";
export * from "./updated-on-column";
export * from "./spreadsheet-view"; export * from "./spreadsheet-view";
export * from "./single-issue"; export * from "./issue-column/issue-column";
export * from "./spreadsheet-columns"; export * from "./issue-column/spreadsheet-issue-column";
export * from "./spreadsheet-issues";

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-issue-column";
export * from "./issue-column";

View File

@ -0,0 +1,180 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
// components
import { Popover2 } from "@blueprintjs/popover2";
// icons
import { Icon } from "components/ui";
import {
EllipsisHorizontalIcon,
LinkIcon,
PencilIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// hooks
import useToast from "hooks/use-toast";
// types
import { IIssue, Properties, UserAuth } from "types";
// helper
import { copyTextToClipboard } from "helpers/string.helper";
type Props = {
issue: IIssue;
projectId: string;
expanded: boolean;
handleToggleExpand: (issueId: string) => void;
properties: Properties;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
setCurrentProjectId: React.Dispatch<React.SetStateAction<string | null>>;
disableUserActions: boolean;
userAuth: UserAuth;
nestingLevel: number;
};
export const IssueColumn: React.FC<Props> = ({
issue,
projectId,
expanded,
handleToggleExpand,
properties,
handleEditIssue,
handleDeleteIssue,
setCurrentProjectId,
disableUserActions,
userAuth,
nestingLevel,
}) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const openPeekOverview = () => {
const { query } = router;
setCurrentProjectId(issue.project_detail.id);
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: issue.id },
});
};
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const paddingLeft = `${nestingLevel * 54}px`;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
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">
{properties.key && (
<div
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">
<span className="flex items-center justify-center font-medium opacity-100 group-hover:opacity-0 ">
{issue.project_detail?.identifier}-{issue.sequence_id}
</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-100 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
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={() => {
handleDeleteIssue(issue);
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</button>
<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={() => {
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)}
>
<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">
<button
type="button"
className="truncate text-custom-text-100 text-left cursor-pointer w-full text-[0.825rem]"
onClick={openPeekOverview}
>
{issue.name}
</button>
</span>
</div>
);
};

View File

@ -1,36 +1,34 @@
import React, { useState } from "react"; import React from "react";
// components // components
import { SingleSpreadsheetIssue } from "components/core"; import { IssueColumn } from "components/core";
// hooks // hooks
import useSubIssue from "hooks/use-sub-issue"; import useSubIssue from "hooks/use-sub-issue";
// types // types
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; import { IIssue, Properties, UserAuth } from "types";
type Props = { type Props = {
issue: IIssue; issue: IIssue;
index: number; projectId: string;
expandedIssues: string[]; expandedIssues: string[];
setExpandedIssues: React.Dispatch<React.SetStateAction<string[]>>; setExpandedIssues: React.Dispatch<React.SetStateAction<string[]>>;
properties: Properties; properties: Properties;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
gridTemplateColumns: string; setCurrentProjectId: React.Dispatch<React.SetStateAction<string | null>>;
disableUserActions: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
nestingLevel?: number; nestingLevel?: number;
}; };
export const SpreadsheetIssues: React.FC<Props> = ({ export const SpreadsheetIssuesColumn: React.FC<Props> = ({
index,
issue, issue,
projectId,
expandedIssues, expandedIssues,
setExpandedIssues, setExpandedIssues,
gridTemplateColumns,
properties, properties,
handleIssueAction, handleIssueAction,
setCurrentProjectId,
disableUserActions, disableUserActions,
user,
userAuth, userAuth,
nestingLevel = 0, nestingLevel = 0,
}) => { }) => {
@ -49,21 +47,20 @@ export const SpreadsheetIssues: React.FC<Props> = ({
const isExpanded = expandedIssues.indexOf(issue.id) > -1; const isExpanded = expandedIssues.indexOf(issue.id) > -1;
const { subIssues, isLoading } = useSubIssue(issue.id, isExpanded); const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
return ( return (
<div> <div>
<SingleSpreadsheetIssue <IssueColumn
issue={issue} issue={issue}
index={index} projectId={projectId}
expanded={isExpanded} expanded={isExpanded}
handleToggleExpand={handleToggleExpand} handleToggleExpand={handleToggleExpand}
gridTemplateColumns={gridTemplateColumns}
properties={properties} properties={properties}
handleEditIssue={() => handleIssueAction(issue, "edit")} handleEditIssue={() => handleIssueAction(issue, "edit")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
setCurrentProjectId={setCurrentProjectId}
disableUserActions={disableUserActions} disableUserActions={disableUserActions}
user={user}
userAuth={userAuth} userAuth={userAuth}
nestingLevel={nestingLevel} nestingLevel={nestingLevel}
/> />
@ -73,17 +70,16 @@ export const SpreadsheetIssues: React.FC<Props> = ({
subIssues && subIssues &&
subIssues.length > 0 && subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => ( subIssues.map((subIssue: IIssue) => (
<SpreadsheetIssues <SpreadsheetIssuesColumn
key={subIssue.id} key={subIssue.id}
issue={subIssue} issue={subIssue}
index={index} projectId={subIssue.project_detail.id}
expandedIssues={expandedIssues} expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues} setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties} properties={properties}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
setCurrentProjectId={setCurrentProjectId}
disableUserActions={disableUserActions} disableUserActions={disableUserActions}
user={user}
userAuth={userAuth} userAuth={userAuth}
nestingLevel={nestingLevel + 1} nestingLevel={nestingLevel + 1}
/> />

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-label-column";
export * from "./label-column";

View File

@ -0,0 +1,47 @@
import React from "react";
// components
import { LabelSelect } from "components/project";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const LabelColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => {
const handleLabelChange = (data: any) => {
partialUpdateIssue({ labels_list: data }, issue);
};
return (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-100">
{properties.labels && (
<LabelSelect
value={issue.labels}
projectId={projectId}
onChange={handleLabelChange}
labelsDetails={issue.label_details}
hideDropdownArrow
maxRender={1}
user={user}
disabled={isNotAllowed}
/>
)}
</span>
</div>
);
};

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { LabelColumn } from "components/core";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetLabelColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
expandedIssues,
properties,
user,
isNotAllowed,
}) => {
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
return (
<div>
<LabelColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetLabelColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-priority-column";
export * from "./priority-column";

View File

@ -0,0 +1,64 @@
import React from "react";
import { useRouter } from "next/router";
// components
import { PrioritySelect } from "components/project";
// services
import trackEventServices from "services/track-event.service";
// types
import { ICurrentUserResponse, IIssue, Properties, TIssuePriorities } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const PriorityColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const handlePriorityChange = (data: TIssuePriorities) => {
partialUpdateIssue({ priority: data }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_PRIORITY",
user
);
};
return (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-100">
{properties.priority && (
<PrioritySelect
value={issue.priority}
onChange={handlePriorityChange}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
</span>
</div>
);
};

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { PriorityColumn } from "components/core";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetPriorityColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
expandedIssues,
properties,
user,
isNotAllowed,
}) => {
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
return (
<div>
<PriorityColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetPriorityColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -49,6 +49,7 @@ import { renderLongDetailDateFormat } from "helpers/date-time.helper";
type Props = { type Props = {
issue: IIssue; issue: IIssue;
projectId: string;
index: number; index: number;
expanded: boolean; expanded: boolean;
handleToggleExpand: (issueId: string) => void; handleToggleExpand: (issueId: string) => void;
@ -64,6 +65,7 @@ type Props = {
export const SingleSpreadsheetIssue: React.FC<Props> = ({ export const SingleSpreadsheetIssue: React.FC<Props> = ({
issue, issue,
projectId,
index, index,
expanded, expanded,
handleToggleExpand, handleToggleExpand,
@ -80,7 +82,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { workspaceSlug, cycleId, moduleId, viewId } = router.query;
const { params } = useSpreadsheetIssuesView(); const { params } = useSpreadsheetIssuesView();
@ -96,7 +98,7 @@ export const SingleSpreadsheetIssue: 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)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params); : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId, params);
if (issue.parent) if (issue.parent)
mutate<ISubIssueResponse>( mutate<ISubIssueResponse>(
@ -136,13 +138,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
); );
issuesService issuesService
.patchIssue( .patchIssue(workspaceSlug as string, projectId, issue.id as string, formData, user)
workspaceSlug as string,
projectId as string,
issue.id as string,
formData,
user
)
.then(() => { .then(() => {
if (issue.parent) { if (issue.parent) {
mutate(SUB_ISSUES(issue.parent as string)); mutate(SUB_ISSUES(issue.parent as string));
@ -368,6 +364,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200"> <div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<StateSelect <StateSelect
value={issue.state_detail} value={issue.state_detail}
projectId={projectId}
onChange={handleStateChange} onChange={handleStateChange}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0" buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
hideDropdownArrow hideDropdownArrow
@ -390,6 +387,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200"> <div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<MembersSelect <MembersSelect
value={issue.assignees} value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange} onChange={handleAssigneeChange}
membersDetails={issue.assignee_details} membersDetails={issue.assignee_details}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0" buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
@ -402,6 +400,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200"> <div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<LabelSelect <LabelSelect
value={issue.labels} value={issue.labels}
projectId={projectId}
onChange={handleLabelChange} onChange={handleLabelChange}
labelsDetails={issue.label_details} labelsDetails={issue.label_details}
hideDropdownArrow hideDropdownArrow

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,23 +1,61 @@
import React, { useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
// next // next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { KeyedMutator, mutate } from "swr";
// components // components
import { SpreadsheetColumns, SpreadsheetIssues } from "components/core"; import {
import { CustomMenu, Spinner } from "components/ui"; ListInlineCreateIssueForm,
SpreadsheetAssigneeColumn,
SpreadsheetCreatedOnColumn,
SpreadsheetDueDateColumn,
SpreadsheetEstimateColumn,
SpreadsheetIssuesColumn,
SpreadsheetLabelColumn,
SpreadsheetPriorityColumn,
SpreadsheetStartDateColumn,
SpreadsheetStateColumn,
SpreadsheetUpdatedOnColumn,
} from "components/core";
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 useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; import useLocalStorage from "hooks/use-local-storage";
import { useWorkspaceView } from "hooks/use-workspace-view";
// types // types
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; import {
// constants ICurrentUserResponse,
import { SPREADSHEET_COLUMN } from "constants/spreadsheet"; IIssue,
ISubIssueResponse,
TIssueOrderByOptions,
UserAuth,
} from "types";
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
VIEW_ISSUES,
WORKSPACE_VIEW_ISSUES,
} from "constants/fetch-keys";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import projectIssuesServices from "services/issues.service";
// icon // icon
import { PlusIcon } from "@heroicons/react/24/outline"; import { CheckIcon, ChevronDownIcon, PlusIcon } from "lucide-react";
type Props = { type Props = {
spreadsheetIssues: IIssue[];
mutateIssues: KeyedMutator<
| IIssue[]
| {
[key: string]: IIssue[];
}
>;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
disableUserActions: boolean; disableUserActions: boolean;
@ -26,6 +64,8 @@ type Props = {
}; };
export const SpreadsheetView: React.FC<Props> = ({ export const SpreadsheetView: React.FC<Props> = ({
spreadsheetIssues,
mutateIssues,
handleIssueAction, handleIssueAction,
openIssuesListModal, openIssuesListModal,
disableUserActions, disableUserActions,
@ -33,99 +73,588 @@ export const SpreadsheetView: React.FC<Props> = ({
userAuth, userAuth,
}) => { }) => {
const [expandedIssues, setExpandedIssues] = useState<string[]>([]); const [expandedIssues, setExpandedIssues] = useState<string[]>([]);
const [currentProjectId, setCurrentProjectId] = useState<string | null>(null);
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 } = 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 { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const columnData = SPREADSHEET_COLUMN.map((column) => ({ const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage(
...column, "spreadsheetViewSorting",
isActive: properties ""
? column.propertyName === "labels" );
? properties[column.propertyName as keyof Properties] const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } =
: column.propertyName === "title" useLocalStorage("spreadsheetViewActiveSortingProperty", "");
? true
: properties[column.propertyName as keyof Properties]
: false,
}));
const gridTemplateColumns = columnData const workspaceIssuesPath = [
.filter((column) => column.isActive) {
.map((column) => column.colSize) params: {
.join(" "); 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 {
params: workspaceViewParams,
filters: workspaceViewFilters,
handleFilters,
} = useWorkspaceView();
const workspaceViewProperties = workspaceViewFilters.display_properties;
const isWorkspaceView = globalViewId || currentWorkspaceIssuePath;
const currentViewProperties = isWorkspaceView ? workspaceViewProperties : properties;
const { params, displayFilters, setDisplayFilters } = useSpreadsheetIssuesView();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !issue) return;
const fetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: globalViewId
? WORKSPACE_VIEW_ISSUES(globalViewId.toString(), workspaceViewParams)
: currentWorkspaceIssuePath
? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(issue.project_detail.id, params);
if (issue.parent)
mutate<ISubIssueResponse>(
SUB_ISSUES(issue.parent.toString()),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
sub_issues: (prevData.sub_issues ?? []).map((i) => {
if (i.id === issue.id) {
return {
...i,
...formData,
};
}
return i;
}),
};
},
false
);
else
mutate<IIssue[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === issue.id) {
return {
...p,
...formData,
};
}
return p;
}),
false
);
projectIssuesServices
.patchIssue(
workspaceSlug as string,
issue.project_detail.id,
issue.id as string,
formData,
user
)
.then(() => {
if (issue.parent) {
mutate(SUB_ISSUES(issue.parent as string));
} else {
mutate(fetchKey);
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
}
})
.catch((error) => {
console.log(error);
});
},
[
workspaceSlug,
cycleId,
moduleId,
viewId,
globalViewId,
workspaceViewParams,
currentWorkspaceIssuePath,
params,
user,
]
);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => {
if (globalViewId) handleFilters("display_filters", { order_by: order });
else setDisplayFilters({ order_by: order });
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 className="h-full min-w-[9rem] w-full">
{spreadsheetIssues.map((issue: IIssue, index) => (
<Component
key={`${issue.id}_${index}`}
issue={issue}
projectId={issue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={currentViewProperties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</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
handleMutation={() => mutateIssues()} handleMutation={() => mutateIssues()}
projectId={projectId?.toString() ?? ""} projectId={currentProjectId ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""} workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={disableUserActions} readOnly={disableUserActions}
/> />
<div className="h-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="sticky z-[2] top-0 border-b border-custom-border-200 bg-custom-background-90 w-full min-w-max"> <div className="h-full w-full flex flex-col">
<SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} /> <div ref={containerRef} className="flex max-h-full h-full overflow-y-auto">
</div> {spreadsheetIssues ? (
{spreadsheetIssues ? ( <>
<div className="flex flex-col h-full w-full bg-custom-background-100 rounded-sm "> <div className="sticky left-0 w-[28rem] z-[2]">
{spreadsheetIssues.map((issue: IIssue, index) => ( <div
<SpreadsheetIssues className="relative flex flex-col h-max w-full bg-custom-background-100 z-[2]"
key={`${issue.id}_${index}`} style={{
index={index} boxShadow: isScrolled ? "8px -9px 12px rgba(0, 0, 0, 0.15)" : "",
issue={issue} }}
expandedIssues={expandedIssues} >
setExpandedIssues={setExpandedIssues} <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">
gridTemplateColumns={gridTemplateColumns} {currentViewProperties.key && (
properties={properties} <span className="flex items-center px-4 py-2.5 h-full w-24 flex-shrink-0">
handleIssueAction={handleIssueAction} ID
disableUserActions={disableUserActions} </span>
user={user} )}
userAuth={userAuth} <span className="flex items-center px-4 py-2.5 h-full w-full flex-grow">
Issue
</span>
</div>
{spreadsheetIssues.map((issue: IIssue, index) => (
<SpreadsheetIssuesColumn
key={`${issue.id}_${index}`}
issue={issue}
projectId={issue.project_detail.id}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
setCurrentProjectId={setCurrentProjectId}
properties={currentViewProperties}
handleIssueAction={handleIssueAction}
disableUserActions={disableUserActions}
userAuth={userAuth}
/>
))}
</div>
</div>
{currentViewProperties.state &&
renderColumn(
"State",
"state",
SpreadsheetStateColumn,
"state__name",
"-state__name"
)}
{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">
<Spinner />
</div>
)}
</div>
<div className="border-t border-custom-border-100">
<div className="mb-3 z-50 sticky bottom-0 left-0">
<ListInlineCreateIssueForm
isOpen={isInlineCreateIssueFormOpen}
handleClose={() => setIsInlineCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
}}
/> />
))} </div>
<div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max" {type === "issue"
style={{ gridTemplateColumns }} ? !disableUserActions &&
> !isInlineCreateIssueFormOpen && (
{type === "issue" ? ( <button
<button className="flex gap-1.5 items-center text-custom-primary-100 pl-4 py-2.5 text-sm sticky left-0 z-[1] w-full"
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full" onClick={() => setIsInlineCreateIssueFormOpen(true)}
onClick={() => { >
const e = new KeyboardEvent("keydown", { key: "c" }); <PlusIcon className="h-4 w-4" />
document.dispatchEvent(e); New Issue
}} </button>
> )
<PlusIcon className="h-4 w-4" /> : !disableUserActions &&
Add Issue !isInlineCreateIssueFormOpen && (
</button>
) : (
!disableUserActions && (
<CustomMenu <CustomMenu
className="sticky left-0 z-[1]" className="sticky left-0 z-10"
customButton={ customButton={
<button <button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full" className="flex gap-1.5 items-center text-custom-primary-100 pl-4 py-2.5 text-sm sticky left-0 z-[1] border-custom-border-200 w-full"
type="button" type="button"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Add Issue New Issue
</button> </button>
} }
position="left" position="left"
verticalPosition="top"
optionsClassName="left-5 !w-36" optionsClassName="left-5 !w-36"
noBorder noBorder
> >
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={() => setIsInlineCreateIssueFormOpen(true)}>
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
Create new Create new
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{openIssuesListModal && ( {openIssuesListModal && (
@ -134,13 +663,9 @@ export const SpreadsheetView: React.FC<Props> = ({
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
</CustomMenu> </CustomMenu>
) )}
)}
</div>
</div> </div>
) : ( </div>
<Spinner />
)}
</div> </div>
</> </>
); );

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-start-date-column";
export * from "./start-date-column";

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { StartDateColumn } from "components/core";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetStartDateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
expandedIssues,
properties,
user,
isNotAllowed,
}) => {
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
return (
<div>
<StartDateColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetStartDateColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from "react";
// components
import { ViewStartDateSelect } from "components/issues";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const StartDateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-100">
{properties.due_date && (
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</span>
</div>
);

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-state-column";
export * from "./state-column";

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { StateColumn } from "components/core";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetStateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
expandedIssues,
properties,
user,
isNotAllowed,
}) => {
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
return (
<div>
<StateColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetStateColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -0,0 +1,87 @@
import React from "react";
import { useRouter } from "next/router";
// components
import { StateSelect } from "components/states";
// services
import trackEventServices from "services/track-event.service";
// types
import { ICurrentUserResponse, IIssue, IState, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const StateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const handleStateChange = (data: string, states: IState[] | undefined) => {
const oldState = states?.find((s) => s.id === issue.state);
const newState = states?.find((s) => s.id === data);
partialUpdateIssue(
{
state: data,
state_detail: newState,
},
issue
);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_STATE",
user
);
if (oldState?.group !== "completed" && newState?.group !== "completed") {
trackEventServices.trackIssueMarkedAsDoneEvent(
{
workspaceSlug: issue.workspace_detail.slug,
workspaceId: issue.workspace_detail.id,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
user
);
}
};
return (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-100">
{properties.state && (
<StateSelect
value={issue.state_detail}
projectId={projectId}
onChange={handleStateChange}
buttonClassName="!shadow-none !border-0"
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
</span>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-updated-on-column";
export * from "./updated-on-column";

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { UpdatedOnColumn } from "components/core";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetUpdatedOnColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
expandedIssues,
properties,
user,
isNotAllowed,
}) => {
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
return (
<div>
<UpdatedOnColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetUpdatedOnColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -0,0 +1,34 @@
import React from "react";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
// helper
import { renderLongDetailDateFormat } from "helpers/date-time.helper";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const UpdatedOnColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-100">
{properties.updated_on && (
<div className="flex items-center text-xs cursor-default text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
{renderLongDetailDateFormat(issue.updated_at)}
</div>
)}
</span>
</div>
);

View File

@ -1,14 +1,15 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// ui // ui
import { CustomSearchSelect } from "components/ui"; import { CustomSearchSelect } from "components/ui";
import { renderEmoji } from "helpers/emoji.helper"; // icons
import { TableProperties } from "lucide-react"; import { TableProperties } from "lucide-react";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
type Props = { type Props = {
onChange: (val: string | null) => void; onChange: (val: string | null) => void;

View File

@ -149,6 +149,10 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
color: group.color, color: group.color,
})); }));
const completedIssues = cycle.completed_issues + cycle.cancelled_issues;
const percentage = cycle.total_issues > 0 ? (completedIssues / cycle.total_issues) * 100 : 0;
return ( return (
<div> <div>
<div className="flex flex-col text-xs hover:bg-custom-background-80"> <div className="flex flex-col text-xs hover:bg-custom-background-80">
@ -307,7 +311,7 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
) : cycleStatus === "completed" ? ( ) : cycleStatus === "completed" ? (
<span className="flex gap-1"> <span className="flex gap-1">
<RadialProgressBar progress={100} /> <RadialProgressBar progress={100} />
<span>{100} %</span> <span>{Math.round(percentage)} %</span>
</span> </span>
) : ( ) : (
<span className="flex gap-1"> <span className="flex gap-1">

View File

@ -1,4 +1,6 @@
import { FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from "react";
// next
import { useRouter } from "next/router";
// icons // icons
import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid"; import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
// components // components
@ -11,6 +13,8 @@ import { GanttSidebar } from "../sidebar";
import { MonthChartView } from "./month"; import { MonthChartView } from "./month";
// import { QuarterChartView } from "./quarter"; // import { QuarterChartView } from "./quarter";
// import { YearChartView } from "./year"; // import { YearChartView } from "./year";
// icons
import { PlusIcon } from "lucide-react";
// views // views
import { import {
// generateHourChart, // generateHourChart,
@ -25,6 +29,7 @@ import {
getNumberOfDaysBetweenTwoDatesInYear, getNumberOfDaysBetweenTwoDatesInYear,
getMonthChartItemPositionWidthInMonth, getMonthChartItemPositionWidthInMonth,
} from "../views"; } from "../views";
import { GanttInlineCreateIssueForm } from "components/core/views/gantt-chart-view/inline-create-issue-form";
// types // types
import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types"; import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types";
// data // data
@ -64,12 +69,17 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
const [itemsContainerWidth, setItemsContainerWidth] = useState<number>(0); const [itemsContainerWidth, setItemsContainerWidth] = useState<number>(0);
const [fullScreenMode, setFullScreenMode] = useState<boolean>(false); const [fullScreenMode, setFullScreenMode] = useState<boolean>(false);
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
// blocks state management starts // blocks state management starts
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null); const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
const { currentView, currentViewData, renderView, dispatch, allViews, updateScrollLeft } = const { currentView, currentViewData, renderView, dispatch, allViews, updateScrollLeft } =
useChart(); useChart();
const router = useRouter();
const { cycleId, moduleId } = router.query;
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) => ({
@ -294,9 +304,12 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
> >
<div <div
id="gantt-sidebar" id="gantt-sidebar"
className="h-full w-1/4 flex flex-col border-r border-custom-border-200 space-y-3" className="h-full w-1/4 flex flex-col border-r border-custom-border-200"
> >
<div className="h-[60px] border-b border-custom-border-200 box-border flex-shrink-0" /> <div className="h-[60px] border-b border-custom-border-200 box-border flex-shrink-0 flex items-end justify-between gap-2 text-sm text-custom-text-300 font-medium pb-2 pl-10 pr-4">
<h6>{title}</h6>
<h6>Duration</h6>
</div>
<GanttSidebar <GanttSidebar
title={title} title={title}
blockUpdateHandler={blockUpdateHandler} blockUpdateHandler={blockUpdateHandler}
@ -304,6 +317,44 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
SidebarBlockRender={SidebarBlockRender} SidebarBlockRender={SidebarBlockRender}
enableReorder={enableReorder} enableReorder={enableReorder}
/> />
{chartBlocks && (
<div className="pl-2.5 py-3">
<GanttInlineCreateIssueForm
isOpen={isCreateIssueFormOpen}
handleClose={() => setIsCreateIssueFormOpen(false)}
onSuccess={() => {
const ganttSidebar = document.getElementById(`gantt-sidebar-${cycleId}`);
const timeoutId = setTimeout(() => {
if (ganttSidebar)
ganttSidebar.scrollBy({
top: ganttSidebar.scrollHeight,
left: 0,
behavior: "smooth",
});
clearTimeout(timeoutId);
}, 10);
}}
prePopulatedData={{
start_date: new Date(Date.now()).toISOString().split("T")[0],
target_date: new Date(Date.now() + 86400000).toISOString().split("T")[0],
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
}}
/>
{!isCreateIssueFormOpen && (
<button
type="button"
onClick={() => setIsCreateIssueFormOpen(true)}
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 pl-[1.875rem] 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>
)}
</div> </div>
<div <div
className="relative flex h-full w-full flex-1 flex-col overflow-hidden overflow-x-auto horizontal-scroll-enable" className="relative flex h-full w-full flex-1 flex-col overflow-hidden overflow-x-auto horizontal-scroll-enable"

View File

@ -179,11 +179,6 @@ export const ChartDraggable: React.FC<Props> = ({
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
setIsMoving(true);
const resizableDiv = resizableRef.current; const resizableDiv = resizableRef.current;
const columnWidth = currentViewData.data.width; const columnWidth = currentViewData.data.width;
@ -193,6 +188,8 @@ export const ChartDraggable: React.FC<Props> = ({
let initialMarginLeft = parseInt(resizableDiv.style.marginLeft); let initialMarginLeft = parseInt(resizableDiv.style.marginLeft);
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
setIsMoving(true);
let delWidth = 0; let delWidth = 0;
delWidth = checkScrollEnd(e); delWidth = checkScrollEnd(e);
@ -295,7 +292,9 @@ export const ChartDraggable: React.FC<Props> = ({
</> </>
)} )}
<div <div
className="relative z-[2] rounded h-8 w-full flex items-center" className={`relative z-[2] rounded h-8 w-full flex items-center ${
isMoving ? "pointer-events-none" : ""
}`}
onMouseDown={handleBlockMove} onMouseDown={handleBlockMove}
> >
<BlockRender data={block.data} /> <BlockRender data={block.data} />

View File

@ -1,3 +1,4 @@
import { useRouter } from "next/router";
// react-beautiful-dnd // react-beautiful-dnd
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd"; import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
@ -7,6 +8,8 @@ import { useChart } from "./hooks";
import { Loader } from "components/ui"; import { Loader } from "components/ui";
// icons // icons
import { EllipsisVerticalIcon } from "@heroicons/react/24/outline"; import { EllipsisVerticalIcon } from "@heroicons/react/24/outline";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
// types // types
import { IBlockUpdateData, IGanttBlock } from "./types"; import { IBlockUpdateData, IGanttBlock } from "./types";
@ -18,13 +21,12 @@ type Props = {
enableReorder: boolean; enableReorder: boolean;
}; };
export const GanttSidebar: React.FC<Props> = ({ export const GanttSidebar: React.FC<Props> = (props) => {
title, const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props;
blockUpdateHandler,
blocks, const router = useRouter();
SidebarBlockRender, const { cycleId } = router.query;
enableReorder,
}) => {
const { activeBlock, dispatch } = useChart(); const { activeBlock, dispatch } = useChart();
// update the active block on hover // update the active block on hover
@ -85,14 +87,21 @@ export const GanttSidebar: React.FC<Props> = ({
<StrictModeDroppable droppableId="gantt-sidebar"> <StrictModeDroppable droppableId="gantt-sidebar">
{(droppableProvided) => ( {(droppableProvided) => (
<div <div
className="h-full overflow-y-auto pl-2.5" id={`gantt-sidebar-${cycleId}`}
className="max-h-full overflow-y-auto pl-2.5 mt-3"
ref={droppableProvided.innerRef} ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps} {...droppableProvided.droppableProps}
> >
<> <>
{blocks ? ( {blocks ? (
blocks.length > 0 ? ( blocks.map((block, index) => {
blocks.map((block, index) => ( const duration = findTotalDaysInRange(
block.start_date ?? "",
block.target_date ?? "",
true
);
return (
<Draggable <Draggable
key={`sidebar-block-${block.id}`} key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`} draggableId={`sidebar-block-${block.id}`}
@ -125,19 +134,20 @@ export const GanttSidebar: React.FC<Props> = ({
<EllipsisVerticalIcon className="h-4 -ml-5" /> <EllipsisVerticalIcon className="h-4 -ml-5" />
</button> </button>
)} )}
<div className="flex-grow truncate w-full h-full"> <div className="flex-grow truncate h-full flex items-center justify-between gap-2">
<SidebarBlockRender data={block.data} /> <div className="flex-grow truncate">
<SidebarBlockRender data={block.data} />
</div>
<div className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</div>
</div> </div>
</div> </div>
</div> </div>
)} )}
</Draggable> </Draggable>
)) );
) : ( })
<div className="text-custom-text-200 text-sm text-center mt-8">
No <span className="lowercase">{title}</span> found
</div>
)
) : ( ) : (
<Loader className="pr-2 space-y-3"> <Loader className="pr-2 space-y-3">
<Loader.Item height="34px" /> <Loader.Item height="34px" />

Some files were not shown because too many files have changed in this diff Show More