diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75ccb884c..6baa0bb07 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,6 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla - Python version 3.8+ - Postgres version v14 - Redis version v6.2.7 -- pnpm version 7.22.0 ### Setup the project diff --git a/README.md b/README.md index cf0af7fe2..1e600df5e 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,6 @@ chmod +x setup.sh > If running in a cloud env replace localhost with public facing IP address of the VM -- Export Environment Variables - -```bash -set -a -source .env -set +a -``` - - Run Docker compose up ```bash @@ -165,4 +157,4 @@ Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CON ## ⛓️ Security -If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities. +If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email engineering@plane.so to disclose any security vulnerabilities. diff --git a/apiserver/plane/api/views/analytic.py b/apiserver/plane/api/views/analytic.py index a096c2700..e537af84a 100644 --- a/apiserver/plane/api/views/analytic.py +++ b/apiserver/plane/api/views/analytic.py @@ -3,6 +3,7 @@ from django.db.models import ( Count, Sum, F, + Q ) from django.db.models.functions import ExtractMonth @@ -59,10 +60,11 @@ class AnalyticsEndpoint(BaseAPIView): colors = ( State.objects.filter( + ~Q(name="Triage"), workspace__slug=slug, project_id__in=filters.get("project__in") ).values(key, "color") if filters.get("project__in", False) - else State.objects.filter(workspace__slug=slug).values(key, "color") + else State.objects.filter(~Q(name="Triage"), workspace__slug=slug).values(key, "color") ) if x_axis in ["labels__name"] or segment in ["labels__name"]: diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index 385ec7568..068fae5a9 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -72,7 +72,7 @@ class SignUpEndpoint(BaseAPIView): # Check if the user already exists if User.objects.filter(email=email).exists(): return Response( - {"error": "User already exist please sign in"}, + {"error": "User with this email already exists"}, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/api/views/importer.py b/apiserver/plane/api/views/importer.py index e045a2ec1..63e2d38a1 100644 --- a/apiserver/plane/api/views/importer.py +++ b/apiserver/plane/api/views/importer.py @@ -7,7 +7,7 @@ from rest_framework.response import Response from sentry_sdk import capture_exception # Django imports -from django.db.models import Max +from django.db.models import Max, Q # Module imports from plane.api.views import BaseAPIView @@ -42,16 +42,34 @@ from plane.utils.html_processor import strip_tags class ServiceIssueImportSummaryEndpoint(BaseAPIView): + def get(self, request, slug, service): try: if service == "github": + owner = request.GET.get("owner", False) + repo = request.GET.get("repo", False) + + if not owner or not repo: + return Response( + {"error": "Owner and repo are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + workspace_integration = WorkspaceIntegration.objects.get( integration__provider="github", workspace__slug=slug ) - access_tokens_url = workspace_integration.metadata["access_tokens_url"] - owner = request.GET.get("owner") - repo = request.GET.get("repo") + access_tokens_url = workspace_integration.metadata.get( + "access_tokens_url", False + ) + + if not access_tokens_url: + return Response( + { + "error": "There was an error during the installation of the GitHub app. To resolve this issue, we recommend reinstalling the GitHub app." + }, + status=status.HTTP_400_BAD_REQUEST, + ) issue_count, labels, collaborators = get_github_repo_details( access_tokens_url, owner, repo @@ -309,11 +327,13 @@ class BulkImportIssuesEndpoint(BaseAPIView): # Get the default state default_state = State.objects.filter( - project_id=project_id, default=True + ~Q(name="Triage"), project_id=project_id, default=True ).first() # if there is no default state assign any random state if default_state is None: - default_state = State.objects.filter(project_id=project_id).first() + default_state = State.objects.filter( + ~Q(name="Triage"), sproject_id=project_id + ).first() # Get the maximum sequence_id last_id = IssueSequence.objects.filter(project_id=project_id).aggregate( diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 0e4c1603e..ada76c9b3 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -68,13 +68,12 @@ class InboxViewSet(BaseViewSet): inbox = Inbox.objects.get( workspace__slug=slug, project_id=project_id, pk=pk ) - + # Handle default inbox delete if inbox.is_default: return Response( {"error": "You cannot delete the default inbox"}, status=status.HTTP_400_BAD_REQUEST, ) - inbox.delete() return Response(status=status.HTTP_204_NO_CONTENT) except Exception as e: @@ -112,7 +111,6 @@ class InboxIssueViewSet(BaseViewSet): def list(self, request, slug, project_id, inbox_id): try: - order_by = request.GET.get("order_by", "created_at") filters = issue_filters(request.query_params, "GET") issues = ( Issue.objects.filter( @@ -120,23 +118,17 @@ class InboxIssueViewSet(BaseViewSet): workspace__slug=slug, project_id=project_id, ) + .filter(**filters) + .annotate(bridge_id=F("issue_inbox__id")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels") + .order_by("issue_inbox__snoozed_till", "issue_inbox__status") .annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate(bridge_id=F("issue_inbox__id")) - .filter(project_id=project_id) - .filter(workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .order_by(order_by) - .filter(**filters) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -180,7 +172,8 @@ class InboxIssueViewSet(BaseViewSet): {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) - if not request.data.get("issue", {}).get("priority", "low") in [ + # Check for valid priority + if not request.data.get("issue", {}).get("priority", None) in [ "low", "medium", "high", @@ -213,7 +206,6 @@ class InboxIssueViewSet(BaseViewSet): ) # Create an Issue Activity - # Track the issue issue_activity.delay( type="issue.activity.created", requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), @@ -231,9 +223,7 @@ class InboxIssueViewSet(BaseViewSet): ) serializer = IssueStateInboxSerializer(issue) - return Response(serializer.data, status=status.HTTP_200_OK) - except Exception as e: capture_exception(e) return Response( @@ -260,7 +250,7 @@ class InboxIssueViewSet(BaseViewSet): pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id ) # Only allow guests and viewers to edit name and description - if project_member <= 10: + if project_member.role <= 10: # viewers and guests since only viewers and guests issue_data = { "name": issue_data.get("name", issue.name), diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index f68219834..b8ead2ab9 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -16,6 +16,7 @@ from django.db.models import ( CharField, When, Exists, + Max, ) from django.core.serializers.json import DjangoJSONEncoder from django.utils.decorators import method_decorator @@ -159,8 +160,9 @@ class IssueViewSet(BaseViewSet): filters = issue_filters(request.query_params, "GET") show_sub_issues = request.GET.get("show_sub_issues", "true") - # Custom ordering for priority + # 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") @@ -185,7 +187,13 @@ class IssueViewSet(BaseViewSet): ) ) - if order_by_param == "priority": + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) issue_queryset = issue_queryset.annotate( priority_order=Case( *[ @@ -195,6 +203,45 @@ class IssueViewSet(BaseViewSet): output_field=CharField(), ) ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) else: issue_queryset = issue_queryset.order_by(order_by_param) @@ -614,7 +661,9 @@ class SubIssuesEndpoint(BaseAPIView): ) state_distribution = ( - State.objects.filter(workspace__slug=slug, project_id=project_id) + State.objects.filter( + ~Q(name="Triage"), workspace__slug=slug, project_id=project_id + ) .annotate( state_count=Count( "state_issue", diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 29cba7a74..4fe0c8260 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -3,13 +3,13 @@ from itertools import groupby # Django imports from django.db import IntegrityError +from django.db.models import Q # Third party imports from rest_framework.response import Response from rest_framework import status from sentry_sdk import capture_exception - # Module imports from . import BaseViewSet, BaseAPIView from plane.api.serializers import StateSerializer @@ -34,6 +34,7 @@ class StateViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(project__project_projectmember__member=self.request.user) + .filter(~Q(name="Triage")) .select_related("project") .select_related("workspace") .distinct() @@ -80,7 +81,8 @@ class StateViewSet(BaseViewSet): def destroy(self, request, slug, project_id, pk): try: state = State.objects.get( - pk=pk, project_id=project_id, workspace__slug=slug + ~Q(name="Triage"), + pk=pk, project_id=project_id, workspace__slug=slug, ) if state.default: diff --git a/apiserver/plane/db/migrations/0034_auto_20230628_1046.py b/apiserver/plane/db/migrations/0034_auto_20230628_1046.py new file mode 100644 index 000000000..cdd722f59 --- /dev/null +++ b/apiserver/plane/db/migrations/0034_auto_20230628_1046.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.19 on 2023-06-28 05:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0033_auto_20230618_2125'), + ] + + operations = [ + migrations.RemoveField( + model_name='timelineissue', + name='created_by', + ), + migrations.RemoveField( + model_name='timelineissue', + name='issue', + ), + migrations.RemoveField( + model_name='timelineissue', + name='project', + ), + migrations.RemoveField( + model_name='timelineissue', + name='updated_by', + ), + migrations.RemoveField( + model_name='timelineissue', + name='workspace', + ), + migrations.DeleteModel( + name='Shortcut', + ), + migrations.DeleteModel( + name='TimelineIssue', + ), + ] diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index ab6c65840..4b765a516 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -98,11 +98,13 @@ class Issue(ProjectBaseModel): from plane.db.models import State default_state = State.objects.filter( - project=self.project, default=True + ~models.Q(name="Triage"), project=self.project, default=True ).first() # if there is no default state assign any random state if default_state is None: - random_state = State.objects.filter(project=self.project).first() + random_state = State.objects.filter( + ~models.Q(name="Triage"), project=self.project + ).first() self.state = random_state if random_state.group == "started": self.start_date = timezone.now().date() diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 1b862c013..e6f5f8e39 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -63,6 +63,7 @@ if os.environ.get("SENTRY_DSN", False): send_default_pii=True, environment="local", traces_sample_rate=0.7, + profiles_sample_rate=1.0, ) REDIS_HOST = "localhost" diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 29b75fc8b..983931110 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -84,6 +84,7 @@ if bool(os.environ.get("SENTRY_DSN", False)): traces_sample_rate=1, send_default_pii=True, environment="production", + profiles_sample_rate=1.0, ) if DOCKERIZED and USE_MINIO: diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 11ff7a372..5a43e266e 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -66,6 +66,7 @@ sentry_sdk.init( traces_sample_rate=1, send_default_pii=True, environment="staging", + profiles_sample_rate=1.0, ) # The AWS region to connect to. diff --git a/apiserver/plane/utils/integrations/github.py b/apiserver/plane/utils/integrations/github.py index d9aecece1..45cb5925a 100644 --- a/apiserver/plane/utils/integrations/github.py +++ b/apiserver/plane/utils/integrations/github.py @@ -113,7 +113,7 @@ def get_github_repo_details(access_tokens_url, owner, repo): last_url = labels_response.links.get("last").get("url") parsed_url = urlparse(last_url) last_page_value = parse_qs(parsed_url.query)["page"][0] - total_labels = total_labels + 100 * (last_page_value - 1) + total_labels = total_labels + 100 * (int(last_page_value) - 1) # Get labels in last page last_page_labels = requests.get(last_url, headers=headers).json() diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index f348f642a..74acb2044 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -1,7 +1,6 @@ from django.utils.timezone import make_aware from django.utils.dateparse import parse_datetime - def filter_state(params, filter, method): if method == "GET": states = params.get("state").split(",") @@ -26,12 +25,27 @@ def filter_estimate_point(params, filter, method): def filter_priority(params, filter, method): if method == "GET": - priorties = params.get("priority").split(",") - if len(priorties) and "" not in priorties: - filter["priority__in"] = priorties + priorities = params.get("priority").split(",") + if len(priorities) and "" not in priorities: + if len(priorities) == 1 and "null" in priorities: + filter["priority__isnull"] = True + elif len(priorities) > 1 and "null" in priorities: + filter["priority__isnull"] = True + filter["priority__in"] = [p for p in priorities if p != "null"] + else: + filter["priority__in"] = [p for p in priorities if p != "null"] + else: if params.get("priority", None) and len(params.get("priority")): - filter["priority__in"] = params.get("priority") + priorities = params.get("priority") + if len(priorities) == 1 and "null" in priorities: + filter["priority__isnull"] = True + elif len(priorities) > 1 and "null" in priorities: + filter["priority__isnull"] = True + filter["priority__in"] = [p for p in priorities if p != "null"] + else: + filter["priority__in"] = [p for p in priorities if p != "null"] + return filter diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 2bc109968..3cd196830 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -2,30 +2,30 @@ Django==3.2.19 django-braces==1.15.0 -django-taggit==3.1.0 -psycopg2==2.9.5 -django-oauth-toolkit==2.2.0 +django-taggit==4.0.0 +psycopg2==2.9.6 +django-oauth-toolkit==2.3.0 mistune==2.0.4 djangorestframework==3.14.0 -redis==4.5.4 +redis==4.6.0 django-nested-admin==4.0.2 -django-cors-headers==3.13.0 +django-cors-headers==4.1.0 whitenoise==6.3.0 -django-allauth==0.52.0 +django-allauth==0.54.0 faker==13.4.0 -django-filter==22.1 +django-filter==23.2 jsonmodels==2.6.0 djangorestframework-simplejwt==5.2.2 -sentry-sdk==1.14.0 -django-s3-storage==0.13.11 +sentry-sdk==1.26.0 +django-s3-storage==0.14.0 django-crum==0.7.9 django-guardian==2.4.0 dj_rest_auth==2.2.5 google-auth==2.16.0 google-api-python-client==2.75.0 -django-redis==5.2.0 -uvicorn==0.20.0 +django-redis==5.3.0 +uvicorn==0.22.0 channels==4.0.0 -openai==0.27.2 -slack-sdk==3.20.2 -celery==5.2.7 \ No newline at end of file +openai==0.27.8 +slack-sdk==3.21.3 +celery==5.3.1 \ No newline at end of file diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt index c37e98ffd..13b3e9aed 100644 --- a/apiserver/requirements/production.txt +++ b/apiserver/requirements/production.txt @@ -1,11 +1,11 @@ -r base.txt -dj-database-url==1.2.0 +dj-database-url==2.0.0 gunicorn==20.1.0 whitenoise==6.3.0 django-storages==1.13.2 -boto3==1.26.136 -django-anymail==9.0 +boto3==1.26.163 +django-anymail==10.0 twilio==7.16.2 django-debug-toolbar==3.8.1 gevent==22.10.2 diff --git a/apps/app/Dockerfile.web b/apps/app/Dockerfile.web index 1b9bc41d5..e0b5f29c1 100644 --- a/apps/app/Dockerfile.web +++ b/apps/app/Dockerfile.web @@ -20,7 +20,7 @@ ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 COPY .gitignore .gitignore COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/yarn.lock ./yarn.lock -RUN yarn install +RUN yarn install --network-timeout 500000 # Build the project COPY --from=builder /app/out/full/ . diff --git a/apps/app/components/analytics/custom-analytics/sidebar.tsx b/apps/app/components/analytics/custom-analytics/sidebar.tsx index 5f4700f29..b533df519 100644 --- a/apps/app/components/analytics/custom-analytics/sidebar.tsx +++ b/apps/app/components/analytics/custom-analytics/sidebar.tsx @@ -237,7 +237,7 @@ export const AnalyticsSidebar: React.FC = ({ {project?.name.charAt(0)} )} -
+
{project.name} ({project.identifier}) @@ -276,7 +276,7 @@ export const AnalyticsSidebar: React.FC = ({ {projectId ? ( cycleId && cycleDetails ? (
-

Analytics for {cycleDetails.name}

+

Analytics for {cycleDetails.name}

Lead
@@ -304,7 +304,7 @@ export const AnalyticsSidebar: React.FC = ({
) : moduleId && moduleDetails ? (
-

Analytics for {moduleDetails.name}

+

Analytics for {moduleDetails.name}

Lead
@@ -352,7 +352,7 @@ export const AnalyticsSidebar: React.FC = ({ {projectDetails?.name.charAt(0)} )} -

{projectDetails?.name}

+

{projectDetails?.name}

diff --git a/apps/app/components/analytics/project-modal.tsx b/apps/app/components/analytics/project-modal.tsx index da308582f..5fdb6682d 100644 --- a/apps/app/components/analytics/project-modal.tsx +++ b/apps/app/components/analytics/project-modal.tsx @@ -160,7 +160,7 @@ export const AnalyticsProjectModal: React.FC = ({ isOpen, onClose }) => { }`} >
-

+

Analytics for{" "} {cycleId ? cycleDetails?.name : moduleId ? moduleDetails?.name : projectDetails?.name}

diff --git a/apps/app/components/analytics/scope-and-demand/leaderboard.tsx b/apps/app/components/analytics/scope-and-demand/leaderboard.tsx index 855f9eff4..72b892eeb 100644 --- a/apps/app/components/analytics/scope-and-demand/leaderboard.tsx +++ b/apps/app/components/analytics/scope-and-demand/leaderboard.tsx @@ -33,7 +33,7 @@ export const AnalyticsLeaderboard: React.FC = ({ users, title }) => ( {user.firstName !== "" ? user.firstName[0] : "?"}
)} - + {user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
diff --git a/apps/app/components/analytics/scope-and-demand/year-wise-issues.tsx b/apps/app/components/analytics/scope-and-demand/year-wise-issues.tsx index 863eb664b..a37518cba 100644 --- a/apps/app/components/analytics/scope-and-demand/year-wise-issues.tsx +++ b/apps/app/components/analytics/scope-and-demand/year-wise-issues.tsx @@ -41,6 +41,14 @@ export const AnalyticsYearWiseIssues: React.FC = ({ defaultAnalytics }) = colors={(datum) => datum.color} curve="monotoneX" margin={{ top: 20 }} + enableSlices="x" + sliceTooltip={(datum) => ( +
+ {datum.slice.points[0].data.yFormatted} + issues closed in + {datum.slice.points[0].data.xFormatted} +
+ )} theme={{ background: "rgb(var(--color-bg-base))", }} diff --git a/apps/app/components/breadcrumbs/index.tsx b/apps/app/components/breadcrumbs/index.tsx index 240faefa2..6e2c85785 100644 --- a/apps/app/components/breadcrumbs/index.tsx +++ b/apps/app/components/breadcrumbs/index.tsx @@ -52,7 +52,7 @@ const BreadcrumbItem: React.FC = ({ title, link, icon }) =>

{icon} - {title} + {title}

)} diff --git a/apps/app/components/command-palette/command-pallette.tsx b/apps/app/components/command-palette/command-pallette.tsx index ffbe67ca5..32634f18c 100644 --- a/apps/app/components/command-palette/command-pallette.tsx +++ b/apps/app/components/command-palette/command-pallette.tsx @@ -9,6 +9,7 @@ import { ChatBubbleOvalLeftEllipsisIcon, DocumentTextIcon, FolderPlusIcon, + InboxIcon, LinkIcon, MagnifyingGlassIcon, RocketLaunchIcon, @@ -34,6 +35,7 @@ import { Dialog, Transition } from "@headlessui/react"; // cmdk import { Command } from "cmdk"; // hooks +import useProjectDetails from "hooks/use-project-details"; import useTheme from "hooks/use-theme"; import useToast from "hooks/use-toast"; import useUser from "hooks/use-user"; @@ -64,10 +66,11 @@ import { // services import issuesService from "services/issues.service"; import workspaceService from "services/workspace.service"; +import inboxService from "services/inbox.service"; // types import { IIssue, IWorkspaceSearchResults } from "types"; // fetch keys -import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +import { INBOX_LIST, ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; export const CommandPalette: React.FC = () => { const [isPaletteOpen, setIsPaletteOpen] = useState(false); @@ -81,7 +84,7 @@ export const CommandPalette: React.FC = () => { const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false); - const [searchTerm, setSearchTerm] = React.useState(""); + const [searchTerm, setSearchTerm] = useState(""); const [results, setResults] = useState({ results: { workspace: [], @@ -105,6 +108,8 @@ export const CommandPalette: React.FC = () => { const { workspaceSlug, projectId, issueId, inboxId } = router.query; const { user } = useUser(); + const { projectDetails } = useProjectDetails(); + const { setToastAlert } = useToast(); const { toggleCollapsed } = useTheme(); @@ -116,6 +121,13 @@ export const CommandPalette: React.FC = () => { : null ); + const { data: inboxList } = useSWR( + workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null, + workspaceSlug && projectId + ? () => inboxService.getInboxes(workspaceSlug as string, projectId as string) + : null + ); + const updateIssue = useCallback( async (formData: Partial) => { if (!workspaceSlug || !projectId || !issueId) return; @@ -321,9 +333,9 @@ export const CommandPalette: React.FC = () => { setDeleteIssueModal(true); }; - const goToSettings = (path: string = "") => { + const redirect = (path: string) => { setIsPaletteOpen(false); - router.push(`/${workspaceSlug}/settings/${path}`); + router.push(path); }; return ( @@ -396,7 +408,7 @@ export const CommandPalette: React.FC = () => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
+
@@ -409,14 +421,14 @@ export const CommandPalette: React.FC = () => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - + { if (value.toLowerCase().includes(search.toLowerCase())) return 1; return 0; }} onKeyDown={(e) => { - // when seach is empty and page is undefined + // when search is empty and page is undefined // when user tries to close the modal with esc if (e.key === "Escape" && !page && !searchTerm) { setIsPaletteOpen(false); @@ -698,6 +710,24 @@ export const CommandPalette: React.FC = () => { D + + {projectDetails && projectDetails.inbox_view && ( + + + redirect( + `/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}` + ) + } + className="focus:outline-none" + > +
+ + Open inbox +
+
+
+ )} )} @@ -814,7 +844,7 @@ export const CommandPalette: React.FC = () => { {page === "settings" && workspaceSlug && ( <> goToSettings()} + onSelect={() => redirect(`/${workspaceSlug}/settings`)} className="focus:outline-none" >
@@ -823,7 +853,7 @@ export const CommandPalette: React.FC = () => {
goToSettings("members")} + onSelect={() => redirect(`/${workspaceSlug}/settings/members`)} className="focus:outline-none" >
@@ -832,7 +862,7 @@ export const CommandPalette: React.FC = () => {
goToSettings("billing")} + onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)} className="focus:outline-none" >
@@ -841,7 +871,7 @@ export const CommandPalette: React.FC = () => {
goToSettings("integrations")} + onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)} className="focus:outline-none" >
@@ -850,12 +880,12 @@ export const CommandPalette: React.FC = () => {
goToSettings("import-export")} + onSelect={() => redirect(`/${workspaceSlug}/settings/import-export`)} className="focus:outline-none" >
- Import/ Export + Import/Export
diff --git a/apps/app/components/core/board-view/all-boards.tsx b/apps/app/components/core/board-view/all-boards.tsx index 3e67e86b5..711fb7336 100644 --- a/apps/app/components/core/board-view/all-boards.tsx +++ b/apps/app/components/core/board-view/all-boards.tsx @@ -2,11 +2,12 @@ import useProjectIssuesView from "hooks/use-issues-view"; // components import { SingleBoard } from "components/core/board-view/single-board"; +// icons +import { getStateGroupIcon } from "components/icons"; // helpers import { addSpaceIfCamelCase } from "helpers/string.helper"; // types import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types"; -import { getStateGroupIcon } from "components/icons"; type Props = { type: "issue" | "cycle" | "module"; diff --git a/apps/app/components/core/board-view/board-header.tsx b/apps/app/components/core/board-view/board-header.tsx index 6fa8f68f3..a5df7a426 100644 --- a/apps/app/components/core/board-view/board-header.tsx +++ b/apps/app/components/core/board-view/board-header.tsx @@ -166,7 +166,7 @@ export const BoardHeader: React.FC = ({ )} - {!isCompleted && ( + {!isCompleted && selectedGroup !== "created_by" && (
-
- {type === "issue" ? ( - - ) : ( - !isCompleted && ( - - - Add Issue - - } - position="left" - noBorder + {selectedGroup !== "created_by" && ( +
+ {type === "issue" ? ( + + ) : ( + !isCompleted && ( + + + Add Issue + + } + position="left" + noBorder + > + + Create new - )} - - ) - )} -
+ {openIssuesListModal && ( + + Add an existing issue + + )} +
+ ) + )} +
+ )}
)} diff --git a/apps/app/components/core/board-view/single-issue.tsx b/apps/app/components/core/board-view/single-issue.tsx index 003be9e94..6753e84cd 100644 --- a/apps/app/components/core/board-view/single-issue.tsx +++ b/apps/app/components/core/board-view/single-issue.tsx @@ -23,6 +23,7 @@ import { ViewAssigneeSelect, ViewDueDateSelect, ViewEstimateSelect, + ViewLabelSelect, ViewPrioritySelect, ViewStateSelect, } from "components/issues"; @@ -44,7 +45,14 @@ import { LayerDiagonalIcon } from "components/icons"; import { handleIssuesMutation } from "constants/issue"; import { copyTextToClipboard, truncateText } from "helpers/string.helper"; // types -import { ICurrentUserResponse, IIssue, Properties, TIssueGroupByOptions, UserAuth } from "types"; +import { + ICurrentUserResponse, + IIssue, + ISubIssueResponse, + Properties, + TIssueGroupByOptions, + UserAuth, +} from "types"; // fetch-keys import { CYCLE_DETAILS, @@ -52,6 +60,8 @@ import { MODULE_DETAILS, MODULE_ISSUES_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS, + SUB_ISSUES, + VIEW_ISSUES, } from "constants/fetch-keys"; type Props = { @@ -101,86 +111,71 @@ export const SingleBoardIssue: React.FC = ({ const { orderBy, params } = useIssuesView(); const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { setToastAlert } = useToast(); const partialUpdateIssue = useCallback( - (formData: Partial, issueId: string) => { + (formData: Partial, issue: IIssue) => { if (!workspaceSlug || !projectId) return; - if (cycleId) - mutate< - | { - [key: string]: IIssue[]; - } - | IIssue[] - >( - CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params), - (prevData) => - handleIssuesMutation( - formData, - groupTitle ?? "", - selectedGroup, - index, - orderBy, - prevData - ), - false - ); - else if (moduleId) - mutate< - | { - [key: string]: IIssue[]; - } - | IIssue[] - >( - MODULE_ISSUES_WITH_PARAMS(moduleId as string), - (prevData) => - handleIssuesMutation( - formData, - groupTitle ?? "", - selectedGroup, - index, - orderBy, - prevData - ), - false - ); - else { - mutate< - | { - [key: string]: IIssue[]; - } - | IIssue[] - >( - PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params), + const fetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) + : viewId + ? VIEW_ISSUES(viewId.toString(), params) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params); + + if (issue.parent) { + mutate( + SUB_ISSUES(issue.parent.toString()), (prevData) => { if (!prevData) return prevData; - return handleIssuesMutation( + return { + ...prevData, + sub_issues: (prevData.sub_issues ?? []).map((i) => { + if (i.id === issue.id) { + return { + ...i, + ...formData, + }; + } + return i; + }), + }; + }, + false + ); + } else { + mutate< + | { + [key: string]: IIssue[]; + } + | IIssue[] + >( + fetchKey, + (prevData) => + handleIssuesMutation( formData, groupTitle ?? "", selectedGroup, index, orderBy, prevData - ); - }, + ), false ); } issuesService - .patchIssue(workspaceSlug as string, projectId as string, issueId, formData, user) + .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user) .then(() => { - if (cycleId) { - mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); - mutate(CYCLE_DETAILS(cycleId as string)); - } else if (moduleId) { - mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); - mutate(MODULE_DETAILS(moduleId as string)); - } else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params)); + mutate(fetchKey); + + if (cycleId) mutate(CYCLE_DETAILS(cycleId as string)); + if (moduleId) mutate(MODULE_DETAILS(moduleId as string)); }); }, [ @@ -188,6 +183,7 @@ export const SingleBoardIssue: React.FC = ({ projectId, cycleId, moduleId, + viewId, groupTitle, index, selectedGroup, @@ -338,11 +334,8 @@ export const SingleBoardIssue: React.FC = ({ {issue.project_detail.identifier}-{issue.sequence_id}
)} -
- {truncateText(issue.name, 100)} +
+ {issue.name}
@@ -373,30 +366,20 @@ export const SingleBoardIssue: React.FC = ({ isNotAllowed={isNotAllowed} /> )} - {properties.labels && issue.label_details.length > 0 && ( -
- {issue.label_details.map((label) => ( -
- - {label.name} -
- ))} -
+ {properties.labels && ( + )} {properties.assignee && ( diff --git a/apps/app/components/core/bulk-delete-issues-modal.tsx b/apps/app/components/core/bulk-delete-issues-modal.tsx index 603efe8e3..a2c6310e9 100644 --- a/apps/app/components/core/bulk-delete-issues-modal.tsx +++ b/apps/app/components/core/bulk-delete-issues-modal.tsx @@ -12,6 +12,8 @@ import { Combobox, Dialog, Transition } from "@headlessui/react"; import issuesServices from "services/issues.service"; // hooks import useToast from "hooks/use-toast"; +import useIssuesView from "hooks/use-issues-view"; +import useCalendarIssuesView from "hooks/use-calendar-issues-view"; // ui import { DangerButton, SecondaryButton } from "components/ui"; // icons @@ -20,7 +22,15 @@ import { LayerDiagonalIcon } from "components/icons"; // types import { ICurrentUserResponse, IIssue } from "types"; // fetch keys -import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; +import { + CYCLE_DETAILS, + CYCLE_ISSUES_WITH_PARAMS, + MODULE_DETAILS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST, + PROJECT_ISSUES_LIST_WITH_PARAMS, + VIEW_ISSUES, +} from "constants/fetch-keys"; type FormInput = { delete_issue_ids: string[]; @@ -36,7 +46,7 @@ export const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen, user const [query, setQuery] = useState(""); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { data: issues } = useSWR( workspaceSlug && projectId @@ -48,6 +58,9 @@ export const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen, user ); const { setToastAlert } = useToast(); + const { issueView, params } = useIssuesView(); + const { params: calendarParams } = useCalendarIssuesView(); + const { order_by, group_by, ...viewGanttParams } = params; const { handleSubmit, @@ -61,6 +74,81 @@ export const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen, user }, }); + const handleClose = () => { + setIsOpen(false); + setQuery(""); + reset(); + }; + + const handleDelete: SubmitHandler = async (data) => { + if (!workspaceSlug || !projectId) return; + + if (!data.delete_issue_ids || data.delete_issue_ids.length === 0) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please select at least one issue.", + }); + return; + } + + if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids]; + + const calendarFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams) + : viewId + ? VIEW_ISSUES(viewId.toString(), calendarParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", calendarParams); + + const ganttFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString()) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString()) + : viewId + ? VIEW_ISSUES(viewId.toString(), viewGanttParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? ""); + + await issuesServices + .bulkDeleteIssues( + workspaceSlug as string, + projectId as string, + { + issue_ids: data.delete_issue_ids, + }, + user + ) + .then(() => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Issues deleted successfully!", + }); + + if (issueView === "calendar") mutate(calendarFetchKey); + else if (issueView === "gantt_chart") mutate(ganttFetchKey); + else { + if (cycleId) { + mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)); + mutate(CYCLE_DETAILS(cycleId.toString())); + } else if (moduleId) { + mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); + mutate(MODULE_DETAILS(moduleId as string)); + } else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params)); + } + + handleClose(); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Something went wrong. Please try again.", + }) + ); + }; + const filteredIssues: IIssue[] = query === "" ? issues ?? [] @@ -72,48 +160,6 @@ export const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen, user .includes(query.toLowerCase()) ) ?? []; - const handleClose = () => { - setIsOpen(false); - setQuery(""); - reset(); - }; - - const handleDelete: SubmitHandler = async (data) => { - if (!data.delete_issue_ids || data.delete_issue_ids.length === 0) { - setToastAlert({ - title: "Error", - type: "error", - message: "Please select atleast one issue", - }); - return; - } - - if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids]; - - if (workspaceSlug && projectId) { - await issuesServices - .bulkDeleteIssues( - workspaceSlug as string, - projectId as string, - { - issue_ids: data.delete_issue_ids, - }, - user - ) - .then((res) => { - setToastAlert({ - title: "Success", - type: "success", - message: res.message, - }); - handleClose(); - }) - .catch((e) => { - console.log(e); - }); - } - }; - return ( setQuery("")} appear> diff --git a/apps/app/components/core/calendar-view/single-issue.tsx b/apps/app/components/core/calendar-view/single-issue.tsx index 12fa60d01..f172f9a5a 100644 --- a/apps/app/components/core/calendar-view/single-issue.tsx +++ b/apps/app/components/core/calendar-view/single-issue.tsx @@ -19,6 +19,7 @@ import { ViewAssigneeSelect, ViewDueDateSelect, ViewEstimateSelect, + ViewLabelSelect, ViewPrioritySelect, ViewStateSelect, } from "components/issues"; @@ -28,12 +29,13 @@ import { LayerDiagonalIcon } from "components/icons"; // helper import { copyTextToClipboard, truncateText } from "helpers/string.helper"; // type -import { ICurrentUserResponse, IIssue } from "types"; +import { ICurrentUserResponse, IIssue, ISubIssueResponse } from "types"; // fetch-keys import { CYCLE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS, + SUB_ISSUES, VIEW_ISSUES, } from "constants/fetch-keys"; @@ -68,7 +70,7 @@ export const SingleCalendarIssue: React.FC = ({ const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); const partialUpdateIssue = useCallback( - (formData: Partial, issueId: string) => { + (formData: Partial, issue: IIssue) => { if (!workspaceSlug || !projectId) return; const fetchKey = cycleId @@ -79,25 +81,54 @@ export const SingleCalendarIssue: React.FC = ({ ? VIEW_ISSUES(viewId.toString(), params) : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params); - mutate( - fetchKey, - (prevData) => - (prevData ?? []).map((p) => { - if (p.id === issueId) { - return { - ...p, - ...formData, - assignees: formData?.assignees_list ?? p.assignees, - }; - } + if (issue.parent) { + mutate( + SUB_ISSUES(issue.parent.toString()), + (prevData) => { + if (!prevData) return prevData; - return p; - }), - false - ); + return { + ...prevData, + sub_issues: (prevData.sub_issues ?? []).map((i) => { + if (i.id === issue.id) { + return { + ...i, + ...formData, + }; + } + return i; + }), + }; + }, + false + ); + } else { + mutate( + fetchKey, + (prevData) => + (prevData ?? []).map((p) => { + if (p.id === issue.id) { + return { + ...p, + ...formData, + assignees: formData?.assignees_list ?? p.assignees, + }; + } + + return p; + }), + false + ); + } issuesService - .patchIssue(workspaceSlug as string, projectId as string, issueId as string, formData, user) + .patchIssue( + workspaceSlug as string, + projectId as string, + issue.id as string, + formData, + user + ) .then(() => { mutate(fetchKey); }) @@ -207,25 +238,14 @@ export const SingleCalendarIssue: React.FC = ({ isNotAllowed={isNotAllowed} /> )} - {properties.labels && issue.label_details.length > 0 ? ( -
- {issue.label_details.map((label) => ( - - - {label.name} - - ))} -
- ) : ( - "" + {properties.labels && ( + )} {properties.assignee && ( void; - issues: IIssue[]; - handleOnSubmit: any; + searchParams: Partial; + handleOnSubmit: (data: ISearchIssueResponse[]) => Promise; }; export const ExistingIssuesListModal: React.FC = ({ isOpen, handleClose: onClose, - issues, + searchParams, handleOnSubmit, }) => { - const [query, setQuery] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); + const [issues, setIssues] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [selectedIssues, setSelectedIssues] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + + const debouncedSearchTerm: string = useDebounce(searchTerm, 500); const router = useRouter(); - const { cycleId, moduleId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { setToastAlert } = useToast(); @@ -54,37 +58,30 @@ export const ExistingIssuesListModal: React.FC = ({ const handleClose = () => { onClose(); - setQuery(""); - reset(); + setSearchTerm(""); + setSelectedIssues([]); }; - const { - handleSubmit, - reset, - control, - formState: { isSubmitting }, - } = useForm({ - defaultValues: { - issues: [], - }, - }); - - const onSubmit: SubmitHandler = async (data) => { - if (!data.issues || data.issues.length === 0) { + const onSubmit = async () => { + if (selectedIssues.length === 0) { setToastAlert({ - title: "Error", type: "error", - message: "Please select atleast one issue", + title: "Error!", + message: "Please select at least one issue.", }); return; } - await handleOnSubmit(data); + setIsSubmitting(true); + + await handleOnSubmit(selectedIssues).finally(() => setIsSubmitting(false)); + if (cycleId) { mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); mutate(CYCLE_DETAILS(cycleId as string)); } + if (moduleId) { mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); mutate(MODULE_DETAILS(moduleId as string)); @@ -95,18 +92,45 @@ export const ExistingIssuesListModal: React.FC = ({ setToastAlert({ title: "Success", type: "success", - message: `Issue${data.issues.length > 1 ? "s" : ""} added successfully`, + message: `Issue${selectedIssues.length > 1 ? "s" : ""} added successfully`, }); }; - const filteredIssues: IIssue[] = - query === "" - ? issues ?? [] - : issues.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? []; + useEffect(() => { + if (!workspaceSlug || !projectId) return; + + setIsLoading(true); + + if (debouncedSearchTerm) { + setIsSearching(true); + + projectService + .projectIssuesSearch(workspaceSlug as string, projectId as string, { + search: debouncedSearchTerm, + ...searchParams, + }) + .then((res) => { + setIssues(res); + }) + .finally(() => { + setIsLoading(false); + setIsSearching(false); + }); + } else { + setIssues([]); + setIsLoading(false); + setIsSearching(false); + } + }, [debouncedSearchTerm, workspaceSlug, projectId, searchParams]); return ( <> - setQuery("")} appear> + setSearchTerm("")} + appear + > = ({ leaveTo="opacity-0 scale-95" > -
- ( - -
-
+ { + if (selectedIssues.some((i) => i.id === val.id)) + setSelectedIssues((prevData) => prevData.filter((i) => i.id !== val.id)); + else setSelectedIssues((prevData) => [...prevData, val]); + }} + > +
+
- - {filteredIssues.length > 0 ? ( -
  • - {query === "" && ( -

    - Select issues to add -

    - )} -
      - {filteredIssues.map((issue) => ( - - `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ - active ? "bg-brand-surface-2 text-brand-base" : "" - } ${selected ? "text-brand-base" : ""}` - } - > - {({ selected }) => ( - <> - - - - {issue.project_detail.identifier}-{issue.sequence_id} - - {issue.name} - - )} - - ))} -
    -
  • - ) : ( -
    - -

    - No issues found. Create a new issue with{" "} -
    C
    - . -

    -
    - )} -
    -
    +
    + {selectedIssues.length > 0 ? ( +
    + {selectedIssues.map((issue) => ( +
    + {issue.project__identifier}-{issue.sequence_id} + +
    + ))} +
    + ) : ( +
    + No issues selected +
    )} - /> - {filteredIssues.length > 0 && ( -
    - Cancel - - {isSubmitting ? "Adding..." : "Add selected issues"} - -
    - )} - +
    + + + {debouncedSearchTerm !== "" && ( +
    + Search results for{" "} + + {'"'} + {debouncedSearchTerm} + {'"'} + {" "} + in project: +
    + )} + + {!isLoading && + issues.length === 0 && + searchTerm !== "" && + debouncedSearchTerm !== "" && ( +
    + +

    + No issues found. Create a new issue with{" "} +
    +                              C
    +                            
    + . +

    +
    + )} + + {isLoading || isSearching ? ( + + + + + + + ) : ( +
      0 ? "p-2" : ""}`}> + {issues.map((issue) => { + const selected = selectedIssues.some((i) => i.id === issue.id); + + return ( + + `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ + active ? "bg-brand-surface-2 text-brand-base" : "" + } ${selected ? "text-brand-base" : ""}` + } + > + + + + {issue.project__identifier}-{issue.sequence_id} + + {issue.name} + + ); + })} +
    + )} +
    +
    + {selectedIssues.length > 0 && ( +
    + Cancel + + {isSubmitting ? "Adding..." : "Add selected issues"} + +
    + )}
    diff --git a/apps/app/components/core/filters-list.tsx b/apps/app/components/core/filters-list.tsx index 70f980aae..e080c8e9a 100644 --- a/apps/app/components/core/filters-list.tsx +++ b/apps/app/components/core/filters-list.tsx @@ -135,7 +135,7 @@ export const FilterList: React.FC = ({ filters, setFilters }) => { }`} > {getPriorityIcon(priority)} - {priority ? priority : "None"} + {priority === "null" ? "None" : priority} diff --git a/apps/app/components/core/index.ts b/apps/app/components/core/index.ts index e3e187d60..c50ce7251 100644 --- a/apps/app/components/core/index.ts +++ b/apps/app/components/core/index.ts @@ -2,6 +2,7 @@ export * from "./board-view"; export * from "./calendar-view"; export * from "./gantt-chart-view"; export * from "./list-view"; +export * from "./spreadsheet-view"; export * from "./sidebar"; export * from "./bulk-delete-issues-modal"; export * from "./existing-issues-list-modal"; diff --git a/apps/app/components/core/issues-view-filter.tsx b/apps/app/components/core/issues-view-filter.tsx index 6856e8f8b..a6996793c 100644 --- a/apps/app/components/core/issues-view-filter.tsx +++ b/apps/app/components/core/issues-view-filter.tsx @@ -10,7 +10,7 @@ import { Popover, Transition } from "@headlessui/react"; // components import { SelectFilters } from "components/views"; // ui -import { CustomMenu, ToggleSwitch } from "components/ui"; +import { CustomMenu, Icon, ToggleSwitch } from "components/ui"; // icons import { ChevronDownIcon, @@ -83,6 +83,15 @@ export const IssuesFilterView: React.FC = () => { > +
    -
    {link.title}
    +
    {link.title}

    Added {timeAgo(link.created_at)}
    diff --git a/apps/app/components/core/sidebar/progress-chart.tsx b/apps/app/components/core/sidebar/progress-chart.tsx index e6349bfe5..5fa25e863 100644 --- a/apps/app/components/core/sidebar/progress-chart.tsx +++ b/apps/app/components/core/sidebar/progress-chart.tsx @@ -3,7 +3,7 @@ import React from "react"; // ui import { LineGraph } from "components/ui"; // helpers -import { renderShortNumericDateFormat } from "helpers/date-time.helper"; +import { getDatesInRange, renderShortNumericDateFormat } from "helpers/date-time.helper"; //types import { TCompletionChartDistribution } from "types"; @@ -46,6 +46,27 @@ const ProgressChart: React.FC = ({ distribution, startDate, endDate, tota pending: distribution[key], })); + const generateXAxisTickValues = () => { + const dates = getDatesInRange(startDate, endDate); + + const maxDates = 4; + const totalDates = dates.length; + + if (totalDates <= maxDates) return dates.map((d) => renderShortNumericDateFormat(d)); + else { + const interval = Math.ceil(totalDates / maxDates); + const limitedDates = []; + + for (let i = 0; i < totalDates; i += interval) + limitedDates.push(renderShortNumericDateFormat(dates[i])); + + if (!limitedDates.includes(renderShortNumericDateFormat(dates[totalDates - 1]))) + limitedDates.push(renderShortNumericDateFormat(dates[totalDates - 1])); + + return limitedDates; + } + }; + return (

    = ({ distribution, startDate, endDate, tota id: "ideal", color: "#a9bbd0", fill: "transparent", - data: [ - { - x: chartData[0].currentDate, - y: totalIssues, - }, - { - x: chartData[chartData.length - 1].currentDate, - y: 0, - }, - ], + data: + chartData.length > 0 + ? [ + { + x: chartData[0].currentDate, + y: totalIssues, + }, + { + x: chartData[chartData.length - 1].currentDate, + y: 0, + }, + ] + : [], }, ]} layers={["grid", "markers", "areas", DashedLine, "slices", "points", "axes", "legends"]} axisBottom={{ - tickValues: chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : "")), + tickValues: generateXAxisTickValues(), }} enablePoints={false} enableArea colors={(datum) => datum.color ?? "#3F76FF"} customYAxisTickValues={[0, totalIssues]} gridXValues={chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : ""))} + enableSlices="x" + sliceTooltip={(datum) => ( +
    + {datum.slice.points[0].data.yFormatted} + issues pending on + {datum.slice.points[0].data.xFormatted} +
    + )} theme={{ background: "transparent", axis: { diff --git a/apps/app/components/core/spreadsheet-view/index.ts b/apps/app/components/core/spreadsheet-view/index.ts new file mode 100644 index 000000000..7729d5e93 --- /dev/null +++ b/apps/app/components/core/spreadsheet-view/index.ts @@ -0,0 +1,4 @@ +export * from "./spreadsheet-view"; +export * from "./single-issue"; +export * from "./spreadsheet-columns"; +export * from "./spreadsheet-issues"; diff --git a/apps/app/components/core/spreadsheet-view/single-issue.tsx b/apps/app/components/core/spreadsheet-view/single-issue.tsx new file mode 100644 index 000000000..ada1e3689 --- /dev/null +++ b/apps/app/components/core/spreadsheet-view/single-issue.tsx @@ -0,0 +1,350 @@ +import React, { useCallback, useState } from "react"; + +import Link from "next/link"; +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// components +import { + ViewAssigneeSelect, + ViewDueDateSelect, + ViewEstimateSelect, + ViewLabelSelect, + ViewPrioritySelect, + ViewStateSelect, +} from "components/issues"; +import { Popover2 } from "@blueprintjs/popover2"; +// icons +import { Icon } from "components/ui"; +import { + EllipsisHorizontalIcon, + LinkIcon, + PencilIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; +// hooks +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; +import useToast from "hooks/use-toast"; +// services +import issuesService from "services/issues.service"; +// constant +import { + CYCLE_DETAILS, + CYCLE_ISSUES_WITH_PARAMS, + MODULE_DETAILS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, + SUB_ISSUES, + VIEW_ISSUES, +} from "constants/fetch-keys"; +// types +import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types"; +// helper +import { copyTextToClipboard } from "helpers/string.helper"; + +type Props = { + issue: IIssue; + index: number; + expanded: boolean; + handleToggleExpand: (issueId: string) => void; + properties: Properties; + handleEditIssue: (issue: IIssue) => void; + handleDeleteIssue: (issue: IIssue) => void; + gridTemplateColumns: string; + isCompleted?: boolean; + user: ICurrentUserResponse | undefined; + userAuth: UserAuth; + nestingLevel: number; +}; + +export const SingleSpreadsheetIssue: React.FC = ({ + issue, + index, + expanded, + handleToggleExpand, + properties, + handleEditIssue, + handleDeleteIssue, + gridTemplateColumns, + isCompleted = false, + user, + userAuth, + nestingLevel, +}) => { + const [isOpen, setIsOpen] = useState(false); + const router = useRouter(); + + const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + + const { params } = useSpreadsheetIssuesView(); + + const { setToastAlert } = useToast(); + + const partialUpdateIssue = useCallback( + (formData: Partial, issue: IIssue) => { + if (!workspaceSlug || !projectId) 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) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params); + + if (issue.parent) { + mutate( + 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( + fetchKey, + (prevData) => + (prevData ?? []).map((p) => { + if (p.id === issue.id) { + return { + ...p, + ...formData, + }; + } + return p; + }), + false + ); + } + + issuesService + .patchIssue( + workspaceSlug as string, + projectId as string, + 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, projectId, cycleId, moduleId, viewId, params, user] + ); + + 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 * 68}px`; + + const tooltipPosition = index === 0 ? "bottom" : "top"; + + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + return ( +
    +
    +
    +
    + {properties.key && ( + + {issue.project_detail?.identifier}-{issue.sequence_id} + + )} + {!isNotAllowed && !isCompleted && ( +
    + setIsOpen(nextOpenState)} + content={ +
    + + + + + +
    + } + placement="bottom-start" + > + +
    +
    + )} +
    + + {issue.sub_issues_count > 0 && ( +
    + +
    + )} +
    + + + + {issue.name} + + +
    + {properties.state && ( +
    + +
    + )} + {properties.priority && ( +
    + +
    + )} + {properties.assignee && ( +
    + +
    + )} + {properties.labels && ( +
    + +
    + )} + + {properties.due_date && ( +
    + +
    + )} + {properties.estimate && ( +
    + +
    + )} +
    + ); +}; diff --git a/apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx b/apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx new file mode 100644 index 000000000..a0f404fba --- /dev/null +++ b/apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx @@ -0,0 +1,277 @@ +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 = ({ columnData, gridTemplateColumns }) => { + const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( + "spreadsheetViewSorting", + "" + ); + const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = + useLocalStorage("spreadsheetViewActiveSortingProperty", ""); + + const { orderBy, setOrderBy } = useSpreadsheetIssuesView(); + + const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { + setOrderBy(order); + setSelectedMenuItem(`${order}_${itemKey}`); + setActiveSortingProperty(order === "-created_at" ? "" : itemKey); + }; + + return ( +
    + {columnData.map((col: any) => { + if (col.isActive) { + return ( +
    + {col.propertyName === "title" ? ( +
    + {col.colName} +
    + ) : ( + + {activeSortingProperty === col.propertyName && ( +
    + +
    + )} + + {col.icon ? ( +
    + } + menuItemsWhiteBg + width="xl" + > + { + handleOrderBy(col.ascendingOrder, col.propertyName); + }} + > +
    +
    + {col.propertyName === "assignee" || col.propertyName === "labels" ? ( + <> + + + + + A + + Z + + ) : col.propertyName === "due_date" ? ( + <> + + + + + New + + Old + + ) : ( + <> + + + + + First + + Last + + )} +
    + + +
    +
    + { + handleOrderBy(col.descendingOrder, col.propertyName); + }} + > +
    +
    + {col.propertyName === "assignee" || col.propertyName === "labels" ? ( + <> + + + + + Z + + A + + ) : col.propertyName === "due_date" ? ( + <> + + + + + Old + + New + + ) : ( + <> + + + + + Last + + First + + )} +
    + + +
    +
    + {selectedMenuItem && + selectedMenuItem !== "" && + orderBy !== "-created_at" && + selectedMenuItem.includes(col.propertyName) && ( + { + handleOrderBy("-created_at", col.propertyName); + }} + > +
    +
    + + + + + Clear sorting +
    +
    +
    + )} + + )} +
    + ); + } + })} +
    + ); +}; diff --git a/apps/app/components/core/spreadsheet-view/spreadsheet-issues.tsx b/apps/app/components/core/spreadsheet-view/spreadsheet-issues.tsx new file mode 100644 index 000000000..1e05eba4e --- /dev/null +++ b/apps/app/components/core/spreadsheet-view/spreadsheet-issues.tsx @@ -0,0 +1,98 @@ +import React, { useState } from "react"; + +// components +import { SingleSpreadsheetIssue } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; + +type Props = { + key: string; + issue: IIssue; + index: number; + expandedIssues: string[]; + setExpandedIssues: React.Dispatch>; + properties: Properties; + handleEditIssue: (issue: IIssue) => void; + handleDeleteIssue: (issue: IIssue) => void; + gridTemplateColumns: string; + isCompleted?: boolean; + user: ICurrentUserResponse | undefined; + userAuth: UserAuth; + nestingLevel?: number; +}; + +export const SpreadsheetIssues: React.FC = ({ + key, + index, + issue, + expandedIssues, + setExpandedIssues, + gridTemplateColumns, + properties, + handleEditIssue, + handleDeleteIssue, + isCompleted = false, + user, + userAuth, + nestingLevel = 0, +}) => { + const handleToggleExpand = (issueId: string) => { + setExpandedIssues((prevState) => { + const newArray = [...prevState]; + const index = newArray.indexOf(issueId); + if (index > -1) { + newArray.splice(index, 1); + } else { + newArray.push(issueId); + } + return newArray; + }); + }; + + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.id, isExpanded); + + return ( +
    + + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue, subIndex: number) => ( + + ))} +
    + ); +}; diff --git a/apps/app/components/core/spreadsheet-view/spreadsheet-view.tsx b/apps/app/components/core/spreadsheet-view/spreadsheet-view.tsx new file mode 100644 index 000000000..5b8ee0077 --- /dev/null +++ b/apps/app/components/core/spreadsheet-view/spreadsheet-view.tsx @@ -0,0 +1,141 @@ +import React, { useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// components +import { SpreadsheetColumns, SpreadsheetIssues } from "components/core"; +import { CustomMenu, Icon, Spinner } from "components/ui"; +// hooks +import useIssuesProperties from "hooks/use-issue-properties"; +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; +// types +import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; +// constants +import { SPREADSHEET_COLUMN } from "constants/spreadsheet"; +// icon +import { PlusIcon } from "@heroicons/react/24/outline"; + +type Props = { + type: "issue" | "cycle" | "module"; + handleEditIssue: (issue: IIssue) => void; + handleDeleteIssue: (issue: IIssue) => void; + openIssuesListModal?: (() => void) | null; + isCompleted?: boolean; + user: ICurrentUserResponse | undefined; + userAuth: UserAuth; +}; + +export const SpreadsheetView: React.FC = ({ + type, + handleEditIssue, + handleDeleteIssue, + openIssuesListModal, + isCompleted = false, + user, + userAuth, +}) => { + const [expandedIssues, setExpandedIssues] = useState([]); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { spreadsheetIssues } = useSpreadsheetIssuesView(); + + const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); + + const columnData = SPREADSHEET_COLUMN.map((column) => ({ + ...column, + isActive: properties + ? column.propertyName === "labels" + ? properties[column.propertyName as keyof Properties] + : column.propertyName === "title" + ? true + : properties[column.propertyName as keyof Properties] + : false, + })); + + const gridTemplateColumns = columnData + .filter((column) => column.isActive) + .map((column) => column.colSize) + .join(" "); + + return ( +
    +
    + +
    + {spreadsheetIssues ? ( +
    + {spreadsheetIssues.map((issue: IIssue, index) => ( + + ))} +
    + {type === "issue" ? ( + + ) : ( + !isCompleted && ( + + + Add Issue + + } + position="left" + menuItemsClassName="left-5 !w-36" + noBorder + > + { + const e = new KeyboardEvent("keydown", { key: "c" }); + document.dispatchEvent(e); + }} + > + Create new + + {openIssuesListModal && ( + + Add an existing issue + + )} + + ) + )} +
    +
    + ) : ( + + )} +
    + ); +}; diff --git a/apps/app/components/cycles/active-cycle-details.tsx b/apps/app/components/cycles/active-cycle-details.tsx index 5a0c1cb58..e7a1990e9 100644 --- a/apps/app/components/cycles/active-cycle-details.tsx +++ b/apps/app/components/cycles/active-cycle-details.tsx @@ -101,6 +101,13 @@ export const ActiveCycleDetails: React.FC = () => { : null ) as { data: IIssue[] | undefined }; + if (!currentCycle) + return ( + + + + ); + if (!cycle) return (
    @@ -226,7 +233,7 @@ export const ActiveCycleDetails: React.FC = () => { /> -

    +

    {truncateText(cycle.name, 70)}

    @@ -395,82 +402,87 @@ export const ActiveCycleDetails: React.FC = () => {
    High Priority Issues
    {issues ? ( - issues.map((issue) => ( -
    -
    -
    + issues.length > 0 ? ( + issues.map((issue) => ( +
    +
    +
    + + + {issue.project_detail?.identifier}-{issue.sequence_id} + + +
    - - {issue.project_detail?.identifier}-{issue.sequence_id} + + {truncateText(issue.name, 30)}
    - - - {truncateText(issue.name, 30)} - - -
    - -
    -
    - {getPriorityIcon(issue.priority, "text-sm")} -
    - {issue.label_details.length > 0 ? ( -
    - {issue.label_details.map((label) => ( - - - {label.name} - - ))} +
    +
    + {getPriorityIcon(issue.priority, "text-sm")}
    - ) : ( - "" - )} -
    - {issue.assignees && - issue.assignees.length > 0 && - Array.isArray(issue.assignees) ? ( -
    - + {issue.label_details.length > 0 ? ( +
    + {issue.label_details.map((label) => ( + + + {label.name} + + ))}
    ) : ( "" )} +
    + {issue.assignees && + issue.assignees.length > 0 && + Array.isArray(issue.assignees) ? ( +
    + +
    + ) : ( + "" + )} +
    + )) + ) : ( +
    + No issues present in the cycle.
    - )) + ) ) : ( @@ -481,27 +493,29 @@ export const ActiveCycleDetails: React.FC = () => {
    -
    -
    -
    issue?.state_detail?.group === "completed") - ?.length / - issues.length) * - 100 ?? 0 - }%`, - }} - /> + {issues && issues.length > 0 && ( +
    +
    +
    issue?.state_detail?.group === "completed") + ?.length / + issues.length) * + 100 ?? 0 + }%`, + }} + /> +
    +
    + {issues?.filter((issue) => issue?.state_detail?.group === "completed")?.length} of{" "} + {issues?.length} +
    -
    - {issues?.filter((issue) => issue?.state_detail?.group === "completed")?.length} of{" "} - {issues?.length} -
    -
    + )}
    diff --git a/apps/app/components/cycles/active-cycle-stats.tsx b/apps/app/components/cycles/active-cycle-stats.tsx index a01293d43..30b69ffaf 100644 --- a/apps/app/components/cycles/active-cycle-stats.tsx +++ b/apps/app/components/cycles/active-cycle-stats.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { Fragment } from "react"; // headless ui import { Tab } from "@headlessui/react"; @@ -32,6 +32,7 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { return ( { switch (i) { @@ -68,81 +69,87 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { Labels - - - {cycle.distribution.assignees.map((assignee, index) => { - if (assignee.assignee_id) - return ( - - - {assignee.first_name} -
    - } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - else - return ( - -
    - User 0 ? ( + + + {cycle.distribution.assignees.map((assignee, index) => { + if (assignee.assignee_id) + return ( + + + {assignee.first_name}
    - No assignee -
    - } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - })} - - - {cycle.distribution.labels.map((label, index) => ( - - - {label.label_name ?? "No labels"} -
    - } - completed={label.completed_issues} - total={label.total_issues} - /> - ))} - - + ); + else + return ( + +
    + User +
    + No assignee +
    + } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + })} + + + {cycle.distribution.labels.map((label, index) => ( + + + {label.label_name ?? "No labels"} +
    + } + completed={label.completed_issues} + total={label.total_issues} + /> + ))} + + + ) : ( +
    + No issues present in the cycle. +
    + )} ); }; diff --git a/apps/app/components/cycles/delete-cycle-modal.tsx b/apps/app/components/cycles/delete-cycle-modal.tsx index 3f2de2913..d60e3ddce 100644 --- a/apps/app/components/cycles/delete-cycle-modal.tsx +++ b/apps/app/components/cycles/delete-cycle-modal.tsx @@ -143,7 +143,7 @@ export const DeleteCycleModal: React.FC = ({

    Are you sure you want to delete cycle-{" "} - + {data?.name} ? All of the data related to the cycle will be permanently removed. This diff --git a/apps/app/components/cycles/sidebar.tsx b/apps/app/components/cycles/sidebar.tsx index d61bd1943..e4b48869f 100644 --- a/apps/app/components/cycles/sidebar.tsx +++ b/apps/app/components/cycles/sidebar.tsx @@ -408,7 +408,11 @@ export const CycleDetailsSidebar: React.FC = ({

    -

    {cycle.name}

    +
    +

    + {cycle.name} +

    +
    {!isCompleted && ( setCycleDeleteModal(true)}> @@ -427,7 +431,7 @@ export const CycleDetailsSidebar: React.FC = ({
    - + {cycle.description}
    diff --git a/apps/app/components/cycles/single-cycle-card.tsx b/apps/app/components/cycles/single-cycle-card.tsx index c6a6365b0..c00429a43 100644 --- a/apps/app/components/cycles/single-cycle-card.tsx +++ b/apps/app/components/cycles/single-cycle-card.tsx @@ -150,8 +150,8 @@ export const SingleCycleCard: React.FC = ({ }`} /> - -

    + +

    {truncateText(cycle.name, 15)}

    diff --git a/apps/app/components/cycles/single-cycle-list.tsx b/apps/app/components/cycles/single-cycle-list.tsx index f8a1fbf28..423580383 100644 --- a/apps/app/components/cycles/single-cycle-list.tsx +++ b/apps/app/components/cycles/single-cycle-list.tsx @@ -172,13 +172,19 @@ export const SingleCycleList: React.FC = ({ : "" }`} /> -
    - -

    +
    + +

    {truncateText(cycle.name, 70)}

    -

    {cycle.description}

    +

    + {cycle.description} +

    @@ -282,12 +288,18 @@ export const SingleCycleList: React.FC = ({ > {cycleStatus === "current" ? ( - - - {Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} % - + {cycle.total_issues > 0 ? ( + <> + + + {Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} % + + + ) : ( + No issues present + )} ) : cycleStatus === "upcoming" ? ( diff --git a/apps/app/components/estimates/delete-estimate-modal.tsx b/apps/app/components/estimates/delete-estimate-modal.tsx index c456ceab6..5a4f9ccfa 100644 --- a/apps/app/components/estimates/delete-estimate-modal.tsx +++ b/apps/app/components/estimates/delete-estimate-modal.tsx @@ -74,9 +74,9 @@ export const DeleteEstimateModal: React.FC = ({

    -

    +

    Are you sure you want to delete estimate-{" "} - {data.name} + {data.name} {""}? All of the data related to the estiamte will be permanently removed. This action cannot be undone.

    diff --git a/apps/app/components/gantt-chart/blocks/index.tsx b/apps/app/components/gantt-chart/blocks/index.tsx index d5eadf2a0..31e7839cc 100644 --- a/apps/app/components/gantt-chart/blocks/index.tsx +++ b/apps/app/components/gantt-chart/blocks/index.tsx @@ -18,7 +18,7 @@ export const GanttChartBlocks: FC<{ return (
    diff --git a/apps/app/components/inbox/accept-issue-modal.tsx b/apps/app/components/inbox/accept-issue-modal.tsx new file mode 100644 index 000000000..6427c562c --- /dev/null +++ b/apps/app/components/inbox/accept-issue-modal.tsx @@ -0,0 +1,92 @@ +import React, { useState } from "react"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// icons +import { CheckCircleIcon } from "@heroicons/react/24/outline"; +// ui +import { SecondaryButton, PrimaryButton } from "components/ui"; +// types +import type { IInboxIssue } from "types"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + data: IInboxIssue | undefined; + onSubmit: () => Promise; +}; + +export const AcceptIssueModal: React.FC = ({ isOpen, handleClose, data, onSubmit }) => { + const [isAccepting, setIsAccepting] = useState(false); + + const onClose = () => { + setIsAccepting(false); + handleClose(); + }; + + const handleAccept = () => { + setIsAccepting(true); + + onSubmit().finally(() => setIsAccepting(false)); + }; + + return ( + + + +
    + + +
    +
    + + +
    +
    + + + +

    Accept Issue

    +
    +
    + +

    + Are you sure you want to accept issue{" "} + + {data?.project_detail?.identifier}-{data?.sequence_id} + + {""}? Once accepted, this issue will be added to the project issues list. +

    +
    +
    + Cancel + + {isAccepting ? "Accepting..." : "Accept Issue"} + +
    +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/app/components/inbox/decline-issue-modal.tsx b/apps/app/components/inbox/decline-issue-modal.tsx index 941841659..11f1db5de 100644 --- a/apps/app/components/inbox/decline-issue-modal.tsx +++ b/apps/app/components/inbox/decline-issue-modal.tsx @@ -72,7 +72,7 @@ export const DeclineIssueModal: React.FC = ({ isOpen, handleClose, data,

    Are you sure you want to decline issue{" "} - + {data?.project_detail?.identifier}-{data?.sequence_id} {""}? This action cannot be undone. diff --git a/apps/app/components/inbox/delete-issue-modal.tsx b/apps/app/components/inbox/delete-issue-modal.tsx index c6f5320a2..f188ff1aa 100644 --- a/apps/app/components/inbox/delete-issue-modal.tsx +++ b/apps/app/components/inbox/delete-issue-modal.tsx @@ -127,7 +127,7 @@ export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data })

    Are you sure you want to delete issue{" "} - + {data?.project_detail?.identifier}-{data?.sequence_id} {""}? The issue will only be deleted from the inbox and this action cannot be diff --git a/apps/app/components/inbox/filters-dropdown.tsx b/apps/app/components/inbox/filters-dropdown.tsx index b567baed0..c12fcb8d2 100644 --- a/apps/app/components/inbox/filters-dropdown.tsx +++ b/apps/app/components/inbox/filters-dropdown.tsx @@ -1,63 +1,81 @@ +// hooks +import useInboxView from "hooks/use-inbox-view"; // ui import { MultiLevelDropdown } from "components/ui"; // icons import { getPriorityIcon } from "components/icons"; -// types -import { IInboxFilterOptions } from "types"; // constants import { PRIORITIES } from "constants/project"; import { INBOX_STATUS } from "constants/inbox"; -type Props = { - filters: Partial; - onSelect: (option: any) => void; - direction?: "left" | "right"; - height?: "sm" | "md" | "rg" | "lg"; -}; +export const FiltersDropdown: React.FC = () => { + const { filters, setFilters, filtersLength } = useInboxView(); -export const FiltersDropdown: React.FC = ({ filters, onSelect, direction, height }) => ( - ({ - id: priority ?? "none", - label: ( -

    - {getPriorityIcon(priority)} {priority ?? "None"} -
    - ), - value: { - key: "priority", - value: priority, - }, - selected: filters?.priority?.includes(priority ?? "none"), - })), - ], - }, - { - id: "inbox_status", - label: "Status", - value: INBOX_STATUS.map((status) => status.value), - children: [ - ...INBOX_STATUS.map((status) => ({ - id: status.key, - label: status.label, - value: { - key: "inbox_status", - value: status.value, - }, - selected: filters?.inbox_status?.includes(status.value), - })), - ], - }, - ]} - /> -); + return ( +
    + { + const key = option.key as keyof typeof filters; + + const valueExists = (filters[key] as any[])?.includes(option.value); + + if (valueExists) { + setFilters({ + [option.key]: ((filters[key] ?? []) as any[])?.filter((val) => val !== option.value), + }); + } else { + setFilters({ + [option.key]: [...((filters[key] ?? []) as any[]), option.value], + }); + } + }} + direction="right" + height="rg" + options={[ + { + id: "priority", + label: "Priority", + value: PRIORITIES, + children: [ + ...PRIORITIES.map((priority) => ({ + id: priority === null ? "null" : priority, + label: ( +
    + {getPriorityIcon(priority)} {priority ?? "None"} +
    + ), + value: { + key: "priority", + value: priority === null ? "null" : priority, + }, + selected: filters?.priority?.includes(priority === null ? "null" : priority), + })), + ], + }, + { + id: "inbox_status", + label: "Status", + value: INBOX_STATUS.map((status) => status.value), + children: [ + ...INBOX_STATUS.map((status) => ({ + id: status.key, + label: status.label, + value: { + key: "inbox_status", + value: status.value, + }, + selected: filters?.inbox_status?.includes(status.value), + })), + ], + }, + ]} + /> + {filtersLength > 0 && ( +
    + {filtersLength} +
    + )} +
    + ); +}; diff --git a/apps/app/components/inbox/inbox-action-headers.tsx b/apps/app/components/inbox/inbox-action-headers.tsx index 5ceaa8f2c..ac144ba0b 100644 --- a/apps/app/components/inbox/inbox-action-headers.tsx +++ b/apps/app/components/inbox/inbox-action-headers.tsx @@ -2,17 +2,28 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/router"; +import { mutate } from "swr"; + // react-datepicker import DatePicker from "react-datepicker"; // headless ui import { Popover } from "@headlessui/react"; // contexts import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import inboxServices from "services/inbox.service"; // hooks import useInboxView from "hooks/use-inbox-view"; import useUserAuth from "hooks/use-user-auth"; +import useToast from "hooks/use-toast"; // components -import { FiltersDropdown } from "components/inbox"; +import { + AcceptIssueModal, + DeclineIssueModal, + DeleteIssueModal, + FiltersDropdown, + SelectDuplicateInboxIssueModal, +} from "components/inbox"; // ui import { PrimaryButton, SecondaryButton } from "components/ui"; // icons @@ -26,47 +37,76 @@ import { TrashIcon, } from "@heroicons/react/24/outline"; // types -import type { IInboxIssue } from "types"; +import type { IInboxIssueDetail, TInboxStatus } from "types"; +// fetch-keys +import { INBOX_ISSUE_DETAILS } from "constants/fetch-keys"; -type Props = { - issueCount: number; - currentIssueIndex: number; - issue?: IInboxIssue; - onAccept: () => Promise; - onDecline: () => void; - onMarkAsDuplicate: () => void; - onSnooze: (date: Date | string) => void; - onDelete: () => void; -}; - -export const InboxActionHeader: React.FC = (props) => { - const { - issueCount, - currentIssueIndex, - onAccept, - onDecline, - onMarkAsDuplicate, - onSnooze, - onDelete, - issue, - } = props; - - const [isAccepting, setIsAccepting] = useState(false); +export const InboxActionHeader = () => { const [date, setDate] = useState(new Date()); + const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false); + const [acceptIssueModal, setAcceptIssueModal] = useState(false); + const [declineIssueModal, setDeclineIssueModal] = useState(false); + const [deleteIssueModal, setDeleteIssueModal] = useState(false); const router = useRouter(); - const { inboxIssueId } = router.query; + const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query; - const { memberRole } = useProjectMyMembership(); - const { filters, setFilters, filtersLength } = useInboxView(); const { user } = useUserAuth(); + const { memberRole } = useProjectMyMembership(); + const { issues: inboxIssues, mutate: mutateInboxIssues } = useInboxView(); + const { setToastAlert } = useToast(); - const handleAcceptIssue = () => { - setIsAccepting(true); + const markInboxStatus = async (data: TInboxStatus) => { + if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId) return; - onAccept().finally(() => setIsAccepting(false)); + mutate( + INBOX_ISSUE_DETAILS(inboxId as string, inboxIssueId as string), + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + issue_inbox: [{ ...prevData.issue_inbox[0], ...data }], + }; + }, + false + ); + mutateInboxIssues( + (prevData) => + (prevData ?? []).map((i) => + i.bridge_id === inboxIssueId + ? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] } + : i + ), + false + ); + + await inboxServices + .markInboxStatus( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)?.bridge_id!, + data, + user + ) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Something went wrong while updating inbox status. Please try again.", + }) + ) + .finally(() => { + mutate(INBOX_ISSUE_DETAILS(inboxId as string, inboxIssueId as string)); + mutateInboxIssues(); + }); }; + const issue = inboxIssues?.find((issue) => issue.bridge_id === inboxIssueId); + const currentIssueIndex = + inboxIssues?.findIndex((issue) => issue.bridge_id === inboxIssueId) ?? 0; + useEffect(() => { if (!issue?.issue_inbox[0].snoozed_till) return; @@ -82,163 +122,174 @@ export const InboxActionHeader: React.FC = (props) => { tomorrow.setDate(today.getDate() + 1); return ( -
    -
    -
    - -

    Inbox

    -
    -
    - { - const key = option.key as keyof typeof filters; - - const valueExists = (filters[key] as any[])?.includes(option.value); - - if (valueExists) { - setFilters({ - [option.key]: ((filters[key] ?? []) as any[])?.filter( - (val) => val !== option.value - ), - }); - } else { - setFilters({ - [option.key]: [...((filters[key] ?? []) as any[]), option.value], - }); - } - }} - direction="right" - height="rg" - /> - {filtersLength > 0 && ( -
    - {filtersLength} -
    - )} -
    -
    - {inboxIssueId && ( -
    -
    - - -
    - {currentIssueIndex + 1}/{issueCount} -
    + <> + setSelectDuplicateIssue(false)} + value={ + inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)?.issue_inbox[0] + .duplicate_to + } + onSubmit={(dupIssueId: string) => { + markInboxStatus({ + status: 2, + duplicate_to: dupIssueId, + }).finally(() => setSelectDuplicateIssue(false)); + }} + /> + setAcceptIssueModal(false)} + data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)} + onSubmit={async () => { + await markInboxStatus({ + status: 1, + }).finally(() => setAcceptIssueModal(false)); + }} + /> + setDeclineIssueModal(false)} + data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)} + onSubmit={async () => { + await markInboxStatus({ + status: -1, + }).finally(() => setDeclineIssueModal(false)); + }} + /> + setDeleteIssueModal(false)} + data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)} + /> +
    +
    +
    + +

    Inbox

    -
    - {isAllowed && ( -
    +
    + {inboxIssueId && ( +
    +
    + + +
    + {currentIssueIndex + 1}/{inboxIssues?.length ?? 0} +
    +
    +
    + {isAllowed && (issueStatus === 0 || issueStatus === -2) && ( +
    + + + + + Snooze + + + + {({ close }) => ( +
    + { + if (!val) return; + setDate(val); + }} + dateFormat="dd-MM-yyyy" + minDate={tomorrow} + inline + /> + { + close(); + markInboxStatus({ + status: 0, + snoozed_till: new Date(date), + }); + }} + > + Snooze + +
    + )} +
    +
    +
    + )} + {isAllowed && issueStatus === -2 && ( +
    + setSelectDuplicateIssue(true)} > - - - Snooze - - - - {({ close }) => ( -
    - { - if (!val) return; - setDate(val); - }} - dateFormat="dd-MM-yyyy" - minDate={tomorrow} - inline - /> - { - close(); - onSnooze(date); - }} - > - Snooze - -
    - )} -
    - -
    - )} - {isAllowed && ( -
    - - - Mark as duplicate - - - - {isAccepting ? "Accepting..." : "Accept"} - - - - Decline - -
    - )} - {(isAllowed || user?.id === issue?.created_by) && ( -
    - - - Delete - -
    - )} + + Mark as duplicate + +
    + )} + {isAllowed && (issueStatus === 0 || issueStatus === -2) && ( +
    + setAcceptIssueModal(true)} + > + + Accept + +
    + )} + {isAllowed && issueStatus === -2 && ( +
    + setDeclineIssueModal(true)} + > + + Decline + +
    + )} + {(isAllowed || user?.id === issue?.created_by) && ( +
    + setDeleteIssueModal(true)} + > + + Delete + +
    + )} +
    -
    - )} -
    + )} +
    + ); }; diff --git a/apps/app/components/inbox/inbox-issue-card.tsx b/apps/app/components/inbox/inbox-issue-card.tsx index 814f221a5..072647ae3 100644 --- a/apps/app/components/inbox/inbox-issue-card.tsx +++ b/apps/app/components/inbox/inbox-issue-card.tsx @@ -4,13 +4,21 @@ import Link from "next/link"; // ui import { Tooltip } from "components/ui"; // icons -import { getPriorityIcon, getStateGroupIcon } from "components/icons"; -import { CalendarDaysIcon, ClockIcon } from "@heroicons/react/24/outline"; +import { getPriorityIcon } from "components/icons"; +import { + CalendarDaysIcon, + CheckCircleIcon, + ClockIcon, + DocumentDuplicateIcon, + ExclamationTriangleIcon, + XCircleIcon, +} from "@heroicons/react/24/outline"; // helpers import { renderShortNumericDateFormat } from "helpers/date-time.helper"; -import { addSpaceIfCamelCase } from "helpers/string.helper"; // types import type { IInboxIssue } from "types"; +// constants +import { INBOX_STATUS } from "constants/inbox"; type Props = { issue: IInboxIssue; @@ -30,93 +38,88 @@ export const InboxIssueCard: React.FC = (props) => { href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issue.bridge_id}`} > - -
    -
    -

    - {issue.project_detail?.identifier}-{issue.sequence_id} -

    -
    {issue.name}
    -
    -
    - -
    - {getStateGroupIcon( - issue.state_detail?.group ?? "backlog", - "14", - "14", - issue.state_detail?.color - )} - {issue.state_detail?.name ?? "Triage"} -
    -
    - -
    - {getPriorityIcon( - issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", - "text-sm" - )} -
    -
    - -
    - - {renderShortNumericDateFormat(issue.created_at ?? "")} -
    -
    - {issue.issue_inbox[0].snoozed_till && ( -
    - - - Snoozed till {renderShortNumericDateFormat(issue.issue_inbox[0].snoozed_till)} - -
    - )} -
    +
    +

    + {issue.project_detail?.identifier}-{issue.sequence_id} +

    +
    {issue.name}
    - +
    + +
    + {getPriorityIcon( + issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", + "text-sm" + )} +
    +
    + +
    + + {renderShortNumericDateFormat(issue.created_at ?? "")} +
    +
    +
    +
    s.value === issueStatus)?.textColor + }`} + > + {issueStatus === -2 ? ( + <> + + Pending + + ) : issueStatus === -1 ? ( + <> + + Declined + + ) : issueStatus === 0 ? ( + <> + + + {new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date() + ? "Snoozed date passed" + : "Snoozed"} + + + ) : issueStatus === 1 ? ( + <> + + Accepted + + ) : ( + <> + + Duplicate + + )} +
    +
    ); diff --git a/apps/app/components/inbox/inbox-main-content.tsx b/apps/app/components/inbox/inbox-main-content.tsx index 83948495c..a05239a95 100644 --- a/apps/app/components/inbox/inbox-main-content.tsx +++ b/apps/app/components/inbox/inbox-main-content.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect } from "react"; -import { useRouter } from "next/router"; +import Router, { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; @@ -29,6 +29,7 @@ import { ClockIcon, DocumentDuplicateIcon, ExclamationTriangleIcon, + InboxIcon, XCircleIcon, } from "@heroicons/react/24/outline"; // helpers @@ -55,7 +56,7 @@ export const InboxMainContent: React.FC = () => { const { user } = useUserAuth(); const { memberRole } = useProjectMyMembership(); - const { params } = useInboxView(); + const { params, issues: inboxIssues } = useInboxView(); const { reset, control, watch } = useForm({ defaultValues, @@ -76,17 +77,6 @@ export const InboxMainContent: React.FC = () => { : null ); - useEffect(() => { - if (!issueDetails || !inboxIssueId) return; - - reset({ - ...issueDetails, - assignees_list: - issueDetails.assignees_list ?? (issueDetails.assignee_details ?? []).map((user) => user.id), - labels_list: issueDetails.labels_list ?? issueDetails.labels, - }); - }, [issueDetails, reset, inboxIssueId]); - const submitChanges = useCallback( async (formData: Partial) => { if (!workspaceSlug || !projectId || !inboxIssueId || !inboxId || !issueDetails) return; @@ -144,8 +134,86 @@ export const InboxMainContent: React.FC = () => { ] ); + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!inboxIssues || !inboxIssueId) return; + + const currentIssueIndex = inboxIssues.findIndex((issue) => issue.bridge_id === inboxIssueId); + + switch (e.key) { + case "ArrowUp": + Router.push({ + pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, + query: { + inboxIssueId: + currentIssueIndex === 0 + ? inboxIssues[inboxIssues.length - 1].bridge_id + : inboxIssues[currentIssueIndex - 1].bridge_id, + }, + }); + break; + case "ArrowDown": + Router.push({ + pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, + query: { + inboxIssueId: + currentIssueIndex === inboxIssues.length - 1 + ? inboxIssues[0].bridge_id + : inboxIssues[currentIssueIndex + 1].bridge_id, + }, + }); + break; + default: + break; + } + }, + [workspaceSlug, projectId, inboxIssueId, inboxId, inboxIssues] + ); + + useEffect(() => { + document.addEventListener("keydown", onKeyDown); + + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [onKeyDown]); + + useEffect(() => { + if (!issueDetails || !inboxIssueId) return; + + reset({ + ...issueDetails, + assignees_list: + issueDetails.assignees_list ?? (issueDetails.assignee_details ?? []).map((user) => user.id), + labels_list: issueDetails.labels_list ?? issueDetails.labels, + }); + }, [issueDetails, reset, inboxIssueId]); + const issueStatus = issueDetails?.issue_inbox[0].status; + if (!inboxIssueId) + return ( +
    +
    +
    + + {inboxIssues && inboxIssues.length > 0 ? ( + + {inboxIssues?.length} issues found. Select an issue from the sidebar to view its + details. + + ) : ( + + No issues found. Use{" "} +
    C
    shortcut to + create a new issue +
    + )} +
    +
    +
    + ); + return ( <> {issueDetails ? ( @@ -154,17 +222,17 @@ export const InboxMainContent: React.FC = () => {
    @@ -266,6 +334,4 @@ export const InboxMainContent: React.FC = () => { )} ); - - return null; }; diff --git a/apps/app/components/inbox/index.ts b/apps/app/components/inbox/index.ts index 7cdd8ee9d..38cea0348 100644 --- a/apps/app/components/inbox/index.ts +++ b/apps/app/components/inbox/index.ts @@ -1,3 +1,4 @@ +export * from "./accept-issue-modal"; export * from "./decline-issue-modal"; export * from "./delete-issue-modal"; export * from "./filters-dropdown"; diff --git a/apps/app/components/inbox/issues-list-sidebar.tsx b/apps/app/components/inbox/issues-list-sidebar.tsx index 9f5c85db1..6126be117 100644 --- a/apps/app/components/inbox/issues-list-sidebar.tsx +++ b/apps/app/components/inbox/issues-list-sidebar.tsx @@ -11,7 +11,7 @@ export const IssuesListSidebar = () => { const router = useRouter(); const { inboxIssueId } = router.query; - const { issues: inboxIssues } = useInboxView(); + const { issues: inboxIssues, filtersLength } = useInboxView(); return (
    @@ -29,7 +29,8 @@ export const IssuesListSidebar = () => {
    ) : (
    - No issues found for the selected filters. Try changing the filters. + {filtersLength > 0 && + "No issues found for the selected filters. Try changing the filters."}
    ) ) : ( diff --git a/apps/app/components/integration/delete-import-modal.tsx b/apps/app/components/integration/delete-import-modal.tsx index cd0b12a2a..57af3fbfc 100644 --- a/apps/app/components/integration/delete-import-modal.tsx +++ b/apps/app/components/integration/delete-import-modal.tsx @@ -104,7 +104,7 @@ export const DeleteImportModal: React.FC = ({ isOpen, handleClose, data,

    Are you sure you want to delete import from{" "} - + {data?.service} ? All of the data related to the import will be permanently removed. This diff --git a/apps/app/components/issues/delete-issue-modal.tsx b/apps/app/components/issues/delete-issue-modal.tsx index d0ef4b6e9..c4fb3b00a 100644 --- a/apps/app/components/issues/delete-issue-modal.tsx +++ b/apps/app/components/issues/delete-issue-modal.tsx @@ -12,17 +12,19 @@ import issueServices from "services/issues.service"; import useIssuesView from "hooks/use-issues-view"; import useCalendarIssuesView from "hooks/use-calendar-issues-view"; import useToast from "hooks/use-toast"; +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; // icons import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; // ui import { SecondaryButton, DangerButton } from "components/ui"; // types -import type { IIssue, ICurrentUserResponse } from "types"; +import type { IIssue, ICurrentUserResponse, ISubIssueResponse } from "types"; // fetch-keys import { CYCLE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS, + SUB_ISSUES, VIEW_ISSUES, } from "constants/fetch-keys"; @@ -41,6 +43,7 @@ export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data, u const { issueView, params } = useIssuesView(); const { params: calendarParams } = useCalendarIssuesView(); + const { params: spreadsheetParams } = useSpreadsheetIssuesView(); const { setToastAlert } = useToast(); @@ -74,6 +77,36 @@ export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data, u (prevData) => (prevData ?? []).filter((p) => p.id !== data.id), false ); + } else if (issueView === "spreadsheet") { + const spreadsheetFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams) + : viewId + ? VIEW_ISSUES(viewId.toString(), spreadsheetParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", spreadsheetParams); + if (data.parent) { + mutate( + SUB_ISSUES(data.parent.toString()), + (prevData) => { + if (!prevData) return prevData; + const updatedArray = (prevData.sub_issues ?? []).filter((i) => i.id !== data.id); + + return { + ...prevData, + sub_issues: updatedArray, + }; + }, + false + ); + mutate(spreadsheetFetchKey); + } else { + mutate( + spreadsheetFetchKey, + (prevData) => (prevData ?? []).filter((p) => p.id !== data.id), + false + ); + } } else { if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); else if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); @@ -135,7 +168,7 @@ export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data, u

    Are you sure you want to delete issue{" "} - + {data?.project_detail.identifier}-{data?.sequence_id} {""}? All of the data related to the issue will be permanently removed. This diff --git a/apps/app/components/issues/form.tsx b/apps/app/components/issues/form.tsx index e59e9c2cb..8bf64e6ff 100644 --- a/apps/app/components/issues/form.tsx +++ b/apps/app/components/issues/form.tsx @@ -23,7 +23,6 @@ import { IssueStateSelect, } from "components/issues/select"; import { CreateStateModal } from "components/states"; -import { CreateUpdateCycleModal } from "components/cycles"; import { CreateLabelModal } from "components/labels"; // ui import { @@ -73,7 +72,6 @@ const defaultValues: Partial = { description_html: "

    ", estimate_point: null, state: "", - cycle: null, priority: null, assignees: [], assignees_list: [], @@ -122,7 +120,6 @@ export const IssueForm: FC = ({ }) => { // states const [mostSimilarIssue, setMostSimilarIssue] = useState(); - const [cycleModal, setCycleModal] = useState(false); const [stateModal, setStateModal] = useState(false); const [labelModal, setLabelModal] = useState(false); const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); @@ -148,7 +145,7 @@ export const IssueForm: FC = ({ setValue, setFocus, } = useForm({ - defaultValues, + defaultValues: initialData ?? defaultValues, reValidateMode: "onChange", }); @@ -163,6 +160,8 @@ export const IssueForm: FC = ({ const handleCreateUpdateIssue = async (formData: Partial) => { await handleFormSubmit(formData); + setGptAssistantModal(false); + reset({ ...defaultValues, project: projectId, @@ -198,7 +197,7 @@ export const IssueForm: FC = ({ projectId as string, { prompt: issueName, - task: "Generate a proper description for this issue in context of a project management software.", + task: "Generate a proper description for this issue.", }, user ) @@ -250,11 +249,6 @@ export const IssueForm: FC = ({ projectId={projectId} user={user} /> - setCycleModal(false)} - user={user} - /> setLabelModal(false)} diff --git a/apps/app/components/issues/modal.tsx b/apps/app/components/issues/modal.tsx index 5ef39e13a..b83bbc480 100644 --- a/apps/app/components/issues/modal.tsx +++ b/apps/app/components/issues/modal.tsx @@ -17,6 +17,7 @@ import useIssuesView from "hooks/use-issues-view"; import useCalendarIssuesView from "hooks/use-calendar-issues-view"; import useToast from "hooks/use-toast"; import useInboxView from "hooks/use-inbox-view"; +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; // components import { IssueForm } from "components/issues"; // types @@ -79,13 +80,19 @@ export const CreateUpdateIssueModal: React.FC = ({ const { params: calendarParams } = useCalendarIssuesView(); const { order_by, group_by, ...viewGanttParams } = params; const { params: inboxParams } = useInboxView(); - - if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string }; - if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string }; + const { params: spreadsheetParams } = useSpreadsheetIssuesView(); const { user } = useUser(); const { setToastAlert } = useToast(); + if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string }; + if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string }; + if (router.asPath.includes("my-issues")) + prePopulateData = { + ...prePopulateData, + assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""], + }; + const { data: issues } = useSWR( workspaceSlug && activeProject ? PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? "") @@ -119,7 +126,7 @@ export const CreateUpdateIssueModal: React.FC = ({ }, [handleClose]); const addIssueToCycle = async (issueId: string, cycleId: string) => { - if (!workspaceSlug || !projectId) return; + if (!workspaceSlug || !activeProject) return; await issuesService .addIssueToCycle( @@ -140,7 +147,7 @@ export const CreateUpdateIssueModal: React.FC = ({ }; const addIssueToModule = async (issueId: string, moduleId: string) => { - if (!workspaceSlug || !projectId) return; + if (!workspaceSlug || !activeProject) return; await modulesService .addIssuesToModule( @@ -161,7 +168,7 @@ export const CreateUpdateIssueModal: React.FC = ({ }; const addIssueToInbox = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !inboxId) return; + if (!workspaceSlug || !activeProject || !inboxId) return; const payload = { issue: { @@ -176,7 +183,7 @@ export const CreateUpdateIssueModal: React.FC = ({ await inboxServices .createInboxIssue( workspaceSlug.toString(), - projectId.toString(), + activeProject.toString(), inboxId.toString(), payload, user @@ -188,6 +195,10 @@ export const CreateUpdateIssueModal: React.FC = ({ message: "Issue created successfully.", }); + router.push( + `/${workspaceSlug}/projects/${activeProject}/inbox/${inboxId}?inboxIssueId=${res.issue_inbox[0].id}` + ); + mutate(INBOX_ISSUES(inboxId.toString(), inboxParams)); }) .catch(() => { @@ -205,7 +216,15 @@ export const CreateUpdateIssueModal: React.FC = ({ ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams) : viewId ? VIEW_ISSUES(viewId.toString(), calendarParams) - : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", calendarParams); + : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", calendarParams); + + const spreadsheetFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams) + : viewId + ? VIEW_ISSUES(viewId.toString(), spreadsheetParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", spreadsheetParams); const ganttFetchKey = cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString()) @@ -213,10 +232,10 @@ export const CreateUpdateIssueModal: React.FC = ({ ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString()) : viewId ? VIEW_ISSUES(viewId.toString(), viewGanttParams) - : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? ""); + : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? ""); const createIssue = async (payload: Partial) => { - if (!workspaceSlug || !projectId) return; + if (!workspaceSlug || !activeProject) return; if (inboxId) await addIssueToInbox(payload); else @@ -230,6 +249,7 @@ export const CreateUpdateIssueModal: React.FC = ({ if (issueView === "calendar") mutate(calendarFetchKey); if (issueView === "gantt_chart") mutate(ganttFetchKey); + if (issueView === "spreadsheet") mutate(spreadsheetFetchKey); setToastAlert({ type: "success", @@ -237,7 +257,8 @@ export const CreateUpdateIssueModal: React.FC = ({ message: "Issue created successfully.", }); - if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE); + if (payload.assignees_list?.some((assignee) => assignee === user?.id)) + mutate(USER_ISSUE(workspaceSlug as string)); if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); }) @@ -260,6 +281,8 @@ export const CreateUpdateIssueModal: React.FC = ({ mutate(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); } else { if (issueView === "calendar") mutate(calendarFetchKey); + if (issueView === "spreadsheet") mutate(spreadsheetFetchKey); + if (payload.parent) mutate(SUB_ISSUES(payload.parent.toString())); mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); } @@ -328,7 +351,7 @@ export const CreateUpdateIssueModal: React.FC = ({ = ({ issue, properties, projectId const { setToastAlert } = useToast(); const partialUpdateIssue = useCallback( - (formData: Partial, issueId: string) => { + (formData: Partial, issue: IIssue) => { if (!workspaceSlug) return; mutate( USER_ISSUE(workspaceSlug as string), (prevData) => prevData?.map((p) => { - if (p.id === issueId) return { ...p, ...formData }; + if (p.id === issue.id) return { ...p, ...formData }; return p; }), @@ -59,7 +59,7 @@ export const MyIssuesListItem: React.FC = ({ issue, properties, projectId ); issuesService - .patchIssue(workspaceSlug as string, projectId as string, issueId, formData, user) + .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user) .then((res) => { mutate(USER_ISSUE(workspaceSlug as string)); }) diff --git a/apps/app/components/issues/parent-issues-list-modal.tsx b/apps/app/components/issues/parent-issues-list-modal.tsx index 7510d5e75..b93c07d3c 100644 --- a/apps/app/components/issues/parent-issues-list-modal.tsx +++ b/apps/app/components/issues/parent-issues-list-modal.tsx @@ -1,23 +1,28 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; // headless ui import { Combobox, Dialog, Transition } from "@headlessui/react"; -// icons -import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; -// ui -import { PrimaryButton, SecondaryButton } from "components/ui"; -// types -import { IIssue } from "types"; +// services +import projectService from "services/project.service"; +// hooks +import useDebounce from "hooks/use-debounce"; +// components import { LayerDiagonalIcon } from "components/icons"; +// ui +import { Loader } from "components/ui"; +// icons +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +// types +import { ISearchIssueResponse } from "types"; type Props = { isOpen: boolean; handleClose: () => void; value?: any; onChange: (...event: any[]) => void; - issues: IIssue[]; - title?: string; - multiple?: boolean; + issueId?: string; customDisplay?: JSX.Element; }; @@ -26,28 +31,60 @@ export const ParentIssuesListModal: React.FC = ({ handleClose: onClose, value, onChange, - issues, - title = "Issues", - multiple = false, + issueId, customDisplay, }) => { - const [query, setQuery] = useState(""); - const [values, setValues] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [issues, setIssues] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSearching, setIsSearching] = useState(false); + + const debouncedSearchTerm: string = useDebounce(searchTerm, 500); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; const handleClose = () => { onClose(); - setQuery(""); - setValues([]); + setSearchTerm(""); }; - const filteredIssues: IIssue[] = - query === "" - ? issues ?? [] - : issues?.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? []; + useEffect(() => { + if (!workspaceSlug || !projectId) return; + + setIsLoading(true); + + if (debouncedSearchTerm) { + setIsSearching(true); + + projectService + .projectIssuesSearch(workspaceSlug as string, projectId as string, { + search: debouncedSearchTerm, + parent: true, + issue_id: issueId, + }) + .then((res) => { + setIssues(res); + }) + .finally(() => { + setIsLoading(false); + setIsSearching(false); + }); + } else { + setIssues([]); + setIsLoading(false); + setIsSearching(false); + } + }, [debouncedSearchTerm, workspaceSlug, projectId, issueId]); return ( <> - setQuery("")} appear> + setSearchTerm("")} + appear + > = ({ leaveTo="opacity-0 scale-95" > - {multiple ? ( - <> - ({})} multiple> -
    -
    - {customDisplay &&
    {customDisplay}
    } - - {filteredIssues.length > 0 && ( -
  • - {query === "" && ( -

    {title}

    - )} -
      - {filteredIssues.map((issue) => ( - - `flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ - active ? "bg-brand-surface-2 text-brand-base" : "" - } ${selected ? "text-brand-base" : ""}` - } - > - {({ selected }) => ( - <> - - - - {issue.project_detail?.identifier}-{issue.sequence_id} - {" "} - {issue.id} - - )} - - ))} -
    -
  • - )} -
    + +
    +
    + {customDisplay &&
    {customDisplay}
    } + + {debouncedSearchTerm !== "" && ( +
    + Search results for{" "} + + {'"'} + {debouncedSearchTerm} + {'"'} + {" "} + in project: +
    + )} - {query !== "" && filteredIssues.length === 0 && ( -
    -
    - )} -
    -
    - Cancel - onChange(values)}>Add issues -
    - - ) : ( - -
    -
    - {customDisplay &&
    {customDisplay}
    } - - {filteredIssues.length > 0 ? ( -
  • - {query === "" && ( -

    {title}

    - )} -
      - {filteredIssues.map((issue) => ( - - `flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ - active ? "bg-brand-surface-2 text-brand-base" : "" - } ${selected ? "text-brand-base" : ""}` - } - onClick={handleClose} - > - <> - - - {issue.project_detail?.identifier}-{issue.sequence_id} - {" "} - {issue.name} - - - ))} -
    -
  • - ) : ( + {!isLoading && + issues.length === 0 && + searchTerm !== "" && + debouncedSearchTerm !== "" && (

    @@ -208,9 +152,45 @@ export const ParentIssuesListModal: React.FC = ({

    )} -
    -
    - )} + + {isLoading || isSearching ? ( + + + + + + + ) : ( +
      0 ? "p-2" : ""}`}> + {issues.map((issue) => ( + + `flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ + active ? "bg-brand-surface-2 text-brand-base" : "" + } ${selected ? "text-brand-base" : ""}` + } + onClick={handleClose} + > + <> + + + {issue.project__identifier}-{issue.sequence_id} + {" "} + {issue.name} + + + ))} +
    + )} + +
    diff --git a/apps/app/components/issues/select/label.tsx b/apps/app/components/issues/select/label.tsx index a7ada1133..e99eecc16 100644 --- a/apps/app/components/issues/select/label.tsx +++ b/apps/app/components/issues/select/label.tsx @@ -6,6 +6,10 @@ import useSWR from "swr"; // headless ui import { Combobox, Transition } from "@headlessui/react"; +// services +import issuesServices from "services/issues.service"; +// ui +import { IssueLabelsList } from "components/ui"; // icons import { CheckIcon, @@ -14,13 +18,10 @@ import { RectangleGroupIcon, TagIcon, } from "@heroicons/react/24/outline"; -// services -import issuesServices from "services/issues.service"; // types import type { IIssueLabels } from "types"; // fetch-keys import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; -import { IssueLabelsList } from "components/ui"; type Props = { setIsOpen: React.Dispatch>; diff --git a/apps/app/components/issues/select/parent.tsx b/apps/app/components/issues/select/parent.tsx index c04e89b92..d73cd4e73 100644 --- a/apps/app/components/issues/select/parent.tsx +++ b/apps/app/components/issues/select/parent.tsx @@ -21,7 +21,6 @@ export const IssueParentSelect: React.FC = ({ control, isOpen, setIsOpen, isOpen={isOpen} handleClose={() => setIsOpen(false)} onChange={onChange} - issues={issues} /> )} /> diff --git a/apps/app/components/issues/sidebar-select/blocked.tsx b/apps/app/components/issues/sidebar-select/blocked.tsx index c07f80817..de8985792 100644 --- a/apps/app/components/issues/sidebar-select/blocked.tsx +++ b/apps/app/components/issues/sidebar-select/blocked.tsx @@ -3,299 +3,135 @@ import React, { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; -import useSWR from "swr"; - // react-hook-form -import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form"; -// headless ui -import { Combobox, Dialog, Transition } from "@headlessui/react"; +import { UseFormWatch } from "react-hook-form"; // hooks import useToast from "hooks/use-toast"; -// services -import issuesService from "services/issues.service"; -// ui -import { PrimaryButton, SecondaryButton } from "components/ui"; +import useProjectDetails from "hooks/use-project-details"; +// components +import { ExistingIssuesListModal } from "components/core"; // icons -import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { BlockedIcon, LayerDiagonalIcon } from "components/icons"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { BlockedIcon } from "components/icons"; // types -import { IIssue, UserAuth } from "types"; -// fetch-keys -import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; - -type FormInput = { - blocked_issue_ids: string[]; -}; +import { BlockeIssue, IIssue, ISearchIssueResponse, UserAuth } from "types"; type Props = { + issueId?: string; submitChanges: (formData: Partial) => void; - issuesList: IIssue[]; watch: UseFormWatch; userAuth: UserAuth; }; export const SidebarBlockedSelect: React.FC = ({ + issueId, submitChanges, - issuesList, watch, userAuth, }) => { - const [query, setQuery] = useState(""); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); const { setToastAlert } = useToast(); + const { projectDetails } = useProjectDetails(); const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { data: issues } = useSWR( - workspaceSlug && projectId - ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) - : null, - workspaceSlug && projectId - ? () => issuesService.getIssues(workspaceSlug as string, projectId as string) - : null - ); - - const { - handleSubmit, - reset, - watch: watchBlocked, - setValue, - } = useForm({ - defaultValues: { - blocked_issue_ids: [], - }, - }); - const handleClose = () => { setIsBlockedModalOpen(false); - reset(); }; - const onSubmit: SubmitHandler = (data) => { - if (!data.blocked_issue_ids || data.blocked_issue_ids.length === 0) { + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (data.length === 0) { setToastAlert({ title: "Error", type: "error", - message: "Please select atleast one issue", + message: "Please select at least one issue", }); + return; } - if (!Array.isArray(data.blocked_issue_ids)) data.blocked_issue_ids = [data.blocked_issue_ids]; + const selectedIssues: BlockeIssue[] = data.map((i) => ({ + blocked_issue_detail: { + id: i.id, + name: i.name, + sequence_id: i.sequence_id, + }, + })); - const newBlocked = [...watch("blocked_list"), ...data.blocked_issue_ids]; - submitChanges({ blocks_list: newBlocked }); + const newBlocked = [...watch("blocked_issues"), ...selectedIssues]; + + submitChanges({ + blocked_issues: newBlocked, + blocks_list: newBlocked.map((i) => i.blocked_issue_detail?.id ?? ""), + }); handleClose(); }; - const filteredIssues: IIssue[] = - query === "" - ? issuesList - : issuesList.filter( - (issue) => - issue.name.toLowerCase().includes(query.toLowerCase()) || - `${issue.project_detail.identifier}-${issue.sequence_id}` - .toLowerCase() - .includes(query.toLowerCase()) - ); - const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( -
    -
    - -

    Blocked by

    -
    -
    -
    - {watch("blocked_list") && watch("blocked_list").length > 0 - ? watch("blocked_list").map((issue) => ( -
    - i.id === issue)?.id - }`} - > - - - {`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${ - issues?.find((i) => i.id === issue)?.sequence_id - }`} - - - -
    - )) - : null} + <> + setIsBlockedModalOpen(false)} + searchParams={{ blocker_blocked_by: true, issue_id: issueId }} + handleOnSubmit={onSubmit} + /> +
    +
    + +

    Blocked by

    - setQuery("")} - appear - > - - -
    - +
    +
    + {watch("blocked_issues") && watch("blocked_issues").length > 0 + ? watch("blocked_issues").map((issue) => ( +
    + + + + {`${projectDetails?.identifier}-${issue.blocked_issue_detail?.sequence_id}`} + + +
    -
    - + + +
    + )) + : null} +
    + +
    -
    + ); }; diff --git a/apps/app/components/issues/sidebar-select/blocker.tsx b/apps/app/components/issues/sidebar-select/blocker.tsx index aeede09bb..40f1eb10f 100644 --- a/apps/app/components/issues/sidebar-select/blocker.tsx +++ b/apps/app/components/issues/sidebar-select/blocker.tsx @@ -3,296 +3,137 @@ import React, { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; -import useSWR from "swr"; - // react-hook-form -import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form"; -// headless ui -import { Combobox, Dialog, Transition } from "@headlessui/react"; +import { UseFormWatch } from "react-hook-form"; // hooks import useToast from "hooks/use-toast"; -// services -import issuesServices from "services/issues.service"; -// ui -import { PrimaryButton, SecondaryButton } from "components/ui"; +import useProjectDetails from "hooks/use-project-details"; +// components +import { ExistingIssuesListModal } from "components/core"; // icons -import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { BlockerIcon, LayerDiagonalIcon } from "components/icons"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { BlockerIcon } from "components/icons"; // types -import { IIssue, UserAuth } from "types"; -// fetch-keys -import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; - -type FormInput = { - blocker_issue_ids: string[]; -}; +import { BlockeIssue, IIssue, ISearchIssueResponse, UserAuth } from "types"; type Props = { + issueId?: string; submitChanges: (formData: Partial) => void; - issuesList: IIssue[]; watch: UseFormWatch; userAuth: UserAuth; }; export const SidebarBlockerSelect: React.FC = ({ + issueId, submitChanges, - issuesList, watch, userAuth, }) => { - const [query, setQuery] = useState(""); const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); const { setToastAlert } = useToast(); + const { projectDetails } = useProjectDetails(); const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { data: issues } = useSWR( - workspaceSlug && projectId - ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) - : null, - workspaceSlug && projectId - ? () => issuesServices.getIssues(workspaceSlug as string, projectId as string) - : null - ); - - const { - handleSubmit, - reset, - watch: watchBlocker, - setValue, - } = useForm({ - defaultValues: { - blocker_issue_ids: [], - }, - }); - const handleClose = () => { setIsBlockerModalOpen(false); - reset(); }; - const onSubmit: SubmitHandler = (data) => { - if (!data.blocker_issue_ids || data.blocker_issue_ids.length === 0) { + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (data.length === 0) { setToastAlert({ - title: "Error", type: "error", - message: "Please select atleast one issue", + title: "Error!", + message: "Please select at least one issue.", }); + return; } - if (!Array.isArray(data.blocker_issue_ids)) data.blocker_issue_ids = [data.blocker_issue_ids]; + const selectedIssues: BlockeIssue[] = data.map((i) => ({ + blocker_issue_detail: { + id: i.id, + name: i.name, + sequence_id: i.sequence_id, + }, + })); - const newBlockers = [...watch("blockers_list"), ...data.blocker_issue_ids]; - submitChanges({ blockers_list: newBlockers }); + const newBlockers = [...watch("blocker_issues"), ...selectedIssues]; + + submitChanges({ + blocker_issues: newBlockers, + blockers_list: newBlockers.map((i) => i.blocker_issue_detail?.id ?? ""), + }); handleClose(); }; - const filteredIssues: IIssue[] = - query === "" - ? issuesList - : issuesList.filter( - (issue) => - issue.name.toLowerCase().includes(query.toLowerCase()) || - `${issue.project_detail.identifier}-${issue.sequence_id}` - .toLowerCase() - .includes(query.toLowerCase()) - ); - const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( -
    -
    - -

    Blocking

    -
    -
    -
    - {watch("blockers_list") && watch("blockers_list").length > 0 - ? watch("blockers_list").map((issue) => ( -
    - i.id === issue)?.id - }`} - > - - - {`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${ - issues?.find((i) => i.id === issue)?.sequence_id - }`} - - - -
    - )) - : null} + <> + setIsBlockerModalOpen(false)} + searchParams={{ blocker_blocked_by: true, issue_id: issueId }} + handleOnSubmit={onSubmit} + /> +
    +
    + +

    Blocking

    - setQuery("")} - appear - > - - -
    - - -
    - - - { - const selectedIssues = watchBlocker("blocker_issue_ids"); - if (selectedIssues.includes(val)) - setValue( - "blocker_issue_ids", - selectedIssues.filter((i) => i !== val) - ); - else setValue("blocker_issue_ids", [...selectedIssues, val]); - }} +
    +
    + {watch("blocker_issues") && watch("blocker_issues").length > 0 + ? watch("blocker_issues").map((issue) => ( +
    -
    -
    - - - {filteredIssues.length > 0 ? ( -
  • - {query === "" && ( -

    - Select blocker issues -

    - )} -
      - {filteredIssues.map((issue) => { - if ( - !watch("blockers_list").includes(issue.id) && - !watch("blocked_list").includes(issue.id) - ) - return ( - - `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ - active ? "bg-brand-surface-2 text-brand-base" : "" - } ` - } - > -
      - - - - { - issues?.find((i) => i.id === issue.id)?.project_detail - ?.identifier - } - -{issue.sequence_id} - - {issue.name} -
      -
      - ); - })} -
    -
  • - ) : ( -
    - -

    - No issues found. Create a new issue with{" "} -
    C
    . -

    -
    - )} -
    - + + + {`${projectDetails?.identifier}-${issue.blocker_issue_detail?.sequence_id}`} + + +
    -
    -
    - + submitChanges({ + blocker_issues: updatedBlockers, + blockers_list: updatedBlockers.map( + (i) => i.blocker_issue_detail?.id ?? "" + ), + }); + }} + > + + +
    + )) + : null} +
    + +
    -
    + ); }; diff --git a/apps/app/components/issues/sidebar-select/parent.tsx b/apps/app/components/issues/sidebar-select/parent.tsx index 92a51269f..9d183d262 100644 --- a/apps/app/components/issues/sidebar-select/parent.tsx +++ b/apps/app/components/issues/sidebar-select/parent.tsx @@ -20,7 +20,6 @@ import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; type Props = { control: Control; submitChanges: (formData: Partial) => void; - issuesList: IIssue[]; customDisplay: JSX.Element; watch: UseFormWatch; userAuth: UserAuth; @@ -29,7 +28,6 @@ type Props = { export const SidebarParentSelect: React.FC = ({ control, submitChanges, - issuesList, customDisplay, watch, userAuth, @@ -37,7 +35,7 @@ export const SidebarParentSelect: React.FC = ({ const [isParentModalOpen, setIsParentModalOpen] = useState(false); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, issueId } = router.query; const { data: issues } = useSWR( workspaceSlug && projectId @@ -68,8 +66,7 @@ export const SidebarParentSelect: React.FC = ({ submitChanges({ parent: val }); onChange(val); }} - issues={issuesList} - title="Select Parent" + issueId={issueId as string} value={value} customDisplay={customDisplay} /> diff --git a/apps/app/components/issues/sidebar-select/state.tsx b/apps/app/components/issues/sidebar-select/state.tsx index 8abb362db..02d1dd5cb 100644 --- a/apps/app/components/issues/sidebar-select/state.tsx +++ b/apps/app/components/issues/sidebar-select/state.tsx @@ -27,7 +27,7 @@ type Props = { export const SidebarStateSelect: React.FC = ({ value, onChange, userAuth }) => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, inboxIssueId } = router.query; const { data: stateGroups } = useSWR( workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, @@ -50,15 +50,24 @@ export const SidebarStateSelect: React.FC = ({ value, onChange, userAuth
    - {getStateGroupIcon( - selectedState?.group ?? "backlog", - "16", - "16", - selectedState?.color ?? "" - )} - {addSpaceIfCamelCase(selectedState?.name ?? "")} -
    + selectedState ? ( +
    + {getStateGroupIcon( + selectedState?.group ?? "backlog", + "16", + "16", + selectedState?.color ?? "" + )} + {addSpaceIfCamelCase(selectedState?.name ?? "")} +
    + ) : inboxIssueId ? ( +
    + {getStateGroupIcon("backlog", "16", "16", "#ff7700")} + Triage +
    + ) : ( + "None" + ) } value={value} onChange={onChange} diff --git a/apps/app/components/issues/sidebar.tsx b/apps/app/components/issues/sidebar.tsx index cdc815d3c..6f231871b 100644 --- a/apps/app/components/issues/sidebar.tsx +++ b/apps/app/components/issues/sidebar.tsx @@ -370,14 +370,6 @@ export const IssueDetailsSidebar: React.FC = ({ - i.id !== issueDetail?.id && - i.id !== issueDetail?.parent && - i.parent !== issueDetail?.id - ) ?? [] - } customDisplay={ issueDetail?.parent_detail ? ( ) : ( -
    +
    No parent selected
    ) @@ -400,16 +393,16 @@ export const IssueDetailsSidebar: React.FC = ({ )} {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( i.id !== issueDetail?.id) ?? []} watch={watchIssue} userAuth={memberRole} /> )} {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( i.id !== issueDetail?.id) ?? []} watch={watchIssue} userAuth={memberRole} /> diff --git a/apps/app/components/issues/sub-issues-list.tsx b/apps/app/components/issues/sub-issues-list.tsx index 76424767e..3558c8d74 100644 --- a/apps/app/components/issues/sub-issues-list.tsx +++ b/apps/app/components/issues/sub-issues-list.tsx @@ -21,7 +21,7 @@ import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outli // helpers import { orderArrayBy } from "helpers/array.helper"; // types -import { ICurrentUserResponse, IIssue, ISubIssueResponse } from "types"; +import { ICurrentUserResponse, IIssue, ISearchIssueResponse, ISubIssueResponse } from "types"; // fetch-keys import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys"; @@ -58,14 +58,16 @@ export const SubIssuesList: FC = ({ parentIssue, user }) => { : null ); - const addAsSubIssue = async (data: { issues: string[] }) => { + const addAsSubIssue = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId) return; + const payload = { + sub_issue_ids: data.map((i) => i.id), + }; + await issuesService - .addSubIssues(workspaceSlug as string, projectId as string, parentIssue?.id ?? "", { - sub_issue_ids: data.issues, - }) - .then((res) => { + .addSubIssues(workspaceSlug as string, projectId as string, parentIssue?.id ?? "", payload) + .then(() => { mutate( SUB_ISSUES(parentIssue?.id ?? ""), (prevData) => { @@ -74,10 +76,12 @@ export const SubIssuesList: FC = ({ parentIssue, user }) => { const stateDistribution = { ...prevData.state_distribution }; - data.issues.forEach((issueId: string) => { + payload.sub_issue_ids.forEach((issueId: string) => { const issue = issues?.find((i) => i.id === issueId); + if (issue) { newSubIssues.push(issue); + const issueGroup = issue.state_detail.group; stateDistribution[issueGroup] = stateDistribution[issueGroup] + 1; } @@ -96,7 +100,7 @@ export const SubIssuesList: FC = ({ parentIssue, user }) => { PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), (prevData) => (prevData ?? []).map((p) => { - if (data.issues.includes(p.id)) + if (payload.sub_issue_ids.includes(p.id)) return { ...p, parent: parentIssue.id, @@ -188,14 +192,7 @@ export const SubIssuesList: FC = ({ parentIssue, user }) => { setSubIssuesListModal(false)} - issues={ - issues?.filter( - (i) => - (i.parent === "" || i.parent === null) && - i.id !== parentIssue?.id && - i.id !== parentIssue?.parent - ) ?? [] - } + searchParams={{ sub_issue: true, issue_id: parentIssue?.id }} handleOnSubmit={addAsSubIssue} /> {subIssuesResponse && @@ -285,7 +282,7 @@ export const SubIssuesList: FC = ({ parentIssue, user }) => { {issue.project_detail.identifier}-{issue.sequence_id} - {issue.name} + {issue.name}
    {!isNotAllowed && ( diff --git a/apps/app/components/issues/view-select/assignee.tsx b/apps/app/components/issues/view-select/assignee.tsx index 27d4901f6..cba1db1e7 100644 --- a/apps/app/components/issues/view-select/assignee.tsx +++ b/apps/app/components/issues/view-select/assignee.tsx @@ -18,10 +18,11 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { issue: IIssue; - partialUpdateIssue: (formData: Partial, issueId: string) => void; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; position?: "left" | "right"; + tooltipPosition?: "top" | "bottom"; selfPositioned?: boolean; - tooltipPosition?: "left" | "right"; + customButton?: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -31,9 +32,10 @@ export const ViewAssigneeSelect: React.FC = ({ partialUpdateIssue, position = "left", selfPositioned = false, - tooltipPosition = "right", + tooltipPosition = "top", user, isNotAllowed, + customButton = false, }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -65,6 +67,38 @@ export const ViewAssigneeSelect: React.FC = ({ ), })); + const assigneeLabel = ( + 0 + ? issue.assignee_details + .map((assignee) => + assignee?.first_name !== "" ? assignee?.first_name : assignee?.email + ) + .join(", ") + : "No Assignee" + } + > +
    + {issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? ( +
    + +
    + ) : ( +
    + +
    + )} +
    +
    + ); + return ( = ({ if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); else newData.push(data); - partialUpdateIssue({ assignees_list: data }, issue.id); + partialUpdateIssue({ assignees_list: data }, issue); trackEventServices.trackIssuePartialPropertyUpdateEvent( { @@ -90,37 +124,7 @@ export const ViewAssigneeSelect: React.FC = ({ ); }} options={options} - label={ - 0 - ? issue.assignee_details - .map((assignee) => - assignee?.first_name !== "" ? assignee?.first_name : assignee?.email - ) - .join(", ") - : "No Assignee" - } - > -
    - {issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? ( -
    - -
    - ) : ( -
    - -
    - )} -
    -
    - } + {...(customButton ? { customButton: assigneeLabel } : { label: assigneeLabel })} multiple noChevron position={position} diff --git a/apps/app/components/issues/view-select/due-date.tsx b/apps/app/components/issues/view-select/due-date.tsx index bea5ff045..f5dcbc982 100644 --- a/apps/app/components/issues/view-select/due-date.tsx +++ b/apps/app/components/issues/view-select/due-date.tsx @@ -11,7 +11,9 @@ import { ICurrentUserResponse, IIssue } from "types"; type Props = { issue: IIssue; - partialUpdateIssue: (formData: Partial, issueId: string) => void; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + tooltipPosition?: "top" | "bottom"; + noBorder?: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -19,6 +21,8 @@ type Props = { export const ViewDueDateSelect: React.FC = ({ issue, partialUpdateIssue, + tooltipPosition = "top", + noBorder = false, user, isNotAllowed, }) => { @@ -26,7 +30,11 @@ export const ViewDueDateSelect: React.FC = ({ const { workspaceSlug } = router.query; return ( - +
    = ({ priority: issue.priority, state: issue.state, }, - issue.id + issue ); trackEventServices.trackIssuePartialPropertyUpdateEvent( { @@ -62,6 +70,7 @@ export const ViewDueDateSelect: React.FC = ({ ); }} className={issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"} + noBorder={noBorder} disabled={isNotAllowed} />
    diff --git a/apps/app/components/issues/view-select/estimate.tsx b/apps/app/components/issues/view-select/estimate.tsx index 914a5286e..d330a5f58 100644 --- a/apps/app/components/issues/view-select/estimate.tsx +++ b/apps/app/components/issues/view-select/estimate.tsx @@ -15,9 +15,11 @@ import { ICurrentUserResponse, IIssue } from "types"; type Props = { issue: IIssue; - partialUpdateIssue: (formData: Partial, issueId: string) => void; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; position?: "left" | "right"; + tooltipPosition?: "top" | "bottom"; selfPositioned?: boolean; + customButton?: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -26,7 +28,9 @@ export const ViewEstimateSelect: React.FC = ({ issue, partialUpdateIssue, position = "left", + tooltipPosition = "top", selfPositioned = false, + customButton = false, user, isNotAllowed, }) => { @@ -37,13 +41,22 @@ export const ViewEstimateSelect: React.FC = ({ const estimateValue = estimatePoints?.find((e) => e.key === issue.estimate_point)?.value; + const estimateLabels = ( + +
    + + {estimateValue ?? "None"} +
    +
    + ); + if (!isEstimateActive) return null; return ( { - partialUpdateIssue({ estimate_point: val }, issue.id); + partialUpdateIssue({ estimate_point: val }, issue); trackEventServices.trackIssuePartialPropertyUpdateEvent( { workspaceSlug, @@ -57,14 +70,7 @@ export const ViewEstimateSelect: React.FC = ({ user ); }} - label={ - -
    - - {estimateValue ?? "Estimate"} -
    -
    - } + {...(customButton ? { customButton: estimateLabels } : { label: estimateLabels })} maxHeight="md" noChevron disabled={isNotAllowed} diff --git a/apps/app/components/issues/view-select/index.ts b/apps/app/components/issues/view-select/index.ts index 55ecfcbdb..a05cf61b6 100644 --- a/apps/app/components/issues/view-select/index.ts +++ b/apps/app/components/issues/view-select/index.ts @@ -3,3 +3,4 @@ export * from "./due-date"; export * from "./estimate"; export * from "./priority"; export * from "./state"; +export * from "./label"; diff --git a/apps/app/components/issues/view-select/label.tsx b/apps/app/components/issues/view-select/label.tsx new file mode 100644 index 000000000..b82b02a0f --- /dev/null +++ b/apps/app/components/issues/view-select/label.tsx @@ -0,0 +1,151 @@ +import React, { useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// services +import issuesService from "services/issues.service"; +// component +import { CreateLabelModal } from "components/labels"; +// ui +import { CustomSearchSelect, Tooltip } from "components/ui"; +// icons +import { PlusIcon, TagIcon } from "@heroicons/react/24/outline"; +// types +import { ICurrentUserResponse, IIssue, IIssueLabels } from "types"; +// fetch-keys +import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; + +type Props = { + issue: IIssue; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + position?: "left" | "right"; + selfPositioned?: boolean; + tooltipPosition?: "top" | "bottom"; + customButton?: boolean; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const ViewLabelSelect: React.FC = ({ + issue, + partialUpdateIssue, + position = "left", + selfPositioned = false, + tooltipPosition = "top", + user, + isNotAllowed, + customButton = false, +}) => { + const [labelModal, setLabelModal] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { data: issueLabels } = useSWR( + projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null, + workspaceSlug && projectId + ? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string) + : null + ); + + const options = issueLabels?.map((label) => ({ + value: label.id, + query: label.name, + content: ( +
    + + {label.name} +
    + ), + })); + + const labelsLabel = ( + 0 + ? issue.label_details.map((label) => label.name ?? "").join(", ") + : "No Label" + } + > +
    + {issue.label_details.length > 0 ? ( + <> + {issue.label_details.slice(0, 4).map((label, index) => ( +
    + +
    + ))} + {issue.label_details.length > 4 ? +{issue.label_details.length - 4} : null} + + ) : ( + <> + + + )} +
    +
    + ); + + const footerOption = ( + + ); + + const noResultIcon = ; + + return ( + <> + {projectId && ( + setLabelModal(false)} + projectId={projectId.toString()} + user={user} + /> + )} + { + partialUpdateIssue({ labels_list: data }, issue); + }} + options={options} + {...(customButton ? { customButton: labelsLabel } : { label: labelsLabel })} + multiple + noChevron + position={position} + disabled={isNotAllowed} + selfPositioned={selfPositioned} + footerOption={footerOption} + noResultIcon={noResultIcon} + dropdownWidth="w-full min-w-[12rem]" + /> + + ); +}; diff --git a/apps/app/components/issues/view-select/priority.tsx b/apps/app/components/issues/view-select/priority.tsx index a0c5cd47c..e7a674ec7 100644 --- a/apps/app/components/issues/view-select/priority.tsx +++ b/apps/app/components/issues/view-select/priority.tsx @@ -12,12 +12,16 @@ import { ICurrentUserResponse, IIssue } from "types"; import { PRIORITIES } from "constants/project"; // services import trackEventServices from "services/track-event.service"; +// helper +import { capitalizeFirstLetter } from "helpers/string.helper"; type Props = { issue: IIssue; - partialUpdateIssue: (formData: Partial, issueId: string) => void; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; position?: "left" | "right"; + tooltipPosition?: "top" | "bottom"; selfPositioned?: boolean; + noBorder?: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -26,7 +30,9 @@ export const ViewPrioritySelect: React.FC = ({ issue, partialUpdateIssue, position = "left", + tooltipPosition = "top", selfPositioned = false, + noBorder = false, user, isNotAllowed, }) => { @@ -37,7 +43,7 @@ export const ViewPrioritySelect: React.FC = ({ { - partialUpdateIssue({ priority: data }, issue.id); + partialUpdateIssue({ priority: data }, issue); trackEventServices.trackIssuePartialPropertyUpdateEvent( { workspaceSlug, @@ -55,10 +61,12 @@ export const ViewPrioritySelect: React.FC = ({ customButton={ diff --git a/apps/app/components/issues/view-select/state.tsx b/apps/app/components/issues/view-select/state.tsx index 2b904eb1e..4a9f585e2 100644 --- a/apps/app/components/issues/view-select/state.tsx +++ b/apps/app/components/issues/view-select/state.tsx @@ -19,9 +19,11 @@ import { STATES_LIST } from "constants/fetch-keys"; type Props = { issue: IIssue; - partialUpdateIssue: (formData: Partial, issueId: string) => void; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; position?: "left" | "right"; + tooltipPosition?: "top" | "bottom"; selfPositioned?: boolean; + customButton?: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -30,7 +32,9 @@ export const ViewStateSelect: React.FC = ({ issue, partialUpdateIssue, position = "left", + tooltipPosition = "top", selfPositioned = false, + customButton = false, user, isNotAllowed, }) => { @@ -58,6 +62,20 @@ export const ViewStateSelect: React.FC = ({ const selectedOption = states?.find((s) => s.id === issue.state); + const stateLabel = ( + +
    + {selectedOption && + getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)} + {selectedOption?.name ?? "State"} +
    +
    + ); + return ( = ({ priority: issue.priority, target_date: issue.target_date, }, - issue.id + issue ); trackEventServices.trackIssuePartialPropertyUpdateEvent( { @@ -101,18 +119,7 @@ export const ViewStateSelect: React.FC = ({ } }} options={options} - label={ - -
    - {selectedOption && - getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)} - {selectedOption?.name ?? "State"} -
    -
    - } + {...(customButton ? { customButton: stateLabel } : { label: stateLabel })} position={position} disabled={isNotAllowed} noChevron diff --git a/apps/app/components/modules/delete-module-modal.tsx b/apps/app/components/modules/delete-module-modal.tsx index f2a9ec7ee..deece2ea5 100644 --- a/apps/app/components/modules/delete-module-modal.tsx +++ b/apps/app/components/modules/delete-module-modal.tsx @@ -111,7 +111,7 @@ export const DeleteModuleModal: React.FC = ({ isOpen, setIsOpen, data, us

    Are you sure you want to delete module-{" "} - + {data?.name} ? All of the data related to the module will be permanently removed. This diff --git a/apps/app/components/modules/sidebar.tsx b/apps/app/components/modules/sidebar.tsx index de8714968..4c7feacbd 100644 --- a/apps/app/components/modules/sidebar.tsx +++ b/apps/app/components/modules/sidebar.tsx @@ -322,7 +322,11 @@ export const ModuleDetailsSidebar: React.FC = ({ module, isOpen, moduleIs

    -

    {module.name}

    +
    +

    + {module.name} +

    +
    setModuleDeleteModal(true)}> @@ -339,7 +343,7 @@ export const ModuleDetailsSidebar: React.FC = ({ module, isOpen, moduleIs
    - + {module.description}
    diff --git a/apps/app/components/modules/single-module-card.tsx b/apps/app/components/modules/single-module-card.tsx index 21eb25bbb..ac92bdcdd 100644 --- a/apps/app/components/modules/single-module-card.tsx +++ b/apps/app/components/modules/single-module-card.tsx @@ -138,7 +138,7 @@ export const SingleModuleCard: React.FC = ({ module, handleEditModule, us -

    +

    {truncateText(module.name, 75)}

    diff --git a/apps/app/components/pages/create-update-block-inline.tsx b/apps/app/components/pages/create-update-block-inline.tsx index 91c1108e4..0b3fcb17c 100644 --- a/apps/app/components/pages/create-update-block-inline.tsx +++ b/apps/app/components/pages/create-update-block-inline.tsx @@ -195,7 +195,7 @@ export const CreateUpdateBlockInline: React.FC = ({ projectId as string, { prompt: watch("name"), - task: "Generate a proper description for this issue in context of a project management software.", + task: "Generate a proper description for this issue.", }, user ) diff --git a/apps/app/components/pages/delete-page-modal.tsx b/apps/app/components/pages/delete-page-modal.tsx index 6277870d1..eaa7c2189 100644 --- a/apps/app/components/pages/delete-page-modal.tsx +++ b/apps/app/components/pages/delete-page-modal.tsx @@ -136,7 +136,7 @@ export const DeletePageModal: React.FC = ({

    Are you sure you want to delete Page-{" "} - + {data?.name} ? All of the data related to the page will be permanently removed. This diff --git a/apps/app/components/pages/pages-list/recent-pages-list.tsx b/apps/app/components/pages/pages-list/recent-pages-list.tsx index 44225aee5..ce66a6ce1 100644 --- a/apps/app/components/pages/pages-list/recent-pages-list.tsx +++ b/apps/app/components/pages/pages-list/recent-pages-list.tsx @@ -41,7 +41,7 @@ export const RecentPagesList: React.FC = ({ viewType }) => { if (pages[key].length === 0) return null; return ( -

    +

    {replaceUnderscoreIfSnakeCase(key)}

    diff --git a/apps/app/components/pages/single-page-block.tsx b/apps/app/components/pages/single-page-block.tsx index 3efbd33eb..898f4aba5 100644 --- a/apps/app/components/pages/single-page-block.tsx +++ b/apps/app/components/pages/single-page-block.tsx @@ -194,7 +194,7 @@ export const SinglePageBlock: React.FC = ({ block, projectDetails, index, projectId as string, { prompt: block.name, - task: "Generate a proper description for this issue in context of a project management software.", + task: "Generate a proper description for this issue.", }, user ) @@ -417,7 +417,7 @@ export const SinglePageBlock: React.FC = ({ block, projectDetails, index,
    setCreateBlockForm(true)} >
    diff --git a/apps/app/components/project/delete-project-modal.tsx b/apps/app/components/project/delete-project-modal.tsx index 5a4be1706..eabe85f2d 100644 --- a/apps/app/components/project/delete-project-modal.tsx +++ b/apps/app/components/project/delete-project-modal.tsx @@ -128,13 +128,13 @@ export const DeleteProjectModal: React.FC = ({

    Are you sure you want to delete project{" "} - {selectedProject?.name}? All - of the data related to the project will be permanently removed. This action - cannot be undone + {selectedProject?.name}? + All of the data related to the project will be permanently removed. This + action cannot be undone

    -

    +

    Enter the project name{" "} {selectedProject?.name}{" "} to continue: diff --git a/apps/app/components/project/single-project-card.tsx b/apps/app/components/project/single-project-card.tsx index 66ef6aa2a..04e56652d 100644 --- a/apps/app/components/project/single-project-card.tsx +++ b/apps/app/components/project/single-project-card.tsx @@ -195,7 +195,7 @@ export const SingleProjectCard: React.FC = ({ ) : null}

    -

    +

    {truncateText(project.description ?? "", 100)}

    diff --git a/apps/app/components/states/create-update-state-inline.tsx b/apps/app/components/states/create-update-state-inline.tsx index 8a9d81968..94062878f 100644 --- a/apps/app/components/states/create-update-state-inline.tsx +++ b/apps/app/components/states/create-update-state-inline.tsx @@ -15,7 +15,7 @@ import stateService from "services/state.service"; // hooks import useToast from "hooks/use-toast"; // ui -import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ui"; +import { CustomSelect, Input, PrimaryButton, SecondaryButton, Tooltip } from "components/ui"; // types import type { ICurrentUserResponse, IState, IStateResponse } from "types"; // fetch-keys @@ -28,6 +28,7 @@ type Props = { onClose: () => void; selectedGroup: StateGroup | null; user: ICurrentUserResponse | undefined; + groupLength: number; }; export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | null; @@ -43,6 +44,7 @@ export const CreateUpdateStateInline: React.FC = ({ onClose, selectedGroup, user, + groupLength, }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -174,9 +176,8 @@ export const CreateUpdateStateInline: React.FC = ({ {({ open }) => ( <> {watch("color") && watch("color") !== "" && ( = ({ name="group" control={control} render={({ field: { value, onChange } }) => ( - k === value.toString()) - ? GROUP_CHOICES[value.toString() as keyof typeof GROUP_CHOICES] - : "Select group" - } - input - > - {Object.keys(GROUP_CHOICES).map((key) => ( - - {GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]} - - ))} - + +
    + k === value.toString()) + ? GROUP_CHOICES[value.toString() as keyof typeof GROUP_CHOICES] + : "Select group" + } + input + > + {Object.keys(GROUP_CHOICES).map((key) => ( + + {GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]} + + ))} + +
    +
    )} /> )} diff --git a/apps/app/components/ui/custom-menu.tsx b/apps/app/components/ui/custom-menu.tsx index dac7927b1..958453b28 100644 --- a/apps/app/components/ui/custom-menu.tsx +++ b/apps/app/components/ui/custom-menu.tsx @@ -19,7 +19,9 @@ type Props = { noChevron?: boolean; position?: "left" | "right"; verticalPosition?: "top" | "bottom"; + menuItemsClassName?: string; customButton?: JSX.Element; + menuItemsWhiteBg?: boolean; }; type MenuItemProps = { @@ -43,7 +45,9 @@ const CustomMenu = ({ noChevron = false, position = "right", verticalPosition = "bottom", + menuItemsClassName = "", customButton, + menuItemsWhiteBg = false, }: Props) => ( {({ open }) => ( @@ -105,7 +109,7 @@ const CustomMenu = ({ leaveTo="transform opacity-0 scale-95" >
    {children}
    diff --git a/apps/app/components/ui/custom-search-select.tsx b/apps/app/components/ui/custom-search-select.tsx index fb1c0a88c..f9ea3daa1 100644 --- a/apps/app/components/ui/custom-search-select.tsx +++ b/apps/app/components/ui/custom-search-select.tsx @@ -29,6 +29,7 @@ type CustomSearchSelectProps = { selfPositioned?: boolean; multiple?: boolean; footerOption?: JSX.Element; + noResultIcon?: JSX.Element; dropdownWidth?: string; }; export const CustomSearchSelect = ({ @@ -47,6 +48,7 @@ export const CustomSearchSelect = ({ disabled = false, selfPositioned = false, multiple = false, + noResultIcon, footerOption, dropdownWidth, }: CustomSearchSelectProps) => { @@ -171,7 +173,10 @@ export const CustomSearchSelect = ({ )) ) : ( -

    No matching results

    + + {noResultIcon && noResultIcon} +

    No matching results

    +
    ) ) : (

    Loading...

    diff --git a/apps/app/components/ui/custom-select.tsx b/apps/app/components/ui/custom-select.tsx index 86196a63e..ec55bf29a 100644 --- a/apps/app/components/ui/custom-select.tsx +++ b/apps/app/components/ui/custom-select.tsx @@ -54,7 +54,7 @@ const CustomSelect = ({ ) : ( = ({ placeholder = "Select date", displayShortForm = false, error = false, + noBorder = false, className = "", isClearable = true, disabled = false, @@ -44,7 +46,9 @@ export const CustomDatePicker: React.FC = ({ : "" } ${error ? "border-red-500 bg-red-100" : ""} ${ disabled ? "cursor-not-allowed" : "cursor-pointer" - } w-full rounded-md border border-brand-base bg-transparent caret-transparent ${className}`} + } ${ + noBorder ? "" : "border border-brand-base" + } w-full rounded-md bg-transparent caret-transparent ${className}`} dateFormat="dd-MM-yyyy" isClearable={isClearable} disabled={disabled} diff --git a/apps/app/components/ui/multi-level-dropdown.tsx b/apps/app/components/ui/multi-level-dropdown.tsx index 0033e8e02..ee096774f 100644 --- a/apps/app/components/ui/multi-level-dropdown.tsx +++ b/apps/app/components/ui/multi-level-dropdown.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; // headless ui import { Menu, Transition } from "@headlessui/react"; // icons -import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; import { ChevronRightIcon, ChevronLeftIcon } from "@heroicons/react/20/solid"; type MultiLevelDropdownProps = { @@ -127,9 +127,14 @@ export const MultiLevelDropdown: React.FC = ({ }} className={`${ child.selected ? "bg-brand-surface-2" : "" - } flex w-full items-center whitespace-nowrap break-all rounded px-1 py-1.5 text-left capitalize text-brand-secondary hover:bg-brand-surface-2`} + } flex w-full items-center justify-between whitespace-nowrap break-words rounded px-1 py-1.5 text-left capitalize text-brand-secondary hover:bg-brand-surface-2`} > {child.label} + ))}
    diff --git a/apps/app/components/ui/tooltip.tsx b/apps/app/components/ui/tooltip.tsx index 11504facd..86ca39e54 100644 --- a/apps/app/components/ui/tooltip.tsx +++ b/apps/app/components/ui/tooltip.tsx @@ -42,7 +42,7 @@ export const Tooltip: React.FC = ({ disabled={disabled} content={
    diff --git a/apps/app/components/views/delete-view-modal.tsx b/apps/app/components/views/delete-view-modal.tsx index c57c29dc3..fa5e6781c 100644 --- a/apps/app/components/views/delete-view-modal.tsx +++ b/apps/app/components/views/delete-view-modal.tsx @@ -115,7 +115,7 @@ export const DeleteViewModal: React.FC = ({ isOpen, data, setIsOpen, user

    Are you sure you want to delete view-{" "} - + {data?.name} ? All of the data related to the view will be permanently removed. This diff --git a/apps/app/components/views/select-filters.tsx b/apps/app/components/views/select-filters.tsx index 3351be667..164c4f58c 100644 --- a/apps/app/components/views/select-filters.tsx +++ b/apps/app/components/views/select-filters.tsx @@ -70,7 +70,7 @@ export const SelectFilters: React.FC = ({ value: PRIORITIES, children: [ ...PRIORITIES.map((priority) => ({ - id: priority ?? "none", + id: priority === null ? "null" : priority, label: (

    {getPriorityIcon(priority)} {priority ?? "None"} @@ -78,9 +78,9 @@ export const SelectFilters: React.FC = ({ ), value: { key: "priority", - value: priority, + value: priority === null ? "null" : priority, }, - selected: filters?.priority?.includes(priority ?? "none"), + selected: filters?.priority?.includes(priority === null ? "null" : priority), })), ], }, diff --git a/apps/app/components/workspace/activity-graph.tsx b/apps/app/components/workspace/activity-graph.tsx index ec8e1dfd4..1f9db203d 100644 --- a/apps/app/components/workspace/activity-graph.tsx +++ b/apps/app/components/workspace/activity-graph.tsx @@ -1,34 +1,141 @@ +import { useEffect, useRef, useState } from "react"; + // ui -import { CalendarGraph } from "components/ui"; +import { Tooltip } from "components/ui"; // helpers -import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; +import { renderDateFormat, renderShortNumericDateFormat } from "helpers/date-time.helper"; // types import { IUserActivity } from "types"; +// constants +import { DAYS, MONTHS } from "constants/project"; type Props = { activities: IUserActivity[] | undefined; }; -export const ActivityGraph: React.FC = ({ activities }) => ( - ({ - day: activity.created_date, - value: activity.activity_count, - })) ?? [] +export const ActivityGraph: React.FC = ({ activities }) => { + const ref = useRef(null); + + const [width, setWidth] = useState(0); + + const today = new Date(); + const lastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1); + const twoMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 2, 1); + const threeMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 3, 1); + const fourMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 4, 1); + const fiveMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 5, 1); + + const recentMonths = [ + fiveMonthsAgo, + fourMonthsAgo, + threeMonthsAgo, + twoMonthsAgo, + lastMonth, + today, + ]; + + const getDatesOfMonth = (dateOfMonth: Date) => { + const month = dateOfMonth.getMonth(); + const year = dateOfMonth.getFullYear(); + + const dates = []; + const date = new Date(year, month, 1); + + while (date.getMonth() === month && date < new Date()) { + dates.push(renderDateFormat(new Date(date))); + date.setDate(date.getDate() + 1); } - from={activities?.length ? activities[0].created_date : new Date()} - to={activities?.length ? activities[activities.length - 1].created_date : new Date()} - height="200px" - margin={{ bottom: 0, left: 10, right: 10, top: 0 }} - tooltip={(datum) => ( -
    - {renderShortDateWithYearFormat(datum.day)}:{" "} - {datum.value} + + return dates; + }; + + const recentDates = [ + ...getDatesOfMonth(recentMonths[0]), + ...getDatesOfMonth(recentMonths[1]), + ...getDatesOfMonth(recentMonths[2]), + ...getDatesOfMonth(recentMonths[3]), + ...getDatesOfMonth(recentMonths[4]), + ...getDatesOfMonth(recentMonths[5]), + ]; + + const activitiesIntensity = (activityCount: number) => { + if (activityCount <= 3) return "opacity-20"; + else if (activityCount > 3 && activityCount <= 6) return "opacity-40"; + else if (activityCount > 6 && activityCount <= 9) return "opacity-80"; + else return ""; + }; + + const addPaddingTiles = () => { + const firstDateDay = new Date(recentDates[0]).getDay(); + + for (let i = 0; i < firstDateDay; i++) recentDates.unshift(""); + }; + addPaddingTiles(); + + useEffect(() => { + if (!ref.current) return; + + setWidth(ref.current.offsetWidth); + }, [ref]); + + return ( +
    +
    +
    + {DAYS.map((day, index) => ( +
    + {index % 2 === 0 && day.substring(0, 3)} +
    + ))} +
    +
    +
    + {recentMonths.map((month, index) => ( +
    + {MONTHS[month.getMonth()].substring(0, 3)} +
    + ))} +
    +
    + {recentDates.map((date, index) => { + const isActive = activities?.find((a) => a.created_date === date); + + return ( + +
    + + ); + })} +
    +
    + Less + + + + + + More +
    +
    - )} - theme={{ - background: "rgb(var(--color-bg-base))", - }} - /> -); +
    + ); +}; diff --git a/apps/app/components/workspace/completed-issues-graph.tsx b/apps/app/components/workspace/completed-issues-graph.tsx index 9a1ada618..4c7e1edb6 100644 --- a/apps/app/components/workspace/completed-issues-graph.tsx +++ b/apps/app/components/workspace/completed-issues-graph.tsx @@ -60,6 +60,14 @@ export const CompletedIssuesGraph: React.FC = ({ month, issues, setMonth margin={{ top: 20, right: 20, bottom: 20, left: 20 }} customYAxisTickValues={data.map((item) => item.completed_count)} colors={(datum) => datum.color} + enableSlices="x" + sliceTooltip={(datum) => ( +
    + {datum.slice.points[0].data.yFormatted} + issues closed in + {datum.slice.points[0].data.xFormatted} +
    + )} theme={{ background: "rgb(var(--color-bg-base))", }} diff --git a/apps/app/components/workspace/delete-workspace-modal.tsx b/apps/app/components/workspace/delete-workspace-modal.tsx index 344d700b0..b9f3e60f4 100644 --- a/apps/app/components/workspace/delete-workspace-modal.tsx +++ b/apps/app/components/workspace/delete-workspace-modal.tsx @@ -120,14 +120,14 @@ export const DeleteWorkspaceModal: React.FC = ({ isOpen, data, onClose, u

    Are you sure you want to delete workspace{" "} - {data?.name}? All of the data - related to the workspace will be permanently removed. This action cannot be - undone. + {data?.name}? All of the + data related to the workspace will be permanently removed. This action cannot + be undone.

    -

    +

    Enter the workspace name{" "} {selectedWorkspace?.name}{" "} to continue: diff --git a/apps/app/components/workspace/issues-pie-chart.tsx b/apps/app/components/workspace/issues-pie-chart.tsx index ada35d080..c83ef1de2 100644 --- a/apps/app/components/workspace/issues-pie-chart.tsx +++ b/apps/app/components/workspace/issues-pie-chart.tsx @@ -25,8 +25,12 @@ export const IssuesPieChart: React.FC = ({ groupedIssues }) => ( })) ?? [] } height="320px" - innerRadius={0.5} - arcLinkLabel={(cell) => `${capitalizeFirstLetter(cell.label.toString())} (${cell.value})`} + innerRadius={0.6} + cornerRadius={5} + padAngle={2} + enableArcLabels + arcLabelsTextColor="#000000" + enableArcLinkLabels={false} legends={[ { anchor: "right", @@ -53,8 +57,14 @@ export const IssuesPieChart: React.FC = ({ groupedIssues }) => ( ]} activeInnerRadiusOffset={5} colors={(datum) => datum.data.color} + tooltip={(datum) => ( +

    + {datum.datum.label} issues:{" "} + {datum.datum.value} +
    + )} theme={{ - background: "rgb(var(--color-bg-base))", + background: "transparent", }} />
    diff --git a/apps/app/components/workspace/issues-stats.tsx b/apps/app/components/workspace/issues-stats.tsx index bc7f0364f..8e108a676 100644 --- a/apps/app/components/workspace/issues-stats.tsx +++ b/apps/app/components/workspace/issues-stats.tsx @@ -1,7 +1,9 @@ // components import { ActivityGraph } from "components/workspace"; // ui -import { Loader } from "components/ui"; +import { Loader, Tooltip } from "components/ui"; +// icons +import { InformationCircleIcon } from "@heroicons/react/24/outline"; // types import { IUserWorkspaceDashboard } from "types"; @@ -66,7 +68,15 @@ export const IssuesStats: React.FC = ({ data }) => (
    -

    Activity Graph

    +

    + Activity Graph + + + +

    diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index 5625b710d..7e77e6dc2 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -1,7 +1,7 @@ import { IAnalyticsParams, IJiraMetadata } from "types"; const paramsToKey = (params: any) => { - const { state, priority, assignees, created_by, labels, target_date } = params; + const { state, priority, assignees, created_by, labels, target_date, sub_issue } = params; let stateKey = state ? state.split(",") : []; let priorityKey = priority ? priority.split(",") : []; @@ -12,6 +12,7 @@ const paramsToKey = (params: any) => { const type = params.type ? params.type.toUpperCase() : "NULL"; const groupBy = params.group_by ? params.group_by.toUpperCase() : "NULL"; const orderBy = params.order_by ? params.order_by.toUpperCase() : "NULL"; + const subIssue = sub_issue ? sub_issue.toUpperCase() : "NULL"; // sorting each keys in ascending order stateKey = stateKey.sort().join("_"); @@ -20,7 +21,7 @@ const paramsToKey = (params: any) => { createdByKey = createdByKey.sort().join("_"); labelsKey = labelsKey.sort().join("_"); - return `${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${targetDateKey}`; + return `${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${targetDateKey}_${subIssue}`; }; const inboxParamsToKey = (params: any) => { diff --git a/apps/app/constants/inbox.ts b/apps/app/constants/inbox.ts index 9bbe8230a..3d7e8a054 100644 --- a/apps/app/constants/inbox.ts +++ b/apps/app/constants/inbox.ts @@ -3,26 +3,41 @@ export const INBOX_STATUS = [ key: "pending", label: "Pending", value: -2, + textColor: "text-yellow-500", + bgColor: "bg-yellow-500/10", + borderColor: "border-yellow-500", }, { key: "declined", label: "Declined", value: -1, + textColor: "text-red-500", + bgColor: "bg-red-500/10", + borderColor: "border-red-500", }, { key: "snoozed", label: "Snoozed", value: 0, + textColor: "text-brand-secondary", + bgColor: "bg-gray-500/10", + borderColor: "border-gray-500", }, { key: "accepted", label: "Accepted", value: 1, + textColor: "text-green-500", + bgColor: "bg-green-500/10", + borderColor: "border-green-500", }, { key: "duplicate", label: "Duplicate", value: 2, + textColor: "text-brand-secondary", + bgColor: "bg-gray-500/10", + borderColor: "border-gray-500", }, ]; diff --git a/apps/app/constants/spreadsheet.ts b/apps/app/constants/spreadsheet.ts new file mode 100644 index 000000000..b55cbdb23 --- /dev/null +++ b/apps/app/constants/spreadsheet.ts @@ -0,0 +1,62 @@ +import { + CalendarDaysIcon, + PlayIcon, + Squares2X2Icon, + TagIcon, + UserGroupIcon, +} from "@heroicons/react/24/outline"; + +export const SPREADSHEET_COLUMN = [ + { + propertyName: "title", + colName: "Title", + colSize: "440px", + }, + { + propertyName: "state", + colName: "State", + colSize: "128px", + icon: Squares2X2Icon, + ascendingOrder: "state__name", + descendingOrder: "-state__name", + }, + { + propertyName: "priority", + colName: "Priority", + colSize: "128px", + ascendingOrder: "priority", + descendingOrder: "-priority", + }, + { + propertyName: "assignee", + colName: "Assignees", + colSize: "128px", + icon: UserGroupIcon, + ascendingOrder: "assignees__first_name", + descendingOrder: "-assignees__first_name", + }, + { + propertyName: "labels", + colName: "Labels", + colSize: "128px", + icon: TagIcon, + ascendingOrder: "labels__name", + descendingOrder: "-labels__name", + }, + { + propertyName: "due_date", + colName: "Due Date", + colSize: "128px", + icon: CalendarDaysIcon, + ascendingOrder: "-target_date", + descendingOrder: "target_date", + }, + { + propertyName: "estimate", + colName: "Estimate", + colSize: "128px", + icon: PlayIcon, + ascendingOrder: "estimate_point", + descendingOrder: "-estimate_point", + }, +]; diff --git a/apps/app/helpers/date-time.helper.ts b/apps/app/helpers/date-time.helper.ts index 6333e7558..d462474a4 100644 --- a/apps/app/helpers/date-time.helper.ts +++ b/apps/app/helpers/date-time.helper.ts @@ -25,13 +25,18 @@ export const findHowManyDaysLeft = (date: string | Date) => { return Math.ceil(timeDiff / (1000 * 3600 * 24)); }; -export const getDatesInRange = (startDate: Date, endDate: Date) => { +export const getDatesInRange = (startDate: string | Date, endDate: string | Date) => { + startDate = new Date(startDate); + endDate = new Date(endDate); + const date = new Date(startDate.getTime()); const dates = []; + while (date <= endDate) { dates.push(new Date(date)); date.setDate(date.getDate() + 1); } + return dates; }; diff --git a/apps/app/hooks/use-spreadsheet-issues-view.tsx b/apps/app/hooks/use-spreadsheet-issues-view.tsx new file mode 100644 index 000000000..6e7b66bec --- /dev/null +++ b/apps/app/hooks/use-spreadsheet-issues-view.tsx @@ -0,0 +1,125 @@ +import { useContext } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// contexts +import { issueViewContext } from "contexts/issue-view.context"; +// services +import issuesService from "services/issues.service"; +import cyclesService from "services/cycles.service"; +import modulesService from "services/modules.service"; +// types +import { IIssue } from "types"; +// fetch-keys +import { + CYCLE_ISSUES_WITH_PARAMS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, + VIEW_ISSUES, +} from "constants/fetch-keys"; + +const useSpreadsheetIssuesView = () => { + const { + issueView, + orderBy, + setOrderBy, + filters, + setFilters, + resetFilterToDefault, + setNewFilterDefaultView, + setIssueView, + } = useContext(issueViewContext); + + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + + const params: any = { + order_by: orderBy, + assignees: filters?.assignees ? filters?.assignees.join(",") : undefined, + state: filters?.state ? filters?.state.join(",") : undefined, + priority: filters?.priority ? filters?.priority.join(",") : undefined, + type: filters?.type ? filters?.type : undefined, + labels: filters?.labels ? filters?.labels.join(",") : undefined, + issue__assignees__id: filters?.issue__assignees__id + ? filters?.issue__assignees__id.join(",") + : undefined, + issue__labels__id: filters?.issue__labels__id + ? filters?.issue__labels__id.join(",") + : undefined, + created_by: filters?.created_by ? filters?.created_by.join(",") : undefined, + sub_issue: "false", + }; + + const { data: projectSpreadsheetIssues } = useSWR( + workspaceSlug && projectId + ? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params) + : null, + workspaceSlug && projectId + ? () => + issuesService.getIssuesWithParams(workspaceSlug.toString(), projectId.toString(), params) + : null + ); + + const { data: cycleSpreadsheetIssues } = useSWR( + workspaceSlug && projectId && cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) + : null, + workspaceSlug && projectId && cycleId + ? () => + cyclesService.getCycleIssuesWithParams( + workspaceSlug.toString(), + projectId.toString(), + cycleId.toString(), + params + ) + : null + ); + + const { data: moduleSpreadsheetIssues } = useSWR( + workspaceSlug && projectId && moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) + : null, + workspaceSlug && projectId && moduleId + ? () => + modulesService.getModuleIssuesWithParams( + workspaceSlug.toString(), + projectId.toString(), + moduleId.toString(), + params + ) + : null + ); + + const { data: viewSpreadsheetIssues } = useSWR( + workspaceSlug && projectId && viewId && params ? VIEW_ISSUES(viewId.toString(), params) : null, + workspaceSlug && projectId && viewId && params + ? () => + issuesService.getIssuesWithParams(workspaceSlug.toString(), projectId.toString(), params) + : null + ); + + const spreadsheetIssues = cycleId + ? (cycleSpreadsheetIssues as IIssue[]) + : moduleId + ? (moduleSpreadsheetIssues as IIssue[]) + : viewId + ? (viewSpreadsheetIssues as IIssue[]) + : (projectSpreadsheetIssues as IIssue[]); + + return { + issueView, + spreadsheetIssues: spreadsheetIssues ?? [], + orderBy, + setOrderBy, + filters, + setFilters, + params, + resetFilterToDefault, + setNewFilterDefaultView, + setIssueView, + } as const; +}; + +export default useSpreadsheetIssuesView; diff --git a/apps/app/hooks/use-sub-issue.tsx b/apps/app/hooks/use-sub-issue.tsx new file mode 100644 index 000000000..8eb30fd0b --- /dev/null +++ b/apps/app/hooks/use-sub-issue.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// services +import issuesService from "services/issues.service"; +// types +import { ISubIssueResponse } from "types"; +// fetch-keys +import { SUB_ISSUES } from "constants/fetch-keys"; + +const useSubIssue = (issueId: string, isExpanded: boolean) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const shouldFetch = workspaceSlug && projectId && issueId && isExpanded; + + const { data: subIssuesResponse, isLoading } = useSWR( + shouldFetch ? SUB_ISSUES(issueId as string) : null, + shouldFetch + ? () => + issuesService.subIssues(workspaceSlug as string, projectId as string, issueId as string) + : null + ); + + return { + subIssues: subIssuesResponse?.sub_issues ?? [], + isLoading, + }; +}; + +export default useSubIssue; diff --git a/apps/app/layouts/app-layout/app-header.tsx b/apps/app/layouts/app-layout/app-header.tsx index 95f998dec..ea7d5558c 100644 --- a/apps/app/layouts/app-layout/app-header.tsx +++ b/apps/app/layouts/app-layout/app-header.tsx @@ -6,10 +6,15 @@ type Props = { left?: JSX.Element; right?: JSX.Element; setToggleSidebar: React.Dispatch>; + noHeader: boolean; }; -const Header: React.FC = ({ breadcrumbs, left, right, setToggleSidebar }) => ( -
    +const Header: React.FC = ({ breadcrumbs, left, right, setToggleSidebar, noHeader }) => ( +