From e23e9ccdbb4a19f5602a343af6538ea3fdbe596b Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 4 Jul 2023 13:54:37 +0530 Subject: [PATCH 01/18] fix: project member list endpoint n+1 (#1458) --- apiserver/plane/api/serializers/project.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 18ee19e7b..ed369f20a 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -77,6 +77,13 @@ class ProjectSerializer(BaseSerializer): raise serializers.ValidationError(detail="Project Identifier is already taken") +class ProjectLiteSerializer(BaseSerializer): + class Meta: + model = Project + fields = ["id", "identifier", "name"] + read_only_fields = fields + + class ProjectDetailSerializer(BaseSerializer): workspace = WorkSpaceSerializer(read_only=True) default_assignee = UserLiteSerializer(read_only=True) @@ -94,7 +101,7 @@ class ProjectDetailSerializer(BaseSerializer): class ProjectMemberSerializer(BaseSerializer): workspace = WorkSpaceSerializer(read_only=True) - project = ProjectSerializer(read_only=True) + project = ProjectLiteSerializer(read_only=True) member = UserLiteSerializer(read_only=True) class Meta: @@ -127,10 +134,3 @@ class ProjectFavoriteSerializer(BaseSerializer): "workspace", "user", ] - - -class ProjectLiteSerializer(BaseSerializer): - class Meta: - model = Project - fields = ["id", "identifier", "name"] - read_only_fields = fields From e4ee6a5bfbedab880b925c6d5e511b6d0fd37fa4 Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 4 Jul 2023 13:54:48 +0530 Subject: [PATCH 02/18] chore: workspace char name and slug maximum length (#1453) --- apiserver/plane/api/views/workspace.py | 88 +++++++++++++++++++------- apiserver/plane/db/models/workspace.py | 4 +- 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 26c82d54c..869f21384 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -3,6 +3,7 @@ import jwt from datetime import date, datetime from dateutil.relativedelta import relativedelta from uuid import uuid4 + # Django imports from django.db import IntegrityError from django.db.models import Prefetch @@ -93,14 +94,35 @@ class WorkSpaceViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - return self.filter_queryset( - super().get_queryset().select_related("owner") - ).order_by("name").filter(workspace_member__member=self.request.user).annotate(total_members=member_count).annotate(total_issues=issue_count) + return ( + self.filter_queryset(super().get_queryset().select_related("owner")) + .order_by("name") + .filter(workspace_member__member=self.request.user) + .annotate(total_members=member_count) + .annotate(total_issues=issue_count) + ) def create(self, request): try: serializer = WorkSpaceSerializer(data=request.data) + slug = request.data.get("slug", False) + name = request.data.get("name", False) + + if not name or not slug: + return Response( + {"error": "Both name and slug are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if len(name) > 80 or len(slug) > 48: + return Response( + { + "error": "The maximum length for name is 80 and for slug is 48" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + if serializer.is_valid(): serializer.save(owner=request.user) # Create Workspace member @@ -160,14 +182,20 @@ class UserWorkSpacesEndpoint(BaseAPIView): ) workspace = ( - Workspace.objects.prefetch_related( - Prefetch("workspace_member", queryset=WorkspaceMember.objects.all()) + ( + Workspace.objects.prefetch_related( + Prefetch( + "workspace_member", queryset=WorkspaceMember.objects.all() + ) + ) + .filter( + workspace_member__member=request.user, + ) + .select_related("owner") ) - .filter( - workspace_member__member=request.user, - ) - .select_related("owner") - ).annotate(total_members=member_count).annotate(total_issues=issue_count) + .annotate(total_members=member_count) + .annotate(total_issues=issue_count) + ) serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -216,9 +244,20 @@ class InviteWorkspaceEndpoint(BaseAPIView): ) # check for role level - requesting_user = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user) - if len([email for email in emails if int(email.get("role", 10)) > requesting_user.role]): - return Response({"error": "You cannot invite a user with higher role"}, status=status.HTTP_400_BAD_REQUEST) + requesting_user = WorkspaceMember.objects.get( + workspace__slug=slug, member=request.user + ) + if len( + [ + email + for email in emails + if int(email.get("role", 10)) > requesting_user.role + ] + ): + return Response( + {"error": "You cannot invite a user with higher role"}, + status=status.HTTP_400_BAD_REQUEST, + ) workspace = Workspace.objects.get(slug=slug) @@ -276,14 +315,17 @@ class InviteWorkspaceEndpoint(BaseAPIView): # create the user if signup is disabled if settings.DOCKERIZED and not settings.ENABLE_SIGNUP: - _ = User.objects.bulk_create([ - User( - email=email.get("email"), - password=str(uuid4().hex), - is_password_autoset=True - ) - for email in emails - ], batch_size=100) + _ = User.objects.bulk_create( + [ + User( + email=email.get("email"), + password=str(uuid4().hex), + is_password_autoset=True, + ) + for email in emails + ], + batch_size=100, + ) for invitation in workspace_invitations: workspace_invitation.delay( @@ -865,7 +907,9 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView): ) state_distribution = ( - Issue.issue_objects.filter(workspace__slug=slug, assignees__in=[request.user]) + Issue.issue_objects.filter( + workspace__slug=slug, assignees__in=[request.user] + ) .annotate(state_group=F("state__group")) .values("state_group") .annotate(state_count=Count("state_group")) diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index b00d53013..f071bac2a 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -15,14 +15,14 @@ ROLE_CHOICES = ( class Workspace(BaseModel): - name = models.CharField(max_length=255, verbose_name="Workspace Name") + name = models.CharField(max_length=80, verbose_name="Workspace Name") logo = models.URLField(verbose_name="Logo", blank=True, null=True) owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owner_workspace", ) - slug = models.SlugField(max_length=100, db_index=True, unique=True) + slug = models.SlugField(max_length=48, db_index=True, unique=True) company_size = models.PositiveIntegerField(default=10) def __str__(self): From 1a72a0dff439658176bb119f066cbf458b3743fc Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 4 Jul 2023 13:56:51 +0530 Subject: [PATCH 03/18] fix: user invitation workflow for self hosted version (#1441) --- apiserver/plane/api/views/workspace.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 869f21384..7263c5647 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -22,6 +22,7 @@ from django.db.models import ( ) from django.db.models.functions import ExtractWeek, Cast, ExtractDay from django.db.models.fields import DateField +from django.contrib.auth.hashers import make_password # Third party modules from rest_framework import status @@ -318,11 +319,12 @@ class InviteWorkspaceEndpoint(BaseAPIView): _ = User.objects.bulk_create( [ User( - email=email.get("email"), - password=str(uuid4().hex), + username=str(uuid4().hex), + email=invitation.email, + password=make_password(uuid4().hex), is_password_autoset=True, ) - for email in emails + for invitation in workspace_invitations ], batch_size=100, ) From 5a6fd0efdb8dbc54fbcdd5e1b57307710f5621a1 Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 4 Jul 2023 16:48:59 +0530 Subject: [PATCH 04/18] chore: due date filter (#1460) --- apiserver/plane/utils/issue_filters.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 74acb2044..6a9e8b8e8 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -166,16 +166,16 @@ def filter_target_date(params, filter, method): for query in target_dates: target_date_query = query.split(";") if len(target_date_query) == 2 and "after" in target_date_query: - filter["target_date__gte"] = target_date_query[0] + filter["target_date__gt"] = target_date_query[0] else: - filter["target_date__lte"] = target_date_query[0] + filter["target_date__lt"] = target_date_query[0] else: if params.get("target_date", None) and len(params.get("target_date")): for query in params.get("target_date"): if query.get("timeline", "after") == "after": - filter["target_date__gte"] = query.get("datetime") + filter["target_date__gt"] = query.get("datetime") else: - filter["target_date__lte"] = query.get("datetime") + filter["target_date__lt"] = query.get("datetime") return filter From 4ede04d72f25f54a2b6d6a11720e022098a4a4c0 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 4 Jul 2023 18:19:19 +0530 Subject: [PATCH 05/18] refactor: standardized date format throughout the platform (#1461) --- apps/app/components/core/feeds.tsx | 4 ++-- apps/app/components/cycles/sidebar.tsx | 6 +++--- apps/app/components/inbox/inbox-issue-card.tsx | 8 ++++---- apps/app/components/inbox/inbox-main-content.tsx | 10 +++++++--- apps/app/components/issues/activity.tsx | 4 ++-- apps/app/components/issues/select/date.tsx | 4 ++-- apps/app/components/issues/view-select/due-date.tsx | 6 ++++-- apps/app/components/modules/sidebar.tsx | 12 +++++++++--- apps/app/components/project/single-project-card.tsx | 6 +++--- apps/app/components/ui/date.tsx | 4 ++-- apps/app/components/ui/datepicker.tsx | 6 +++--- apps/app/components/views/single-view-item.tsx | 4 ++-- apps/app/components/workspace/activity-graph.tsx | 4 ++-- apps/app/helpers/date-time.helper.ts | 5 +++-- 14 files changed, 48 insertions(+), 35 deletions(-) diff --git a/apps/app/components/core/feeds.tsx b/apps/app/components/core/feeds.tsx index cce482da5..d00804ec8 100644 --- a/apps/app/components/core/feeds.tsx +++ b/apps/app/components/core/feeds.tsx @@ -19,7 +19,7 @@ import { } from "@heroicons/react/24/outline"; import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons"; // helpers -import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper"; +import { renderShortDateWithYearFormat, timeAgo } from "helpers/date-time.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper"; // types import RemirrorRichTextEditor from "components/rich-text-editor"; @@ -187,7 +187,7 @@ export const Feeds: React.FC = ({ activities }) => ( activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value; - value = renderShortNumericDateFormat(date as string); + value = renderShortDateWithYearFormat(date as string); } else if (activity.field === "description") { value = "description"; } else if (activity.field === "attachment") { diff --git a/apps/app/components/cycles/sidebar.tsx b/apps/app/components/cycles/sidebar.tsx index e4b48869f..2080d7f6b 100644 --- a/apps/app/components/cycles/sidebar.tsx +++ b/apps/app/components/cycles/sidebar.tsx @@ -35,7 +35,7 @@ import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helpe import { isDateGreaterThanToday, renderDateFormat, - renderShortDate, + renderShortDateWithYearFormat, } from "helpers/date-time.helper"; // types import { ICurrentUserResponse, ICycle } from "types"; @@ -315,7 +315,7 @@ export const CycleDetailsSidebar: React.FC = ({ > - {renderShortDate( + {renderShortDateWithYearFormat( new Date( `${watch("start_date") ? watch("start_date") : cycle?.start_date}` ), @@ -366,7 +366,7 @@ export const CycleDetailsSidebar: React.FC = ({ - {renderShortDate( + {renderShortDateWithYearFormat( new Date( `${watch("end_date") ? watch("end_date") : cycle?.end_date}` ), diff --git a/apps/app/components/inbox/inbox-issue-card.tsx b/apps/app/components/inbox/inbox-issue-card.tsx index 072647ae3..53d6c2e95 100644 --- a/apps/app/components/inbox/inbox-issue-card.tsx +++ b/apps/app/components/inbox/inbox-issue-card.tsx @@ -14,7 +14,7 @@ import { XCircleIcon, } from "@heroicons/react/24/outline"; // helpers -import { renderShortNumericDateFormat } from "helpers/date-time.helper"; +import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; // types import type { IInboxIssue } from "types"; // constants @@ -72,12 +72,12 @@ export const InboxIssueCard: React.FC = (props) => {
- {renderShortNumericDateFormat(issue.created_at ?? "")} + {renderShortDateWithYearFormat(issue.created_at ?? "")}
diff --git a/apps/app/components/inbox/inbox-main-content.tsx b/apps/app/components/inbox/inbox-main-content.tsx index a05239a95..46aa4b33f 100644 --- a/apps/app/components/inbox/inbox-main-content.tsx +++ b/apps/app/components/inbox/inbox-main-content.tsx @@ -33,7 +33,7 @@ import { XCircleIcon, } from "@heroicons/react/24/outline"; // helpers -import { renderShortNumericDateFormat } from "helpers/date-time.helper"; +import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; // types import type { IInboxIssue, IIssue } from "types"; // fetch-keys @@ -252,13 +252,17 @@ export const InboxMainContent: React.FC = () => { {new Date(issueDetails.issue_inbox[0].snoozed_till ?? "") < new Date() ? (

This issue was snoozed till{" "} - {renderShortNumericDateFormat(issueDetails.issue_inbox[0].snoozed_till ?? "")} + {renderShortDateWithYearFormat( + issueDetails.issue_inbox[0].snoozed_till ?? "" + )} .

) : (

This issue has been snoozed till{" "} - {renderShortNumericDateFormat(issueDetails.issue_inbox[0].snoozed_till ?? "")} + {renderShortDateWithYearFormat( + issueDetails.issue_inbox[0].snoozed_till ?? "" + )} .

)} diff --git a/apps/app/components/issues/activity.tsx b/apps/app/components/issues/activity.tsx index 8d708b856..e30f00b0b 100644 --- a/apps/app/components/issues/activity.tsx +++ b/apps/app/components/issues/activity.tsx @@ -26,7 +26,7 @@ import { } from "@heroicons/react/24/outline"; import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons"; // helpers -import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper"; +import { renderShortDateWithYearFormat, timeAgo } from "helpers/date-time.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper"; // types import { ICurrentUserResponse, IIssueComment, IIssueLabels } from "types"; @@ -299,7 +299,7 @@ export const IssueActivitySection: React.FC = ({ issueId, user }) => { activityItem.new_value && activityItem.new_value !== "" ? activityItem.new_value : activityItem.old_value; - value = renderShortNumericDateFormat(date as string); + value = renderShortDateWithYearFormat(date as string); } else if (activityItem.field === "description") { value = "description"; } else if (activityItem.field === "attachment") { diff --git a/apps/app/components/issues/select/date.tsx b/apps/app/components/issues/select/date.tsx index 4abb03545..26288e11b 100644 --- a/apps/app/components/issues/select/date.tsx +++ b/apps/app/components/issues/select/date.tsx @@ -5,7 +5,7 @@ import { CalendarDaysIcon, XMarkIcon } from "@heroicons/react/24/outline"; // react-datepicker import DatePicker from "react-datepicker"; // import "react-datepicker/dist/react-datepicker.css"; -import { renderDateFormat } from "helpers/date-time.helper"; +import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper"; type Props = { value: string | null; @@ -20,7 +20,7 @@ export const IssueDateSelect: React.FC = ({ value, onChange }) => ( {value ? ( <> - {value} + {renderShortDateWithYearFormat(value)} diff --git a/apps/app/components/issues/view-select/due-date.tsx b/apps/app/components/issues/view-select/due-date.tsx index f5dcbc982..efd568c30 100644 --- a/apps/app/components/issues/view-select/due-date.tsx +++ b/apps/app/components/issues/view-select/due-date.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; // ui import { CustomDatePicker, Tooltip } from "components/ui"; // helpers -import { findHowManyDaysLeft } from "helpers/date-time.helper"; +import { findHowManyDaysLeft, renderShortDateWithYearFormat } from "helpers/date-time.helper"; // services import trackEventServices from "services/track-event.service"; // types @@ -32,7 +32,9 @@ export const ViewDueDateSelect: React.FC = ({ return (
= ({ module, isOpen, moduleIs > - {renderShortDate(new Date(`${module.start_date}`), "Start date")} + {renderShortDateWithYearFormat( + new Date(`${module.start_date}`), + "Start date" + )} @@ -279,7 +282,10 @@ export const ModuleDetailsSidebar: React.FC = ({ module, isOpen, moduleIs - {renderShortDate(new Date(`${module?.target_date}`), "End date")} + {renderShortDateWithYearFormat( + new Date(`${module?.target_date}`), + "End date" + )} diff --git a/apps/app/components/project/single-project-card.tsx b/apps/app/components/project/single-project-card.tsx index 04e56652d..9b7bf1c99 100644 --- a/apps/app/components/project/single-project-card.tsx +++ b/apps/app/components/project/single-project-card.tsx @@ -22,7 +22,7 @@ import { TrashIcon, } from "@heroicons/react/24/outline"; // helpers -import { renderShortNumericDateFormat } from "helpers/date-time.helper"; +import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { copyTextToClipboard, truncateText } from "helpers/string.helper"; // types import type { IFavoriteProject, IProject } from "types"; @@ -202,13 +202,13 @@ export const SingleProjectCard: React.FC = ({
- {renderShortNumericDateFormat(project.created_at)} + {renderShortDateWithYearFormat(project.created_at)}
{hasJoined ? ( diff --git a/apps/app/components/ui/date.tsx b/apps/app/components/ui/date.tsx index d835ff22b..d5a2cb722 100644 --- a/apps/app/components/ui/date.tsx +++ b/apps/app/components/ui/date.tsx @@ -5,7 +5,7 @@ import { CalendarDaysIcon, XMarkIcon } from "@heroicons/react/24/outline"; // react-datepicker import DatePicker from "react-datepicker"; // import "react-datepicker/dist/react-datepicker.css"; -import { renderDateFormat } from "helpers/date-time.helper"; +import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper"; type Props = { value: string | null; @@ -21,7 +21,7 @@ export const DateSelect: React.FC = ({ value, onChange, label }) => ( {value ? ( <> - {value} + {renderShortDateWithYearFormat(value)} diff --git a/apps/app/components/ui/datepicker.tsx b/apps/app/components/ui/datepicker.tsx index 80ce7aa91..9b20dcafc 100644 --- a/apps/app/components/ui/datepicker.tsx +++ b/apps/app/components/ui/datepicker.tsx @@ -38,9 +38,9 @@ export const CustomDatePicker: React.FC = ({ }} className={`${ renderAs === "input" - ? "block px-3 py-2 text-sm focus:outline-none" + ? "block px-2 py-2 text-sm focus:outline-none" : renderAs === "button" - ? `px-3 py-1 text-xs shadow-sm ${ + ? `px-2 py-1 text-xs shadow-sm ${ disabled ? "" : "hover:bg-brand-surface-2" } duration-300 focus:border-brand-accent focus:outline-none focus:ring-1 focus:ring-brand-accent` : "" @@ -49,7 +49,7 @@ export const CustomDatePicker: React.FC = ({ } ${ noBorder ? "" : "border border-brand-base" } w-full rounded-md bg-transparent caret-transparent ${className}`} - dateFormat="dd-MM-yyyy" + dateFormat="MMM dd, yyyy" isClearable={isClearable} disabled={disabled} /> diff --git a/apps/app/components/views/single-view-item.tsx b/apps/app/components/views/single-view-item.tsx index 5e5ff3cc0..233ee3e74 100644 --- a/apps/app/components/views/single-view-item.tsx +++ b/apps/app/components/views/single-view-item.tsx @@ -18,7 +18,7 @@ import { VIEWS_LIST } from "constants/fetch-keys"; import useToast from "hooks/use-toast"; // helpers import { truncateText } from "helpers/string.helper"; -import { renderShortDate, renderShortTime } from "helpers/date-time.helper"; +import { renderShortDateWithYearFormat, renderShortTime } from "helpers/date-time.helper"; type Props = { view: IView; @@ -107,7 +107,7 @@ export const SingleViewItem: React.FC = ({ view, handleEditView, handleDe

{renderShortTime(view.updated_at)} diff --git a/apps/app/components/workspace/activity-graph.tsx b/apps/app/components/workspace/activity-graph.tsx index 1f9db203d..b49446eeb 100644 --- a/apps/app/components/workspace/activity-graph.tsx +++ b/apps/app/components/workspace/activity-graph.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; // ui import { Tooltip } from "components/ui"; // helpers -import { renderDateFormat, renderShortNumericDateFormat } from "helpers/date-time.helper"; +import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper"; // types import { IUserActivity } from "types"; // constants @@ -109,7 +109,7 @@ export const ActivityGraph: React.FC = ({ activities }) => { key={`${date}-${index}`} tooltipContent={`${ isActive ? isActive.activity_count : 0 - } activities on ${renderShortNumericDateFormat(date)}`} + } activities on ${renderShortDateWithYearFormat(date)}`} theme="dark" >

{ +export const renderShortDateWithYearFormat = (date: string | Date, placeholder?: string) => { if (!date || date === "") return null; date = new Date(date); @@ -136,7 +136,8 @@ export const renderShortDateWithYearFormat = (date: string | Date) => { const day = date.getDate(); const month = months[date.getMonth()]; const year = date.getFullYear(); - return isNaN(date.getTime()) ? "N/A" : ` ${month} ${day}, ${year}`; + + return isNaN(date.getTime()) ? placeholder ?? "N/A" : ` ${month} ${day}, ${year}`; }; export const renderShortDate = (date: string | Date, placeholder?: string) => { From 4c3e2c252a3a0738544d8758f86cf8607ff8439a Mon Sep 17 00:00:00 2001 From: Kunal Vishwakarma <116634168+kunalv17@users.noreply.github.com> Date: Tue, 4 Jul 2023 23:13:07 +0530 Subject: [PATCH 06/18] chore: due date filter (#965) * chore: due date filter * fix: deployment error * chore: optimized code * chore: created constants for due date * chore: create seperated css file for react datepicker styling * fix: due date filter * chore: highlight selected option * fix: merge conflicts * fix: build error * chore: date range selector validation * fix: issue views overflow * refactor: due date filter modal code * refactor: multi level dropdown * chore: due date filter select default value --------- Co-authored-by: Aaryan Khandelwal --- .../core/calendar-view/calendar.tsx | 2 +- .../core/filters/due-date-filter-modal.tsx | 186 ++++++++++++ .../core/filters/due-date-filter-select.tsx | 58 ++++ .../core/{ => filters}/filters-list.tsx | 49 ++- apps/app/components/core/filters/index.ts | 4 + .../core/{ => filters}/issues-view-filter.tsx | 52 ++-- apps/app/components/core/index.ts | 9 +- .../components/core/list-view/all-lists.tsx | 2 +- .../{ => modals}/bulk-delete-issues-modal.tsx | 0 .../existing-issues-list-modal.tsx | 0 .../core/{ => modals}/gpt-assistant-modal.tsx | 0 .../core/{ => modals}/image-upload-modal.tsx | 0 apps/app/components/core/modals/index.ts | 5 + .../core/{ => modals}/link-modal.tsx | 0 .../components/icons/calendar-after-icon.tsx | 26 ++ .../components/icons/calendar-before-icon.tsx | 37 +++ .../components/icons/calendar-month-icon.tsx | 28 +- apps/app/components/icons/index.ts | 2 + .../components/ui/multi-level-dropdown.tsx | 217 ++++++------- apps/app/components/views/select-filters.tsx | 284 ++++++++++-------- apps/app/constants/due-dates.ts | 37 +++ apps/app/constants/project.ts | 1 + apps/app/contexts/issue-view.context.tsx | 1 + apps/app/helpers/array.helper.ts | 8 + apps/app/hooks/use-issues-view.tsx | 1 + .../projects/[projectId]/cycles/[cycleId].tsx | 2 +- .../projects/[projectId]/issues/index.tsx | 4 +- .../[projectId]/modules/[moduleId].tsx | 2 +- .../projects/[projectId]/views/[viewId].tsx | 4 +- apps/app/styles/globals.css | 19 -- apps/app/styles/react-datepicker.css | 2 +- apps/app/types/issues.d.ts | 1 + 32 files changed, 748 insertions(+), 295 deletions(-) create mode 100644 apps/app/components/core/filters/due-date-filter-modal.tsx create mode 100644 apps/app/components/core/filters/due-date-filter-select.tsx rename apps/app/components/core/{ => filters}/filters-list.tsx (86%) create mode 100644 apps/app/components/core/filters/index.ts rename apps/app/components/core/{ => filters}/issues-view-filter.tsx (92%) rename apps/app/components/core/{ => modals}/bulk-delete-issues-modal.tsx (100%) rename apps/app/components/core/{ => modals}/existing-issues-list-modal.tsx (100%) rename apps/app/components/core/{ => modals}/gpt-assistant-modal.tsx (100%) rename apps/app/components/core/{ => modals}/image-upload-modal.tsx (100%) create mode 100644 apps/app/components/core/modals/index.ts rename apps/app/components/core/{ => modals}/link-modal.tsx (100%) create mode 100644 apps/app/components/icons/calendar-after-icon.tsx create mode 100644 apps/app/components/icons/calendar-before-icon.tsx create mode 100644 apps/app/constants/due-dates.ts diff --git a/apps/app/components/core/calendar-view/calendar.tsx b/apps/app/components/core/calendar-view/calendar.tsx index fa29eb9f7..9da6e5873 100644 --- a/apps/app/components/core/calendar-view/calendar.tsx +++ b/apps/app/components/core/calendar-view/calendar.tsx @@ -170,7 +170,7 @@ export const CalendarView: React.FC = ({ const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted; return calendarIssues ? ( -
+
void; +}; + +type TFormValues = { + filterType: "before" | "after" | "range"; + date1: Date; + date2: Date; +}; + +const defaultValues: TFormValues = { + filterType: "range", + date1: new Date(), + date2: new Date(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()), +}; + +export const DueDateFilterModal: React.FC = ({ isOpen, handleClose }) => { + const { filters, setFilters } = useIssuesView(); + + const router = useRouter(); + const { viewId } = router.query; + + const { handleSubmit, watch, control } = useForm({ + defaultValues, + }); + + const handleFormSubmit = (formData: TFormValues) => { + const { filterType, date1, date2 } = formData; + + if (filterType === "range") { + setFilters( + { target_date: [`${renderDateFormat(date1)};after`, `${renderDateFormat(date2)};before`] }, + !Boolean(viewId) + ); + } else { + const filteredArray = filters?.target_date?.filter((item) => { + if (item?.includes(filterType)) return false; + + return true; + }); + + const filterOne = filteredArray && filteredArray?.length > 0 ? filteredArray[0] : null; + if (filterOne) + setFilters( + { target_date: [filterOne, `${renderDateFormat(date1)};${filterType}`] }, + !Boolean(viewId) + ); + else + setFilters( + { + target_date: [`${renderDateFormat(date1)};${filterType}`], + }, + !Boolean(viewId) + ); + } + handleClose(); + }; + + const isInvalid = + watch("filterType") === "range" ? new Date(watch("date1")) > new Date(watch("date2")) : false; + + const nextDay = new Date(watch("date1")); + nextDay.setDate(nextDay.getDate() + 1); + + return ( + + + +
+ +
+
+ + +
+
+ ( + + )} + /> + +
+
+ ( + onChange(val)} + dateFormat="dd-MM-yyyy" + calendarClassName="h-full" + inline + /> + )} + /> + {watch("filterType") === "range" && ( + ( + + )} + /> + )} +
+ {watch("filterType") === "range" && ( +
+ After: + {renderShortDateWithYearFormat(watch("date1"))} + Before: + {!isInvalid && {renderShortDateWithYearFormat(watch("date2"))}} +
+ )} +
+ + Cancel + + + Apply + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/app/components/core/filters/due-date-filter-select.tsx b/apps/app/components/core/filters/due-date-filter-select.tsx new file mode 100644 index 000000000..480a77619 --- /dev/null +++ b/apps/app/components/core/filters/due-date-filter-select.tsx @@ -0,0 +1,58 @@ +import React from "react"; + +// ui +import { CustomSelect } from "components/ui"; +// icons +import { CalendarBeforeIcon, CalendarAfterIcon, CalendarMonthIcon } from "components/icons"; +// fetch-keys + +type Props = { + value: string; + onChange: (value: string) => void; +}; + +type DueDate = { + name: string; + value: string; + icon: any; +}; + +const dueDateRange: DueDate[] = [ + { + name: "Due date before", + value: "before", + icon: , + }, + { + name: "Due date after", + value: "after", + icon: , + }, + { + name: "Due date range", + value: "range", + icon: , + }, +]; + +export const DueDateFilterSelect: React.FC = ({ value, onChange }) => ( + + {dueDateRange.find((item) => item.value === value)?.icon} + {dueDateRange.find((item) => item.value === value)?.name} +
+ } + onChange={onChange} + > + {dueDateRange.map((option, index) => ( + + <> + {option.icon} + {option.name} + + + ))} + +); diff --git a/apps/app/components/core/filters-list.tsx b/apps/app/components/core/filters/filters-list.tsx similarity index 86% rename from apps/app/components/core/filters-list.tsx rename to apps/app/components/core/filters/filters-list.tsx index e080c8e9a..7254f8072 100644 --- a/apps/app/components/core/filters-list.tsx +++ b/apps/app/components/core/filters/filters-list.tsx @@ -17,6 +17,7 @@ import stateService from "services/state.service"; // types import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fetch-keys"; import { IIssueFilterOptions } from "types"; +import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; export const FilterList: React.FC = ({ filters, setFilters }) => { const router = useRouter(); @@ -60,7 +61,7 @@ export const FilterList: React.FC = ({ filters, setFilters }) => { className="flex items-center gap-x-2 rounded-full border border-brand-base bg-brand-surface-2 px-2 py-1" > - {replaceUnderscoreIfSnakeCase(key)}: + {key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}: {filters[key as keyof IIssueFilterOptions] === null || (filters[key as keyof IIssueFilterOptions]?.length ?? 0) <= 0 ? ( @@ -299,6 +300,51 @@ export const FilterList: React.FC = ({ filters, setFilters }) => {
+ ) : key === "target_date" ? ( +
+ {filters.target_date?.map((date: string) => { + if (filters.target_date.length <= 0) return null; + + const splitDate = date.split(";"); + + return ( +
+
+ + {splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])} + + + setFilters( + { + target_date: filters.target_date?.filter( + (d: any) => d !== date + ), + }, + !Boolean(viewId) + ) + } + > + + +
+ ); + })} + +
) : ( (filters[key as keyof IIssueFilterOptions] as any)?.join(", ") )} @@ -332,6 +378,7 @@ export const FilterList: React.FC = ({ filters, setFilters }) => { assignees: null, labels: null, created_by: null, + target_date: null, }) } className="flex items-center gap-x-1 rounded-full border border-brand-base bg-brand-surface-2 px-3 py-1.5 text-xs" diff --git a/apps/app/components/core/filters/index.ts b/apps/app/components/core/filters/index.ts new file mode 100644 index 000000000..01c371911 --- /dev/null +++ b/apps/app/components/core/filters/index.ts @@ -0,0 +1,4 @@ +export * from "./due-date-filter-modal"; +export * from "./due-date-filter-select"; +export * from "./filters-list"; +export * from "./issues-view-filter"; diff --git a/apps/app/components/core/issues-view-filter.tsx b/apps/app/components/core/filters/issues-view-filter.tsx similarity index 92% rename from apps/app/components/core/issues-view-filter.tsx rename to apps/app/components/core/filters/issues-view-filter.tsx index a6996793c..4452bfb61 100644 --- a/apps/app/components/core/issues-view-filter.tsx +++ b/apps/app/components/core/filters/issues-view-filter.tsx @@ -2,11 +2,12 @@ import React from "react"; import { useRouter } from "next/router"; +// headless ui +import { Popover, Transition } from "@headlessui/react"; // hooks import useIssuesProperties from "hooks/use-issue-properties"; import useIssuesView from "hooks/use-issues-view"; -// headless ui -import { Popover, Transition } from "@headlessui/react"; +import useEstimateOption from "hooks/use-estimate-option"; // components import { SelectFilters } from "components/views"; // ui @@ -17,15 +18,14 @@ import { ListBulletIcon, Squares2X2Icon, CalendarDaysIcon, - ChartBarIcon, } from "@heroicons/react/24/outline"; // helpers import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types import { Properties } from "types"; // constants import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue"; -import useEstimateOption from "hooks/use-estimate-option"; export const IssuesFilterView: React.FC = () => { const router = useRouter(); @@ -109,26 +109,34 @@ export const IssuesFilterView: React.FC = () => { onSelect={(option) => { const key = option.key as keyof typeof filters; - const valueExists = filters[key]?.includes(option.value); + if (key === "target_date") { + const valueExists = checkIfArraysHaveSameElements( + filters.target_date ?? [], + option.value + ); - if (valueExists) { - setFilters( - { - ...(filters ?? {}), - [option.key]: ((filters[key] ?? []) as any[])?.filter( - (val) => val !== option.value - ), - }, - !Boolean(viewId) - ); + setFilters({ + target_date: valueExists ? null : option.value, + }); } else { - setFilters( - { - ...(filters ?? {}), - [option.key]: [...((filters[key] ?? []) as any[]), option.value], - }, - !Boolean(viewId) - ); + const valueExists = filters[key]?.includes(option.value); + + if (valueExists) + setFilters( + { + [option.key]: ((filters[key] ?? []) as any[])?.filter( + (val) => val !== option.value + ), + }, + !Boolean(viewId) + ); + else + setFilters( + { + [option.key]: [...((filters[key] ?? []) as any[]), option.value], + }, + !Boolean(viewId) + ); } }} direction="left" diff --git a/apps/app/components/core/index.ts b/apps/app/components/core/index.ts index c50ce7251..1eb52590c 100644 --- a/apps/app/components/core/index.ts +++ b/apps/app/components/core/index.ts @@ -1,17 +1,12 @@ export * from "./board-view"; export * from "./calendar-view"; +export * from "./filters"; export * from "./gantt-chart-view"; export * from "./list-view"; +export * from "./modals"; export * from "./spreadsheet-view"; export * from "./sidebar"; -export * from "./bulk-delete-issues-modal"; -export * from "./existing-issues-list-modal"; -export * from "./filters-list"; -export * from "./gpt-assistant-modal"; -export * from "./image-upload-modal"; -export * from "./issues-view-filter"; export * from "./issues-view"; -export * from "./link-modal"; export * from "./image-picker-popover"; export * from "./feeds"; export * from "./theme-switch"; diff --git a/apps/app/components/core/list-view/all-lists.tsx b/apps/app/components/core/list-view/all-lists.tsx index fd063728a..fcedf169a 100644 --- a/apps/app/components/core/list-view/all-lists.tsx +++ b/apps/app/components/core/list-view/all-lists.tsx @@ -38,7 +38,7 @@ export const AllLists: React.FC = ({ return ( <> {groupedByIssues && ( -
+
{Object.keys(groupedByIssues).map((singleGroup) => { const currentState = selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; diff --git a/apps/app/components/core/bulk-delete-issues-modal.tsx b/apps/app/components/core/modals/bulk-delete-issues-modal.tsx similarity index 100% rename from apps/app/components/core/bulk-delete-issues-modal.tsx rename to apps/app/components/core/modals/bulk-delete-issues-modal.tsx diff --git a/apps/app/components/core/existing-issues-list-modal.tsx b/apps/app/components/core/modals/existing-issues-list-modal.tsx similarity index 100% rename from apps/app/components/core/existing-issues-list-modal.tsx rename to apps/app/components/core/modals/existing-issues-list-modal.tsx diff --git a/apps/app/components/core/gpt-assistant-modal.tsx b/apps/app/components/core/modals/gpt-assistant-modal.tsx similarity index 100% rename from apps/app/components/core/gpt-assistant-modal.tsx rename to apps/app/components/core/modals/gpt-assistant-modal.tsx diff --git a/apps/app/components/core/image-upload-modal.tsx b/apps/app/components/core/modals/image-upload-modal.tsx similarity index 100% rename from apps/app/components/core/image-upload-modal.tsx rename to apps/app/components/core/modals/image-upload-modal.tsx diff --git a/apps/app/components/core/modals/index.ts b/apps/app/components/core/modals/index.ts new file mode 100644 index 000000000..5f55020e4 --- /dev/null +++ b/apps/app/components/core/modals/index.ts @@ -0,0 +1,5 @@ +export * from "./bulk-delete-issues-modal"; +export * from "./existing-issues-list-modal"; +export * from "./gpt-assistant-modal"; +export * from "./image-upload-modal"; +export * from "./link-modal"; diff --git a/apps/app/components/core/link-modal.tsx b/apps/app/components/core/modals/link-modal.tsx similarity index 100% rename from apps/app/components/core/link-modal.tsx rename to apps/app/components/core/modals/link-modal.tsx diff --git a/apps/app/components/icons/calendar-after-icon.tsx b/apps/app/components/icons/calendar-after-icon.tsx new file mode 100644 index 000000000..278e500f0 --- /dev/null +++ b/apps/app/components/icons/calendar-after-icon.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const CalendarAfterIcon: React.FC = ({ width = "24", height = "24", className }) => ( + + + + + + + + + + +); \ No newline at end of file diff --git a/apps/app/components/icons/calendar-before-icon.tsx b/apps/app/components/icons/calendar-before-icon.tsx new file mode 100644 index 000000000..f2651c084 --- /dev/null +++ b/apps/app/components/icons/calendar-before-icon.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const CalendarBeforeIcon: React.FC = ({ width = "24", height = "24", className }) => ( + + + + + + + + + + + + + + + + +); diff --git a/apps/app/components/icons/calendar-month-icon.tsx b/apps/app/components/icons/calendar-month-icon.tsx index a9f5042c9..dbfc43c50 100644 --- a/apps/app/components/icons/calendar-month-icon.tsx +++ b/apps/app/components/icons/calendar-month-icon.tsx @@ -3,17 +3,17 @@ import React from "react"; import type { Props } from "./types"; export const CalendarMonthIcon: React.FC = ({ width = "24", height = "24", className }) => ( - - - - ); + + + +); diff --git a/apps/app/components/icons/index.ts b/apps/app/components/icons/index.ts index 07ecafd24..db7aad041 100644 --- a/apps/app/components/icons/index.ts +++ b/apps/app/components/icons/index.ts @@ -4,6 +4,8 @@ export * from "./backlog-state-icon"; export * from "./blocked-icon"; export * from "./blocker-icon"; export * from "./bolt-icon"; +export * from "./calendar-before-icon"; +export * from "./calendar-after-icon"; export * from "./calendar-month-icon"; export * from "./cancel-icon"; export * from "./cancelled-state-icon"; diff --git a/apps/app/components/ui/multi-level-dropdown.tsx b/apps/app/components/ui/multi-level-dropdown.tsx index ee096774f..e0d5b0f17 100644 --- a/apps/app/components/ui/multi-level-dropdown.tsx +++ b/apps/app/components/ui/multi-level-dropdown.tsx @@ -18,6 +18,7 @@ type MultiLevelDropdownProps = { label: string | JSX.Element; value: any; selected?: boolean; + element?: JSX.Element; }[]; }[]; onSelect: (value: any) => void; @@ -35,117 +36,121 @@ export const MultiLevelDropdown: React.FC = ({ const [openChildFor, setOpenChildFor] = useState(null); return ( - - {({ open }) => ( - <> -
- setOpenChildFor(null)} - className={`group flex items-center justify-between gap-2 rounded-md border border-brand-base px-3 py-1.5 text-xs shadow-sm duration-300 focus:outline-none ${ - open ? "bg-brand-surface-1 text-brand-base" : "text-brand-secondary" - }`} + <> + + {({ open }) => ( + <> +
+ setOpenChildFor(null)} + className={`group flex items-center justify-between gap-2 rounded-md border border-brand-base px-3 py-1.5 text-xs shadow-sm duration-300 focus:outline-none ${ + open ? "bg-brand-surface-1 text-brand-base" : "text-brand-secondary" + }`} + > + {label} + +
+ - {label} -
- - - {options.map((option) => ( -
- { - if (option.children) { - e.stopPropagation(); - e.preventDefault(); + + {options.map((option) => ( +
+ { + if (option.children) { + e.stopPropagation(); + e.preventDefault(); - if (openChildFor === option.id) setOpenChildFor(null); - else setOpenChildFor(option.id); - } else { - onSelect(option.value); - } - }} - className="w-full" - > - {({ active }) => ( - <> -
- {direction === "left" && option.children && ( -
- - )} -
- {option.children && option.id === openChildFor && ( -
-
- {option.children.map((child) => ( - - ))} + {direction === "left" && option.children && ( +
+ + )} + + {option.children && option.id === openChildFor && ( +
+
+ {option.children.map((child) => { + if (child.element) return child.element; + else + return ( + + ); + })} +
-
- )} -
- ))} -
- - - )} -
+ )} +
+ ))} + + + + )} + + ); }; diff --git a/apps/app/components/views/select-filters.tsx b/apps/app/components/views/select-filters.tsx index 164c4f58c..6c2c04954 100644 --- a/apps/app/components/views/select-filters.tsx +++ b/apps/app/components/views/select-filters.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; + import { useRouter } from "next/router"; import useSWR from "swr"; @@ -6,18 +8,22 @@ import useSWR from "swr"; import stateService from "services/state.service"; import projectService from "services/project.service"; import issuesService from "services/issues.service"; +// components +import { DueDateFilterModal } from "components/core"; // ui import { Avatar, MultiLevelDropdown } from "components/ui"; // icons import { getPriorityIcon, getStateGroupIcon } from "components/icons"; // helpers import { getStatesList } from "helpers/state.helper"; +import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types import { IIssueFilterOptions, IQuery } from "types"; // fetch-keys import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fetch-keys"; // constants import { PRIORITIES } from "constants/project"; +import { DUE_DATES } from "constants/due-dates"; type Props = { filters: Partial | IQuery; @@ -32,6 +38,8 @@ export const SelectFilters: React.FC = ({ direction = "right", height = "md", }) => { + const [isDueDateFilterModalOpen, setIsDueDateFilterModalOpen] = useState(false); + const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -58,125 +66,163 @@ export const SelectFilters: React.FC = ({ ); return ( - ({ - id: priority === null ? "null" : priority, - label: ( -
- {getPriorityIcon(priority)} {priority ?? "None"} -
- ), - value: { - key: "priority", - value: priority === null ? "null" : priority, + <> + {isDueDateFilterModalOpen && ( + setIsDueDateFilterModalOpen(false)} + /> + )} + ({ + 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: "state", + label: "State", + value: statesList, + children: [ + ...statesList.map((state) => ({ + id: state.id, + label: ( +
+ {getStateGroupIcon(state.group, "16", "16", state.color)} {state.name} +
+ ), + value: { + key: "state", + value: state.id, + }, + selected: filters?.state?.includes(state.id), + })), + ], + }, + { + id: "assignees", + label: "Assignees", + value: members, + children: [ + ...(members?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email} +
+ ), + value: { + key: "assignees", + value: member.member.id, + }, + selected: filters?.assignees?.includes(member.member.id), + })) ?? []), + ], + }, + { + id: "created_by", + label: "Created by", + value: members, + children: [ + ...(members?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email} +
+ ), + value: { + key: "created_by", + value: member.member.id, + }, + selected: filters?.created_by?.includes(member.member.id), + })) ?? []), + ], + }, + { + id: "labels", + label: "Labels", + value: issueLabels, + children: [ + ...(issueLabels?.map((label) => ({ + id: label.id, + label: ( +
+
+ {label.name} +
+ ), + value: { + key: "labels", + value: label.id, + }, + selected: filters?.labels?.includes(label.id), + })) ?? []), + ], + }, + { + id: "target_date", + label: "Due date", + value: DUE_DATES, + children: [ + ...(DUE_DATES?.map((option) => ({ + id: option.name, + label: option.name, + value: { + key: "target_date", + value: option.value, + }, + selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], option.value), + })) ?? []), + { + id: "custom", + label: "Custom", + value: "custom", + element: ( + + ), }, - selected: filters?.priority?.includes(priority === null ? "null" : priority), - })), - ], - }, - { - id: "state", - label: "State", - value: statesList, - children: [ - ...statesList.map((state) => ({ - id: state.id, - label: ( -
- {getStateGroupIcon(state.group, "16", "16", state.color)} {state.name} -
- ), - value: { - key: "state", - value: state.id, - }, - selected: filters?.state?.includes(state.id), - })), - ], - }, - { - id: "assignees", - label: "Assignees", - value: members, - children: [ - ...(members?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.first_name && member.member.first_name !== "" - ? member.member.first_name - : member.member.email} -
- ), - value: { - key: "assignees", - value: member.member.id, - }, - selected: filters?.assignees?.includes(member.member.id), - })) ?? []), - ], - }, - { - id: "created_by", - label: "Created By", - value: members, - children: [ - ...(members?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.first_name && member.member.first_name !== "" - ? member.member.first_name - : member.member.email} -
- ), - value: { - key: "created_by", - value: member.member.id, - }, - selected: filters?.created_by?.includes(member.member.id), - })) ?? []), - ], - }, - { - id: "labels", - label: "Labels", - value: issueLabels, - children: [ - ...(issueLabels?.map((label) => ({ - id: label.id, - label: ( -
-
- {label.name} -
- ), - value: { - key: "labels", - value: label.id, - }, - selected: filters?.labels?.includes(label.id), - })) ?? []), - ], - }, - ]} - /> + ], + }, + ]} + /> + ); }; diff --git a/apps/app/constants/due-dates.ts b/apps/app/constants/due-dates.ts new file mode 100644 index 000000000..362fd41a5 --- /dev/null +++ b/apps/app/constants/due-dates.ts @@ -0,0 +1,37 @@ +// helper +import { renderDateFormat } from "helpers/date-time.helper"; + +export const DUE_DATES = [ + { + name: "Last week", + value: [ + `${renderDateFormat(new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000))};after`, + `${renderDateFormat(new Date())};before`, + ], + }, + { + name: "2 weeks from now", + value: [ + `${renderDateFormat(new Date())};after`, + `${renderDateFormat(new Date(new Date().getTime() + 14 * 24 * 60 * 60 * 1000))};before`, + ], + }, + { + name: "1 month from now", + value: [ + `${renderDateFormat(new Date())};after`, + `${renderDateFormat( + new Date(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()) + )};before`, + ], + }, + { + name: "2 months from now", + value: [ + `${renderDateFormat(new Date())};after`, + `${renderDateFormat( + new Date(new Date().getFullYear(), new Date().getMonth() + 2, new Date().getDate()) + )};before`, + ], + }, +]; diff --git a/apps/app/constants/project.ts b/apps/app/constants/project.ts index 9ddae96c8..41688f7a7 100644 --- a/apps/app/constants/project.ts +++ b/apps/app/constants/project.ts @@ -1,3 +1,4 @@ + export const NETWORK_CHOICES = { "0": "Secret", "2": "Public" }; export const GROUP_CHOICES = { diff --git a/apps/app/contexts/issue-view.context.tsx b/apps/app/contexts/issue-view.context.tsx index d2a4496c9..454023d54 100644 --- a/apps/app/contexts/issue-view.context.tsx +++ b/apps/app/contexts/issue-view.context.tsx @@ -89,6 +89,7 @@ export const initialState: StateType = { issue__assignees__id: null, issue__labels__id: null, created_by: null, + target_date: null, }, }; diff --git a/apps/app/helpers/array.helper.ts b/apps/app/helpers/array.helper.ts index 2432f88ad..f8134b440 100644 --- a/apps/app/helpers/array.helper.ts +++ b/apps/app/helpers/array.helper.ts @@ -42,3 +42,11 @@ export const findStringWithMostCharacters = (strings: string[]) => strings.reduce((longestString, currentString) => currentString.length > longestString.length ? currentString : longestString ); + +export const checkIfArraysHaveSameElements = (arr1: any[] | null, arr2: any[] | null): boolean => { + if (!arr1 || !arr2) return false; + if (!Array.isArray(arr1) || !Array.isArray(arr2)) return false; + if (arr1.length === 0 && arr2.length === 0) return true; + + return arr1.length === arr2.length && arr1.every((e) => arr2.includes(e)); +}; diff --git a/apps/app/hooks/use-issues-view.tsx b/apps/app/hooks/use-issues-view.tsx index 0e27ed049..cbd550053 100644 --- a/apps/app/hooks/use-issues-view.tsx +++ b/apps/app/hooks/use-issues-view.tsx @@ -60,6 +60,7 @@ const useIssuesView = () => { ? filters?.issue__labels__id.join(",") : undefined, created_by: filters?.created_by ? filters?.created_by.join(",") : undefined, + target_date: filters?.target_date ? filters?.target_date.join(",") : undefined, }; const { data: projectIssues } = useSWR( diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx index 78af8e9e1..917515931 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx @@ -159,7 +159,7 @@ const SingleCycle: React.FC = () => { > setAnalyticsModal(false)} />
diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx index 2270ce7f4..8fdab7f4f 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx @@ -99,7 +99,9 @@ const ProjectIssues: NextPage = () => { } > setAnalyticsModal(false)} /> - +
+ +
); diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx index 51b6b7a5b..8e15cc6f3 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx @@ -164,7 +164,7 @@ const SingleModule: React.FC = () => { setAnalyticsModal(false)} />
diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/views/[viewId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/views/[viewId].tsx index 080aa9011..b1cbf97f2 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/views/[viewId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/views/[viewId].tsx @@ -101,7 +101,9 @@ const SingleView: React.FC = () => {
} > - +
+ +
); diff --git a/apps/app/styles/globals.css b/apps/app/styles/globals.css index ccfda2248..75692e97b 100644 --- a/apps/app/styles/globals.css +++ b/apps/app/styles/globals.css @@ -179,25 +179,6 @@ body { outline: none; } -/* react datepicker styling */ -.react-datepicker-wrapper input::placeholder { - color: rgba(var(--color-text-secondary)); - opacity: 1; -} - -.react-datepicker-wrapper input:-ms-input-placeholder { - color: rgba(var(--color-text-secondary)); -} - -.react-datepicker-wrapper .react-datepicker__close-icon::after { - background: transparent; - color: rgba(var(--color-text-secondary)); -} - -.react-datepicker-popper { - z-index: 30 !important; -} - .conical-gradient { background: conic-gradient( from 180deg at 50% 50%, diff --git a/apps/app/styles/react-datepicker.css b/apps/app/styles/react-datepicker.css index 918f4ed66..2c45fda44 100644 --- a/apps/app/styles/react-datepicker.css +++ b/apps/app/styles/react-datepicker.css @@ -81,7 +81,7 @@ } .react-datepicker__day-name { - color: rgba(var(--color-text-base)) !important; + color: rgba(var(--color-text-secondary)) !important; } .react-datepicker__week { diff --git a/apps/app/types/issues.d.ts b/apps/app/types/issues.d.ts index a33a04ffc..aac0ec4eb 100644 --- a/apps/app/types/issues.d.ts +++ b/apps/app/types/issues.d.ts @@ -239,6 +239,7 @@ export interface IIssueLite { export interface IIssueFilterOptions { type: "active" | "backlog" | null; assignees: string[] | null; + target_date: string[] | null; state: string[] | null; labels: string[] | null; issue__assignees__id: string[] | null; From e530533f9e5f4bc5c6e823747293077756ece992 Mon Sep 17 00:00:00 2001 From: Chandan Jal <97095857+ChandanJal@users.noreply.github.com> Date: Tue, 4 Jul 2023 23:13:31 +0530 Subject: [PATCH 07/18] fix: layout of tabs on Pages is not adaptable to mobile screens #1380 (#1400) * fix: layout of tabs on Pages is not adaptable to mobile screens #1380 * fix: scrolling experience on page --- .../projects/[projectId]/pages/index.tsx | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx index a51c8b44f..f76c7f7e4 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx @@ -124,7 +124,29 @@ const ProjectPages: NextPage = () => { } >
-

Pages

+
+

Pages

+
+ + +
+
{ }} > -
+
{tabsList.map((tab, index) => ( { ))}
-
- - -
From c3fe221e7a49d340e22137bd7e07c23462393da4 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 5 Jul 2023 00:56:15 +0530 Subject: [PATCH 08/18] chore: update project members type (#1459) --- apps/app/types/projects.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/types/projects.d.ts b/apps/app/types/projects.d.ts index c9972bc62..4a6e5154a 100644 --- a/apps/app/types/projects.d.ts +++ b/apps/app/types/projects.d.ts @@ -80,8 +80,8 @@ type ProjectViewTheme = { export interface IProjectMember { id: string; member: IUserLite; - project: IProject; - workspace: IWorkspace; + project: IProjectLite; + workspace: IWorkspaceLite; comment: string; role: 5 | 10 | 15 | 20; From 3906503c1bd9c6963f702e84b90432ca0c3405ed Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 5 Jul 2023 00:56:39 +0530 Subject: [PATCH 09/18] fix: state icon color on group titles (#1435) --- .../components/core/board-view/board-header.tsx | 15 ++------------- .../app/components/core/list-view/single-list.tsx | 5 ++--- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/apps/app/components/core/board-view/board-header.tsx b/apps/app/components/core/board-view/board-header.tsx index a5df7a426..30ec5c0fb 100644 --- a/apps/app/components/core/board-view/board-header.tsx +++ b/apps/app/components/core/board-view/board-header.tsx @@ -57,18 +57,6 @@ export const BoardHeader: React.FC = ({ : null ); - let bgColor = "#000000"; - if (selectedGroup === "state") bgColor = currentState?.color ?? "#000000"; - - if (selectedGroup === "priority") - groupTitle === "high" - ? (bgColor = "#dc2626") - : groupTitle === "medium" - ? (bgColor = "#f97316") - : groupTitle === "low" - ? (bgColor = "#22c55e") - : (bgColor = "#ff0000"); - const getGroupTitle = () => { let title = addSpaceIfCamelCase(groupTitle); @@ -96,7 +84,8 @@ export const BoardHeader: React.FC = ({ switch (selectedGroup) { case "state": - icon = currentState && getStateGroupIcon(currentState.group, "16", "16", bgColor); + icon = + currentState && getStateGroupIcon(currentState.group, "16", "16", currentState.color); break; case "priority": icon = getPriorityIcon(groupTitle, "text-lg"); diff --git a/apps/app/components/core/list-view/single-list.tsx b/apps/app/components/core/list-view/single-list.tsx index a8c6e4a51..b76f3ad8a 100644 --- a/apps/app/components/core/list-view/single-list.tsx +++ b/apps/app/components/core/list-view/single-list.tsx @@ -33,7 +33,6 @@ import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { type?: "issue" | "cycle" | "module"; currentState?: IState | null; - bgColor?: string; groupTitle: string; groupedByIssues: { [key: string]: IIssue[]; @@ -53,7 +52,6 @@ type Props = { export const SingleList: React.FC = ({ type, currentState, - bgColor, groupTitle, groupedByIssues, selectedGroup, @@ -113,7 +111,8 @@ export const SingleList: React.FC = ({ switch (selectedGroup) { case "state": - icon = currentState && getStateGroupIcon(currentState.group, "16", "16", bgColor); + icon = + currentState && getStateGroupIcon(currentState.group, "16", "16", currentState.color); break; case "priority": icon = getPriorityIcon(groupTitle, "text-lg"); From cc0701a823e6c796d227b928133a86179b2b84a0 Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 5 Jul 2023 21:00:30 +0530 Subject: [PATCH 10/18] fix: workspace invitation delete for self hosted (#1475) --- apiserver/plane/api/urls.py | 1 - apiserver/plane/api/views/workspace.py | 28 +++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 936fd73ab..806ebcd6f 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -295,7 +295,6 @@ urlpatterns = [ { "delete": "destroy", "get": "retrieve", - "get": "retrieve", } ), name="workspace", diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 7263c5647..4c136ed8c 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -118,9 +118,7 @@ class WorkSpaceViewSet(BaseViewSet): if len(name) > 80 or len(slug) > 48: return Response( - { - "error": "The maximum length for name is 80 and for slug is 48" - }, + {"error": "The maximum length for name is 80 and for slug is 48"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -444,6 +442,30 @@ class WorkspaceInvitationsViewset(BaseViewSet): .select_related("workspace", "workspace__owner", "created_by") ) + def destroy(self, request, slug, pk): + try: + workspace_member_invite = WorkspaceMemberInvite.objects.get( + pk=pk, workspace__slug=slug + ) + # delete the user if signup is disabled + if settings.DOCKERIZED and not settings.ENABLE_SIGNUP: + user = User.objects.filter(email=workspace_member_invite.email).first() + if user is not None: + user.delete() + workspace_member_invite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except WorkspaceMemberInvite.DoesNotExist: + return Response( + {"error": "Workspace member invite does not exists"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + class UserWorkspaceInvitationsEndpoint(BaseViewSet): serializer_class = WorkSpaceMemberInviteSerializer From 6bfeb6af347699659d52d3a655589f82c3c0b9ce Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Thu, 6 Jul 2023 11:58:24 +0530 Subject: [PATCH 11/18] chore: upgrade backend dependencies (#1479) * chore: upgrade backend dependencies * dev: update storage settings for self hosted version --- apiserver/plane/api/views/issue.py | 2 +- apiserver/plane/settings/production.py | 10 +++++----- apiserver/plane/urls.py | 6 ++---- apiserver/requirements/base.txt | 14 +++++++------- apiserver/requirements/local.txt | 2 +- apiserver/requirements/production.txt | 7 +++---- 6 files changed, 19 insertions(+), 22 deletions(-) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 36b3411fc..bfefc91ba 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -256,7 +256,7 @@ class IssueViewSet(BaseViewSet): return Response(issues, status=status.HTTP_200_OK) except Exception as e: - capture_exception(e) + print(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 983931110..7e76404f6 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -13,9 +13,7 @@ from sentry_sdk.integrations.redis import RedisIntegration from .common import * # noqa # Database -DEBUG = int(os.environ.get( - "DEBUG", 0 -)) == 1 +DEBUG = int(os.environ.get("DEBUG", 0)) == 1 DATABASES = { "default": { @@ -89,7 +87,7 @@ if bool(os.environ.get("SENTRY_DSN", False)): if DOCKERIZED and USE_MINIO: INSTALLED_APPS += ("storages",) - DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" + STORAGES = {"default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}} # The AWS access key to use. AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") # The AWS secret access key to use. @@ -97,7 +95,9 @@ if DOCKERIZED and USE_MINIO: # The name of the bucket to store files in. AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") # The full URL to the S3 endpoint. Leave blank to use the default region URL. - AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "http://plane-minio:9000") + AWS_S3_ENDPOINT_URL = os.environ.get( + "AWS_S3_ENDPOINT_URL", "http://plane-minio:9000" + ) # Default permissions AWS_DEFAULT_ACL = "public-read" AWS_QUERYSTRING_AUTH = False diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index a2244ffe0..2b83ef8cf 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -3,11 +3,10 @@ """ # from django.contrib import admin -from django.urls import path +from django.urls import path, include, re_path from django.views.generic import TemplateView from django.conf import settings -from django.conf.urls import include, url, static # from django.conf.urls.static import static @@ -18,11 +17,10 @@ urlpatterns = [ path("", include("plane.web.urls")), ] -urlpatterns = urlpatterns + static.static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: import debug_toolbar urlpatterns = [ - url(r"^__debug__/", include(debug_toolbar.urls)), + re_path(r"^__debug__/", include(debug_toolbar.urls)), ] + urlpatterns diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 3cd196830..537564828 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,28 +1,28 @@ # base requirements -Django==3.2.19 +Django==4.2.3 django-braces==1.15.0 django-taggit==4.0.0 psycopg2==2.9.6 django-oauth-toolkit==2.3.0 -mistune==2.0.4 +mistune==3.0.1 djangorestframework==3.14.0 redis==4.6.0 django-nested-admin==4.0.2 django-cors-headers==4.1.0 -whitenoise==6.3.0 +whitenoise==6.5.0 django-allauth==0.54.0 -faker==13.4.0 +faker==18.11.2 django-filter==23.2 jsonmodels==2.6.0 djangorestframework-simplejwt==5.2.2 -sentry-sdk==1.26.0 +sentry-sdk==1.27.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 +google-auth==2.21.0 +google-api-python-client==2.92.0 django-redis==5.3.0 uvicorn==0.22.0 channels==4.0.0 diff --git a/apiserver/requirements/local.txt b/apiserver/requirements/local.txt index efd74a071..426236ed8 100644 --- a/apiserver/requirements/local.txt +++ b/apiserver/requirements/local.txt @@ -1,3 +1,3 @@ -r base.txt -django-debug-toolbar==3.8.1 \ No newline at end of file +django-debug-toolbar==4.1.0 \ No newline at end of file diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt index 13b3e9aed..30d9dc9bb 100644 --- a/apiserver/requirements/production.txt +++ b/apiserver/requirements/production.txt @@ -2,11 +2,10 @@ dj-database-url==2.0.0 gunicorn==20.1.0 -whitenoise==6.3.0 +whitenoise==6.5.0 django-storages==1.13.2 -boto3==1.26.163 +boto3==1.27.0 django-anymail==10.0 -twilio==7.16.2 -django-debug-toolbar==3.8.1 +django-debug-toolbar==4.1.0 gevent==22.10.2 psycogreen==1.0.2 \ No newline at end of file From 2f8a1dbe2087832a52443e1a699eea5f52aff0a7 Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Thu, 6 Jul 2023 11:58:35 +0530 Subject: [PATCH 12/18] chore: project members endpoint to support bulk operations (#1464) --- apiserver/plane/api/views/project.py | 64 ++++++++++++++-------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 68a34ab48..822dc78b5 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -259,7 +259,7 @@ class ProjectViewSet(BaseViewSet): group="backlog", description="Default state for managing all Inbox Issues", project_id=pk, - color="#ff7700" + color="#ff7700", ) return Response(serializer.data, status=status.HTTP_200_OK) @@ -550,45 +550,47 @@ class AddMemberToProjectEndpoint(BaseAPIView): def post(self, request, slug, project_id): try: - member_id = request.data.get("member_id", False) - role = request.data.get("role", False) + members = request.data.get("members", []) - if not member_id or not role: + # get the project + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + if not len(members): return Response( - {"error": "Member ID and role is required"}, + {"error": "Atleast one member is required"}, status=status.HTTP_400_BAD_REQUEST, ) - # Check if the user is a member in the workspace - if not WorkspaceMember.objects.filter( - workspace__slug=slug, member_id=member_id - ).exists(): - # TODO: Update this error message - nk - return Response( - { - "error": "User is not a member of the workspace. Invite the user to the workspace to add him to project" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check if the user is already member of project - if ProjectMember.objects.filter( - project=project_id, member_id=member_id - ).exists(): - return Response( - {"error": "User is already a member of the project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Add the user to project - project_member = ProjectMember.objects.create( - project_id=project_id, member_id=member_id, role=role + project_members = ProjectMember.objects.bulk_create( + [ + ProjectMember( + member_id=member.get("member_id"), + role=member.get("role", 10), + project_id=project_id, + workspace_id=project.workspace_id, + ) + for member in members + ], + batch_size=10, + ignore_conflicts=True, ) - serializer = ProjectMemberSerializer(project_member) + serializer = ProjectMemberSerializer(project_members, many=True) return Response(serializer.data, status=status.HTTP_201_CREATED) - + except KeyError: + return Response( + {"error": "Incorrect data sent"}, status=status.HTTP_400_BAD_REQUEST + ) + except Project.DoesNotExist: + return Response( + {"error": "Project does not exist"}, status=status.HTTP_400_BAD_REQUEST + ) + except IntegrityError: + return Response( + {"error": "User not member of the workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) except Exception as e: capture_exception(e) return Response( From 8443dfc9dde7c0ed22c6f019b6c4331454e6bd0d Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Thu, 6 Jul 2023 11:58:47 +0530 Subject: [PATCH 13/18] chore: rename workspace company size to organization size (#1463) * chore: rename workspace company size to organization size * chore: make workspace organization size as required --- .../db/migrations/0035_auto_20230704_2225.py | 42 +++++++++++++++++++ .../0036_alter_workspace_organization_size.py | 18 ++++++++ apiserver/plane/db/models/workspace.py | 2 +- 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 apiserver/plane/db/migrations/0035_auto_20230704_2225.py create mode 100644 apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py diff --git a/apiserver/plane/db/migrations/0035_auto_20230704_2225.py b/apiserver/plane/db/migrations/0035_auto_20230704_2225.py new file mode 100644 index 000000000..dec6265e6 --- /dev/null +++ b/apiserver/plane/db/migrations/0035_auto_20230704_2225.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.19 on 2023-07-04 16:55 + +from django.db import migrations, models + + +def update_company_organization_size(apps, schema_editor): + Model = apps.get_model("db", "Workspace") + updated_size = [] + for obj in Model.objects.all(): + obj.organization_size = str(obj.company_size) + updated_size.append(obj) + + Model.objects.bulk_update(updated_size, ["organization_size"], batch_size=100) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0034_auto_20230628_1046"), + ] + + operations = [ + migrations.AddField( + model_name="workspace", + name="organization_size", + field=models.CharField(default="2-10", max_length=20), + ), + migrations.RunPython(update_company_organization_size), + migrations.AlterField( + model_name="workspace", + name="name", + field=models.CharField(max_length=80, verbose_name="Workspace Name"), + ), + migrations.AlterField( + model_name="workspace", + name="slug", + field=models.SlugField(max_length=48, unique=True), + ), + migrations.RemoveField( + model_name="workspace", + name="company_size", + ), + ] diff --git a/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py b/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py new file mode 100644 index 000000000..0b182f50b --- /dev/null +++ b/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2023-07-05 07:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0035_auto_20230704_2225'), + ] + + operations = [ + migrations.AlterField( + model_name='workspace', + name='organization_size', + field=models.CharField(max_length=20), + ), + ] diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index f071bac2a..9b9fbb68c 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -23,7 +23,7 @@ class Workspace(BaseModel): related_name="owner_workspace", ) slug = models.SlugField(max_length=48, db_index=True, unique=True) - company_size = models.PositiveIntegerField(default=10) + organization_size = models.CharField(max_length=20) def __str__(self): """Return name of the Workspace""" From 3a2f4d55d7d48eb168b01153aa50053cc20941bc Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Thu, 6 Jul 2023 15:30:34 +0530 Subject: [PATCH 14/18] fix: static and media files storages (#1482) --- apiserver/plane/settings/production.py | 18 +++++++++++------- apiserver/plane/settings/staging.py | 17 +++++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 7e76404f6..2e40c5998 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -70,8 +70,12 @@ CORS_ALLOW_HEADERS = [ ] CORS_ALLOW_CREDENTIALS = True -# Simplified static file serving. -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} if bool(os.environ.get("SENTRY_DSN", False)): sentry_sdk.init( @@ -87,7 +91,7 @@ if bool(os.environ.get("SENTRY_DSN", False)): if DOCKERIZED and USE_MINIO: INSTALLED_APPS += ("storages",) - STORAGES = {"default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}} + STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} # The AWS access key to use. AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") # The AWS secret access key to use. @@ -188,7 +192,10 @@ else: # extra characters appended. AWS_S3_FILE_OVERWRITE = False - DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage" + STORAGES["default"] = { + "BACKEND": "django_s3_storage.storage.S3Storage", + } + # AWS Settings End # Enable Connection Pooling (if desired) @@ -203,9 +210,6 @@ ALLOWED_HOSTS = [ ] -# Simplified static file serving. -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" - SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 5a43e266e..076bb3e3c 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -48,8 +48,12 @@ ALLOWED_HOSTS = ["*"] # TODO: Make it FALSE and LIST DOMAINS IN FULL PROD. CORS_ALLOW_ALL_ORIGINS = True -# Simplified static file serving. -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + # Make true if running in a docker environment DOCKERIZED = int(os.environ.get( @@ -151,7 +155,9 @@ AWS_S3_SIGNATURE_VERSION = None AWS_S3_FILE_OVERWRITE = False # AWS Settings End - +STORAGES["default"] = { + "BACKEND": "django_s3_storage.storage.S3Storage", +} # Enable Connection Pooling (if desired) # DATABASES['default']['ENGINE'] = 'django_postgrespool' @@ -164,11 +170,6 @@ ALLOWED_HOSTS = [ "*", ] - -DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage" -# Simplified static file serving. -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" - SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True From 49f37e0346718de29c9f71e72a4d73a336b96786 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 6 Jul 2023 16:08:49 +0530 Subject: [PATCH 15/18] fix: emoji render function (#1484) * fix: emoji render function * fix: emoji render function --- .../analytics/custom-analytics/sidebar.tsx | 5 ++-- .../components/emoji-icon-picker/index.tsx | 6 ++--- .../project/create-project-modal.tsx | 4 +-- .../project/single-project-card.tsx | 3 ++- .../project/single-sidebar-project.tsx | 3 ++- apps/app/helpers/common.helper.ts | 20 --------------- apps/app/helpers/emoji.helper.ts | 25 +++++++++++++++++++ .../projects/[projectId]/settings/index.tsx | 4 ++- 8 files changed, 40 insertions(+), 30 deletions(-) create mode 100644 apps/app/helpers/emoji.helper.ts diff --git a/apps/app/components/analytics/custom-analytics/sidebar.tsx b/apps/app/components/analytics/custom-analytics/sidebar.tsx index b533df519..418d87c4a 100644 --- a/apps/app/components/analytics/custom-analytics/sidebar.tsx +++ b/apps/app/components/analytics/custom-analytics/sidebar.tsx @@ -23,6 +23,7 @@ import { import { ContrastIcon, LayerDiagonalIcon } from "components/icons"; // helpers import { renderShortDate } from "helpers/date-time.helper"; +import { renderEmoji } from "helpers/emoji.helper"; // types import { IAnalyticsParams, @@ -221,7 +222,7 @@ export const AnalyticsSidebar: React.FC = ({
{project.emoji ? ( - {String.fromCodePoint(parseInt(project.emoji))} + {renderEmoji(project.emoji)} ) : project.icon_prop ? (
@@ -336,7 +337,7 @@ export const AnalyticsSidebar: React.FC = ({
{projectDetails?.emoji ? (
- {String.fromCodePoint(parseInt(projectDetails.emoji))} + {renderEmoji(projectDetails.emoji)}
) : projectDetails?.icon_prop ? (
diff --git a/apps/app/components/emoji-icon-picker/index.tsx b/apps/app/components/emoji-icon-picker/index.tsx index f8f8e54e9..fecba1c15 100644 --- a/apps/app/components/emoji-icon-picker/index.tsx +++ b/apps/app/components/emoji-icon-picker/index.tsx @@ -10,7 +10,7 @@ import emojis from "./emojis.json"; import icons from "./icons.json"; // helpers import { getRecentEmojis, saveRecentEmoji } from "./helpers"; -import { getRandomEmoji } from "helpers/common.helper"; +import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper"; // hooks import useOutsideClickDetector from "hooks/use-outside-click-detector"; @@ -101,7 +101,7 @@ const EmojiIconPicker: React.FC = ({ label, value, onChange, onIconColorC setIsOpen(false); }} > - {String.fromCodePoint(parseInt(emoji))} + {renderEmoji(emoji)} ))}
@@ -121,7 +121,7 @@ const EmojiIconPicker: React.FC = ({ label, value, onChange, onIconColorC setIsOpen(false); }} > - {String.fromCodePoint(parseInt(emoji))} + {renderEmoji(emoji)} ))}
diff --git a/apps/app/components/project/create-project-modal.tsx b/apps/app/components/project/create-project-modal.tsx index 06fc73348..f17558d9b 100644 --- a/apps/app/components/project/create-project-modal.tsx +++ b/apps/app/components/project/create-project-modal.tsx @@ -21,7 +21,7 @@ import { XMarkIcon } from "@heroicons/react/24/outline"; import { ImagePickerPopover } from "components/core"; import EmojiIconPicker from "components/emoji-icon-picker"; // helpers -import { getRandomEmoji } from "helpers/common.helper"; +import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper"; // types import { ICurrentUserResponse, IProject } from "types"; // fetch-keys @@ -232,7 +232,7 @@ export const CreateProjectModal: React.FC = (props) => { {value.name} ) : ( - String.fromCodePoint(parseInt(value)) + renderEmoji(value) ) ) : ( "Icon" diff --git a/apps/app/components/project/single-project-card.tsx b/apps/app/components/project/single-project-card.tsx index 9b7bf1c99..b4fe9c1b1 100644 --- a/apps/app/components/project/single-project-card.tsx +++ b/apps/app/components/project/single-project-card.tsx @@ -24,6 +24,7 @@ import { // helpers import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { copyTextToClipboard, truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; // types import type { IFavoriteProject, IProject } from "types"; // fetch-keys @@ -184,7 +185,7 @@ export const SingleProjectCard: React.FC = ({

{project.name}

{project.emoji ? ( - {String.fromCodePoint(parseInt(project.emoji))} + {renderEmoji(project.emoji)} ) : project.icon_prop ? ( = ({
{project.emoji ? ( - {String.fromCodePoint(parseInt(project.emoji))} + {renderEmoji(project.emoji)} ) : project.icon_prop ? (
diff --git a/apps/app/helpers/common.helper.ts b/apps/app/helpers/common.helper.ts index 887fe8052..4220a7174 100644 --- a/apps/app/helpers/common.helper.ts +++ b/apps/app/helpers/common.helper.ts @@ -16,23 +16,3 @@ export const debounce = (func: any, wait: number, immediate: boolean = false) => if (callNow) func(...args); }; }; - -export const getRandomEmoji = () => { - const emojis = [ - "8986", - "9200", - "128204", - "127773", - "127891", - "127947", - "128076", - "128077", - "128187", - "128188", - "128512", - "128522", - "128578", - ]; - - return emojis[Math.floor(Math.random() * emojis.length)]; -}; diff --git a/apps/app/helpers/emoji.helper.ts b/apps/app/helpers/emoji.helper.ts new file mode 100644 index 000000000..f16d0021c --- /dev/null +++ b/apps/app/helpers/emoji.helper.ts @@ -0,0 +1,25 @@ +export const getRandomEmoji = () => { + const emojis = [ + "8986", + "9200", + "128204", + "127773", + "127891", + "127947", + "128076", + "128077", + "128187", + "128188", + "128512", + "128522", + "128578", + ]; + + return emojis[Math.floor(Math.random() * emojis.length)]; +}; + +export const renderEmoji = (emoji: string) => { + if (!emoji) return; + + return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji)); +}; diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx index 92b55cebe..e8ac1d9d0 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx @@ -27,6 +27,8 @@ import { DangerButton, } from "components/ui"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; +// helpers +import { renderEmoji } from "helpers/emoji.helper"; // types import { IProject, IWorkspace } from "types"; import type { NextPage } from "next"; @@ -186,7 +188,7 @@ const GeneralSettings: NextPage = () => { {value.name} ) : ( - String.fromCodePoint(parseInt(value)) + renderEmoji(value) ) ) : ( "Icon" From 353c85120f41486e174adc6147196f037f9309e9 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 7 Jul 2023 14:10:36 +0530 Subject: [PATCH 16/18] feat: bulk invite for project (#1466) * feat: bulk invite for project * feat: members dropdown updated * fix: error message added ,style: ui improvement * feat: added add members button for scenarios with multiple members * chore: updated watch to fields --- .../project/send-project-invitation-modal.tsx | 321 +++++++++++------- apps/app/services/project.service.ts | 3 +- apps/app/types/projects.d.ts | 4 + 3 files changed, 201 insertions(+), 127 deletions(-) diff --git a/apps/app/components/project/send-project-invitation-modal.tsx b/apps/app/components/project/send-project-invitation-modal.tsx index 204633a85..65643d834 100644 --- a/apps/app/components/project/send-project-invitation-modal.tsx +++ b/apps/app/components/project/send-project-invitation-modal.tsx @@ -1,14 +1,23 @@ -import React from "react"; +import React, { useEffect } from "react"; import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, Controller, useFieldArray } from "react-hook-form"; import { Dialog, Transition } from "@headlessui/react"; // ui -import { CustomSelect, PrimaryButton, SecondaryButton, TextArea } from "components/ui"; +import { + Avatar, + CustomSearchSelect, + CustomSelect, + PrimaryButton, + SecondaryButton, +} from "components/ui"; +//icons +import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { ChevronDownIcon } from "@heroicons/react/20/solid"; // services import projectService from "services/project.service"; import workspaceService from "services/workspace.service"; @@ -17,9 +26,9 @@ import { useProjectMyMembership } from "contexts/project-member.context"; // hooks import useToast from "hooks/use-toast"; // types -import { ICurrentUserResponse, IProjectMemberInvitation } from "types"; +import { ICurrentUserResponse } from "types"; // fetch-keys -import { PROJECT_INVITATIONS, WORKSPACE_MEMBERS } from "constants/fetch-keys"; +import { PROJECT_MEMBERS, WORKSPACE_MEMBERS } from "constants/fetch-keys"; // constants import { ROLE } from "constants/workspace"; @@ -30,17 +39,22 @@ type Props = { user: ICurrentUserResponse | undefined; }; -type ProjectMember = IProjectMemberInvitation & { +type member = { + role: 5 | 10 | 15 | 20; member_id: string; - user_id: string; }; -const defaultValues: Partial = { - email: "", - message: "", - role: 5, - member_id: "", - user_id: "", +type FormValues = { + members: member[]; +}; + +const defaultValues: FormValues = { + members: [ + { + role: 5, + member_id: "", + }, + ], }; const SendProjectInvitationModal: React.FC = ({ isOpen, setIsOpen, members, user }) => { @@ -56,14 +70,16 @@ const SendProjectInvitationModal: React.FC = ({ isOpen, setIsOpen, member ); const { - register, formState: { errors, isSubmitting }, - handleSubmit, + reset, - setValue, + handleSubmit, control, - } = useForm({ - defaultValues, + } = useForm(); + + const { fields, append, remove } = useFieldArray({ + control, + name: "members", }); const uninvitedPeople = people?.filter((person) => { @@ -71,20 +87,14 @@ const SendProjectInvitationModal: React.FC = ({ isOpen, setIsOpen, member return !isInvited; }); - const onSubmit = async (formData: ProjectMember) => { + const onSubmit = async (formData: FormValues) => { if (!workspaceSlug || !projectId || isSubmitting) return; + const payload = { ...formData }; await projectService - .inviteProject(workspaceSlug as string, projectId as string, formData, user) - .then((response) => { + .inviteProject(workspaceSlug as string, projectId as string, payload, user) + .then(() => { setIsOpen(false); - mutate( - PROJECT_INVITATIONS, - (prevData) => { - if (!prevData) return prevData; - return [{ ...formData, ...response }, ...(prevData ?? [])]; - }, - false - ); + mutate(PROJECT_MEMBERS(projectId as string)); setToastAlert({ title: "Success", type: "success", @@ -93,6 +103,9 @@ const SendProjectInvitationModal: React.FC = ({ isOpen, setIsOpen, member }) .catch((error) => { console.log(error); + }) + .finally(() => { + reset(defaultValues); }); }; @@ -104,6 +117,35 @@ const SendProjectInvitationModal: React.FC = ({ isOpen, setIsOpen, member }, 500); }; + const appendField = () => { + append({ + role: 5, + member_id: "", + }); + }; + + useEffect(() => { + if (fields.length === 0) { + append([ + { + role: 5, + member_id: "", + }, + ]); + } + }, [fields, append]); + + const options = uninvitedPeople?.map((person) => ({ + value: person.member.id, + query: person.member.email, + content: ( +
+ + {person.member.email} +
+ ), + })); + return ( @@ -116,11 +158,11 @@ const SendProjectInvitationModal: React.FC = ({ isOpen, setIsOpen, member leaveFrom="opacity-100" leaveTo="opacity-0" > -
+
-
+
= ({ isOpen, setIsOpen, member leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
-
+
Invite Members -
-

- Invite members to work on your project. -

+
+ +
+
+
Email
+
Role
-
-
- ( - - {value && value !== "" - ? people?.find((p) => p.member.id === value)?.member.email - : "Select email"} -
- } - onChange={(val: string) => { - onChange(val); - const person = uninvitedPeople?.find((p) => p.member.id === val); - setValue("member_id", val); - setValue("email", person?.member.email ?? ""); - }} - input - width="w-full" - > - {uninvitedPeople && uninvitedPeople.length > 0 ? ( - <> - {uninvitedPeople?.map((person) => ( - - {person.member.email} - - ))} - - ) : ( -
- Invite members to workspace before adding them to a project. -
+
+ {fields.map((field, index) => ( +
+
+ ( + + {value && value !== "" ? ( +
+ p.member.id === value)?.member + } + /> + {people?.find((p) => p.member.id === value)?.member.email} +
+ ) : ( +
Select co-worker’s email
+ )} +
-
-
Role
- ( - - {field.value ? ROLE[field.value] : "Select role"} - - } - input - width="w-full" - > - {Object.entries(ROLE).map(([key, label]) => { - if (parseInt(key) > (memberDetails?.role ?? 5)) return null; + /> + {errors.members && errors.members[index]?.member_id && ( + + {errors.members[index]?.member_id?.message} + + )} +
- return ( - - {label} - - ); - })} - - )} - /> -
-
-