diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index f96be09ab..db6021433 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: diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index a75a878de..7ce79855b 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -299,7 +299,6 @@ urlpatterns = [ { "delete": "destroy", "get": "retrieve", - "get": "retrieve", } ), name="workspace", diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index b8ead2ab9..d96441c75 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -263,7 +263,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/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( diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 26c82d54c..4c136ed8c 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 @@ -21,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 @@ -93,14 +95,33 @@ 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 +181,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 +243,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 +314,18 @@ 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( + username=str(uuid4().hex), + email=invitation.email, + password=make_password(uuid4().hex), + is_password_autoset=True, + ) + for invitation in workspace_invitations + ], + batch_size=100, + ) for invitation in workspace_invitations: workspace_invitation.delay( @@ -400,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 @@ -865,7 +931,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/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 b00d53013..9b9fbb68c 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -15,15 +15,15 @@ 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) - company_size = models.PositiveIntegerField(default=10) + slug = models.SlugField(max_length=48, db_index=True, unique=True) + organization_size = models.CharField(max_length=20) def __str__(self): """Return name of the Workspace""" diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 983931110..2e40c5998 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": { @@ -72,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( @@ -89,7 +91,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 +99,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 @@ -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 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/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 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 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/core/board-view/board-header.tsx b/apps/app/components/core/board-view/board-header.tsx index a5df7a426..880a6b56b 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"); @@ -129,13 +118,13 @@ export const BoardHeader: React.FC = ({ >
{getGroupIcon()}

= ({ const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted; return calendarIssues ? ( -
+
= ({ {displayProperties && ( -
+
{properties.priority && ( = ({ issue={issue} partialUpdateIssue={partialUpdateIssue} position="left" + className="max-w-full" isNotAllowed={isNotAllowed} user={user} /> 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/core/filters/due-date-filter-modal.tsx b/apps/app/components/core/filters/due-date-filter-modal.tsx new file mode 100644 index 000000000..6c6bc4ec6 --- /dev/null +++ b/apps/app/components/core/filters/due-date-filter-modal.tsx @@ -0,0 +1,186 @@ +import { Fragment } from "react"; + +import { useRouter } from "next/router"; + +// react-hook-form +import { Controller, useForm } from "react-hook-form"; +// react-datepicker +import DatePicker from "react-datepicker"; +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// hooks +import useIssuesView from "hooks/use-issues-view"; +// components +import { DueDateFilterSelect } from "./due-date-filter-select"; +// ui +import { PrimaryButton, SecondaryButton } from "components/ui"; +// icons +import { XMarkIcon } from "@heroicons/react/20/solid"; +// helpers +import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper"; + +type Props = { + isOpen: boolean; + handleClose: () => 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 89% rename from apps/app/components/core/issues-view-filter.tsx rename to apps/app/components/core/filters/issues-view-filter.tsx index a6996793c..67b2423ec 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" @@ -262,9 +270,16 @@ export const IssuesFilterView: React.FC = () => { if (key === "estimate" && !isEstimateActive) return null; if ( - (issueView === "spreadsheet" && key === "attachment_count") || - (issueView === "spreadsheet" && key === "link") || - (issueView === "spreadsheet" && key === "sub_issue_count") + issueView === "spreadsheet" && + (key === "attachment_count" || + key === "link" || + key === "sub_issue_count") + ) + return null; + + if ( + issueView !== "spreadsheet" && + (key === "created_on" || key === "updated_on") ) return null; 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/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"); 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/core/spreadsheet-view/single-issue.tsx b/apps/app/components/core/spreadsheet-view/single-issue.tsx index ada1e3689..daeeedd9a 100644 --- a/apps/app/components/core/spreadsheet-view/single-issue.tsx +++ b/apps/app/components/core/spreadsheet-view/single-issue.tsx @@ -42,6 +42,7 @@ import { import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types"; // helper import { copyTextToClipboard } from "helpers/string.helper"; +import { renderLongDetailDateFormat } from "helpers/date-time.helper"; type Props = { issue: IIssue; @@ -274,6 +275,7 @@ export const SingleSpreadsheetIssue: React.FC = ({ issue={issue} partialUpdateIssue={partialUpdateIssue} position="left" + className="max-w-full" tooltipPosition={tooltipPosition} customButton user={user} @@ -345,6 +347,16 @@ export const SingleSpreadsheetIssue: React.FC = ({ />
)} + {properties.created_on && ( +
+ {renderLongDetailDateFormat(issue.created_at)} +
+ )} + {properties.updated_on && ( +
+ {renderLongDetailDateFormat(issue.updated_at)} +
+ )}
); }; diff --git a/apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx b/apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx index a0f404fba..b05a5e82b 100644 --- a/apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx +++ b/apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx @@ -123,7 +123,9 @@ export const SpreadsheetColumns: React.FC = ({ columnData, gridTemplateCo Z - ) : col.propertyName === "due_date" ? ( + ) : col.propertyName === "due_date" || + col.propertyName === "created_on" || + col.propertyName === "updated_on" ? ( <> = ({ > - {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/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/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/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 (
, issue: IIssue) => void; position?: "left" | "right"; tooltipPosition?: "top" | "bottom"; + className?: string; selfPositioned?: boolean; customButton?: boolean; user: ICurrentUserResponse | undefined; @@ -33,6 +34,7 @@ export const ViewStateSelect: React.FC = ({ partialUpdateIssue, position = "left", tooltipPosition = "top", + className = "", selfPositioned = false, customButton = false, user, @@ -68,16 +70,19 @@ export const ViewStateSelect: React.FC = ({ tooltipContent={addSpaceIfCamelCase(selectedOption?.name ?? "")} position={tooltipPosition} > -
- {selectedOption && - getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)} - {selectedOption?.name ?? "State"} +
+ + {selectedOption && + getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)} + + {selectedOption?.name ?? "State"}
); return ( { partialUpdateIssue( diff --git a/apps/app/components/modules/sidebar.tsx b/apps/app/components/modules/sidebar.tsx index 4c7feacbd..3b70814f7 100644 --- a/apps/app/components/modules/sidebar.tsx +++ b/apps/app/components/modules/sidebar.tsx @@ -34,7 +34,7 @@ import { CustomMenu, CustomSelect, Loader, ProgressBar } from "components/ui"; import { ExclamationIcon } from "components/icons"; import { LinkIcon } from "@heroicons/react/20/solid"; // helpers -import { renderDateFormat, renderShortDate } from "helpers/date-time.helper"; +import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper"; // types import { ICurrentUserResponse, IIssue, IModule, ModuleLink } from "types"; @@ -228,7 +228,10 @@ export const ModuleDetailsSidebar: React.FC = ({ 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/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/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} - - ); - })} - - )} - /> -
-
-