diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index b2dca6625..696e27169 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -37,6 +37,7 @@ class IssueFlatSerializer(BaseSerializer): "priority", "start_date", "target_date", + "sequence_id", ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index d4a9faa6f..b04f9dc3a 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -52,7 +52,6 @@ from plane.api.views import ( AddMemberToProjectEndpoint, ProjectJoinEndpoint, BulkDeleteIssuesEndpoint, - BulkAssignIssuesToCycleEndpoint, ProjectUserViewsEndpoint, ModuleViewSet, ModuleIssueViewSet, @@ -444,11 +443,6 @@ urlpatterns = [ ), name="project-cycle", ), - path( - "workspaces//projects//cycles//bulk-assign-issues/", - BulkAssignIssuesToCycleEndpoint.as_view(), - name="bulk-assign-cycle-issues", - ), ## End Cycles # Issue path( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 5706b1994..b641d6e2c 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -38,7 +38,7 @@ from .workspace import ( from .state import StateViewSet from .shortcut import ShortCutViewSet from .view import ViewViewSet -from .cycle import CycleViewSet, CycleIssueViewSet, BulkAssignIssuesToCycleEndpoint +from .cycle import CycleViewSet, CycleIssueViewSet from .asset import FileAssetEndpoint from .issue import ( IssueViewSet, diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 8b74f2a10..aca7fb641 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -1,9 +1,10 @@ # Third party imports from rest_framework.response import Response from rest_framework import status +from sentry_sdk import capture_exception # Module imports -from . import BaseViewSet, BaseAPIView +from . import BaseViewSet from plane.api.serializers import CycleSerializer, CycleIssueSerializer from plane.api.permissions import ProjectEntityPermission from plane.db.models import Cycle, CycleIssue, Issue @@ -66,26 +67,27 @@ class CycleIssueViewSet(BaseViewSet): .distinct() ) - -class BulkAssignIssuesToCycleEndpoint(BaseAPIView): - - permission_classes = [ - ProjectEntityPermission, - ] - - def post(self, request, slug, project_id, cycle_id): + def create(self, request, slug, project_id, cycle_id): try: - issue_ids = request.data.get("issue_ids") + issues = request.data.get("issues", []) + + if not len(issues): + return Response( + {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST + ) cycle = Cycle.objects.get( workspace__slug=slug, project_id=project_id, pk=cycle_id ) issues = Issue.objects.filter( - pk__in=issue_ids, workspace__slug=slug, project_id=project_id + pk__in=issues, workspace__slug=slug, project_id=project_id ) + # Delete old records in order to maintain the database integrity + CycleIssue.objects.filter(issue_id__in=issues).delete() + CycleIssue.objects.bulk_create( [ CycleIssue( @@ -107,3 +109,9 @@ class BulkAssignIssuesToCycleEndpoint(BaseAPIView): return Response( {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 4ff914890..bec21fcd8 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -5,6 +5,7 @@ from django.db.models import Prefetch # Third party imports from rest_framework.response import Response from rest_framework import status +from sentry_sdk import capture_exception # Module imports from . import BaseViewSet @@ -14,7 +15,7 @@ from plane.api.serializers import ( ModuleIssueSerializer, ) from plane.api.permissions import ProjectEntityPermission -from plane.db.models import Module, ModuleIssue, Project +from plane.db.models import Module, ModuleIssue, Project, Issue class ModuleViewSet(BaseViewSet): @@ -71,6 +72,12 @@ class ModuleViewSet(BaseViewSet): {"name": "The module name is already taken"}, status=status.HTTP_410_GONE, ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) class ModuleIssueViewSet(BaseViewSet): @@ -107,3 +114,45 @@ class ModuleIssueViewSet(BaseViewSet): .select_related("issue") .distinct() ) + + def create(self, request, slug, project_id, module_id): + try: + issues = request.data.get("issues", []) + if not len(issues): + return Response( + {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST + ) + module = Module.objects.get( + workspace__slug=slug, project_id=project_id, pk=module_id + ) + + issues = Issue.objects.filter( + pk__in=issues, workspace__slug=slug, project_id=project_id + ) + + ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + module=module, + issue=issue, + project_id=project_id, + workspace=module.workspace, + created_by=request.user, + updated_by=request.user, + ) + for issue in issues + ], + batch_size=10, + ignore_conflicts=True, + ) + return Response({"message": "Success"}, status=status.HTTP_200_OK) + except Module.DoesNotExist: + return Response( + {"error": "Module 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_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apiserver/plane/api/views/people.py b/apiserver/plane/api/views/people.py index 1612e0bc7..81118484c 100644 --- a/apiserver/plane/api/views/people.py +++ b/apiserver/plane/api/views/people.py @@ -52,7 +52,6 @@ class PeopleEndpoint(BaseAPIView): class UserEndpoint(BaseViewSet): serializer_class = UserSerializer model = User - serializers = {} def get_object(self): return self.request.user diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 3a18ef85d..7799c0e69 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -609,7 +609,13 @@ class ProjectUserViewsEndpoint(BaseAPIView): {"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN ) - project_member.view_props = request.data + view_props = project_member.view_props + default_props = project_member.default_props + + project_member.view_props = request.data.get("view_props", view_props) + project_member.default_props = request.data.get( + "default_props", default_props + ) project_member.save() @@ -632,7 +638,7 @@ class ProjectMemberUserEndpoint(BaseAPIView): try: project_member = ProjectMember.objects.get( - project=project_id, workpsace__slug=slug, member=request.user + project_id=project_id, workspace__slug=slug, member=request.user ) serializer = ProjectMemberSerializer(project_member) diff --git a/apiserver/plane/db/migrations/0012_user_my_issues_prop.py b/apiserver/plane/db/migrations/0012_user_my_issues_prop.py new file mode 100644 index 000000000..96c8a33c1 --- /dev/null +++ b/apiserver/plane/db/migrations/0012_user_my_issues_prop.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-12-20 09:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0011_auto_20221216_0259'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='my_issues_prop', + field=models.JSONField(null=True), + ), + ] diff --git a/apiserver/plane/db/migrations/0013_projectmember_default_props.py b/apiserver/plane/db/migrations/0013_projectmember_default_props.py new file mode 100644 index 000000000..2369e1863 --- /dev/null +++ b/apiserver/plane/db/migrations/0013_projectmember_default_props.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.14 on 2022-12-20 11:29 + +from django.db import migrations, models +import plane.db.models.project + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0012_user_my_issues_prop'), + ] + + operations = [ + migrations.AddField( + model_name='projectmember', + name='default_props', + field=models.JSONField(default=plane.db.models.project.get_default_props), + ), + ] diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index d8e46869f..a84d36854 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -19,6 +19,15 @@ ROLE_CHOICES = ( ) +def get_default_props(): + return { + "issueView": "list", + "groupByProperty": None, + "orderBy": None, + "filterIssue": None, + } + + class Project(BaseModel): NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) @@ -119,6 +128,7 @@ class ProjectMember(ProjectBaseModel): comment = models.TextField(blank=True, null=True) role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) view_props = models.JSONField(null=True) + default_props = models.JSONField(default=get_default_props) class Meta: unique_together = ["project", "member"] diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 7efa4be49..1b08c8d69 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -66,6 +66,7 @@ class User(AbstractBaseUser, PermissionsMixin): last_login_uagent = models.TextField(blank=True) token_updated_at = models.DateTimeField(null=True) last_workspace_id = models.UUIDField(null=True) + my_issues_prop = models.JSONField(null=True) USERNAME_FIELD = "email" diff --git a/.eslintrc.json b/apps/app/.eslintrc.json similarity index 100% rename from .eslintrc.json rename to apps/app/.eslintrc.json diff --git a/apps/app/components/command-palette/index.tsx b/apps/app/components/command-palette/index.tsx index 8aafbf6da..ea520cd3d 100644 --- a/apps/app/components/command-palette/index.tsx +++ b/apps/app/components/command-palette/index.tsx @@ -23,7 +23,7 @@ import { // components import ShortcutsModal from "components/command-palette/shortcuts"; import CreateProjectModal from "components/project/create-project-modal"; -import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; +import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal"; import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; // ui import { Button } from "ui"; @@ -260,7 +260,7 @@ const CommandPalette: React.FC = () => {
  • {query === "" && (

    - Issues + Select issues

    )}
      @@ -376,9 +376,9 @@ const CommandPalette: React.FC = () => { )} -
      +
      -
      - {[ - { - title: "Navigation", - shortcuts: [ - { keys: "ctrl,/", description: "To open navigator" }, - { keys: "↑", description: "Move up" }, - { keys: "↓", description: "Move down" }, - { keys: "←", description: "Move left" }, - { keys: "→", description: "Move right" }, - { keys: "Enter", description: "Select" }, - { keys: "Esc", description: "Close" }, - ], - }, - { - title: "Common", - shortcuts: [ - { keys: "ctrl,p", description: "To create project" }, - { keys: "ctrl,i", description: "To create issue" }, - { keys: "ctrl,q", description: "To create cycle" }, - { keys: "ctrl,m", description: "To create module" }, - { keys: "ctrl,h", description: "To open shortcuts guide" }, - { - keys: "ctrl,alt,c", - description: "To copy issue url when on issue detail page.", - }, - ], - }, - ].map(({ title, shortcuts }) => ( -
      -

      {title}

      -
      - {shortcuts.map(({ keys, description }, index) => ( -
      -

      {description}

      -
      - {keys.split(",").map((key, index) => ( - - - {key} - - {/* {index !== keys.split(",").length - 1 ? ( - + - ) : null} */} - - ))} +
      + setQuery(e.target.value)} + /> +
      +
      + {filteredShortcuts.length > 0 ? ( + filteredShortcuts.map(({ title, shortcuts }) => ( +
      +

      {title}

      +
      + {shortcuts.map(({ keys, description }, index) => ( +
      +

      {description}

      +
      + {keys.split(",").map((key, index) => ( + + + {key} + + + ))} +
      -
      - ))} + ))} +
      + )) + ) : ( +
      +

      + No shortcuts found for{" "} + + {`"`} + {query} + {`"`} + +

      - ))} + )}
      diff --git a/apps/app/components/common/board-view/single-board.tsx b/apps/app/components/common/board-view/single-board.tsx new file mode 100644 index 000000000..aa6baf9a8 --- /dev/null +++ b/apps/app/components/common/board-view/single-board.tsx @@ -0,0 +1,5 @@ +const SingleBoard = () => { + return <>; +}; + +export default SingleBoard; diff --git a/apps/app/components/common/board-view/single-issue.tsx b/apps/app/components/common/board-view/single-issue.tsx new file mode 100644 index 000000000..3fde79ae1 --- /dev/null +++ b/apps/app/components/common/board-view/single-issue.tsx @@ -0,0 +1,394 @@ +// next +import Link from "next/link"; +import Image from "next/image"; +// react-beautiful-dnd +import { DraggableStateSnapshot } from "react-beautiful-dnd"; +// headless ui +import { Listbox, Transition } from "@headlessui/react"; +// icons +import { TrashIcon } from "@heroicons/react/24/outline"; +import { CalendarDaysIcon } from "@heroicons/react/20/solid"; +import User from "public/user.png"; +// types +import { IIssue, IWorkspaceMember, Properties } from "types"; +// common +import { + addSpaceIfCamelCase, + classNames, + findHowManyDaysLeft, + renderShortNumericDateFormat, +} from "constants/common"; +// constants +import { PRIORITIES } from "constants/"; +import useUser from "lib/hooks/useUser"; +import React from "react"; + +type Props = { + issue: IIssue; + properties: Properties; + snapshot?: DraggableStateSnapshot; + assignees: { + avatar: string | undefined; + first_name: string | undefined; + email: string | undefined; + }[]; + people: IWorkspaceMember[] | undefined; + handleDeleteIssue?: React.Dispatch>; + partialUpdateIssue: (formData: Partial, childIssueId: string) => void; +}; + +const SingleIssue: React.FC = ({ + issue, + properties, + snapshot, + assignees, + people, + handleDeleteIssue, + partialUpdateIssue, +}) => { + const { activeProject, states } = useUser(); + + return ( +
      +
      + {handleDeleteIssue && ( +
      + +
      + )} + + + {properties.key && ( +
      + {activeProject?.identifier}-{issue.sequence_id} +
      + )} +
      + {issue.name} +
      +
      + +
      + {properties.priority && ( + { + partialUpdateIssue({ priority: data }, issue.id); + }} + className="group relative flex-shrink-0" + > + {({ open }) => ( + <> +
      + + {issue.priority ?? "None"} + + + + + {PRIORITIES?.map((priority) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "cursor-pointer capitalize select-none px-3 py-2" + ) + } + value={priority} + > + {priority} + + ))} + + +
      +
      +
      Priority
      +
      + {issue.priority ?? "None"} +
      +
      + + )} +
      + )} + {properties.state && ( + { + partialUpdateIssue({ state: data }, issue.id); + }} + className="group relative flex-shrink-0" + > + {({ open }) => ( + <> +
      + + + {addSpaceIfCamelCase(issue.state_detail.name)} + + + + + {states?.map((state) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "flex items-center gap-2 cursor-pointer select-none px-3 py-2" + ) + } + value={state.id} + > + + {addSpaceIfCamelCase(state.name)} + + ))} + + +
      +
      +
      State
      +
      {issue.state_detail.name}
      +
      + + )} +
      + )} + {properties.start_date && ( +
      + + {issue.start_date ? renderShortNumericDateFormat(issue.start_date) : "N/A"} +
      +
      Started at
      +
      {renderShortNumericDateFormat(issue.start_date ?? "")}
      +
      +
      + )} + {properties.due_date && ( +
      + + {issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"} +
      +
      Target date
      +
      {renderShortNumericDateFormat(issue.target_date ?? "")}
      +
      + {issue.target_date && + (issue.target_date < new Date().toISOString() + ? `Due date has passed by ${findHowManyDaysLeft(issue.target_date)} days` + : findHowManyDaysLeft(issue.target_date) <= 3 + ? `Due date is in ${findHowManyDaysLeft(issue.target_date)} days` + : "Due date")} +
      +
      +
      + )} + {properties.assignee && ( + { + const newData = issue.assignees ?? []; + if (newData.includes(data)) { + newData.splice(newData.indexOf(data), 1); + } else { + newData.push(data); + } + partialUpdateIssue({ assignees_list: newData }, issue.id); + }} + className="group relative flex-shrink-0" + > + {({ open }) => ( + <> +
      + +
      + {assignees.length > 0 ? ( + assignees.map((assignee, index: number) => ( +
      + {assignee.avatar && assignee.avatar !== "" ? ( +
      + {assignee?.first_name} +
      + ) : ( +
      + {assignee.first_name && assignee.first_name !== "" + ? assignee.first_name.charAt(0) + : assignee?.email?.charAt(0)} +
      + )} +
      + )) + ) : ( +
      + No user +
      + )} +
      +
      + + + + {people?.map((person) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "cursor-pointer select-none p-2" + ) + } + value={person.member.id} + > +
      + {person.member.avatar && person.member.avatar !== "" ? ( +
      + avatar +
      + ) : ( +
      + {person.member.first_name && person.member.first_name !== "" + ? person.member.first_name.charAt(0) + : person.member.email.charAt(0)} +
      + )} +

      + {person.member.first_name && person.member.first_name !== "" + ? person.member.first_name + : person.member.email} +

      +
      +
      + ))} +
      +
      +
      +
      +
      Assigned to
      +
      + {issue.assignee_details?.length > 0 + ? issue.assignee_details.map((assignee) => assignee.first_name).join(", ") + : "No one"} +
      +
      + + )} +
      + )} +
      +
      +
      + ); +}; + +export default SingleIssue; diff --git a/apps/app/components/project/ConfirmProjectMemberRemove.tsx b/apps/app/components/project/confirm-project-member-remove.tsx similarity index 100% rename from apps/app/components/project/ConfirmProjectMemberRemove.tsx rename to apps/app/components/project/confirm-project-member-remove.tsx diff --git a/apps/app/components/project/create-project-modal.tsx b/apps/app/components/project/create-project-modal.tsx index 5d4d9edb8..e93fa6d10 100644 --- a/apps/app/components/project/create-project-modal.tsx +++ b/apps/app/components/project/create-project-modal.tsx @@ -2,14 +2,14 @@ import React, { useState, useEffect } from "react"; // swr import useSWR, { mutate } from "swr"; // react hook form -import { useForm } from "react-hook-form"; +import { useForm, Controller } from "react-hook-form"; // headless import { Dialog, Transition } from "@headlessui/react"; // services import projectServices from "lib/services/project.service"; import workspaceService from "lib/services/workspace.service"; // common -import { createSimilarString } from "constants/common"; +import { createSimilarString, getRandomEmoji } from "constants/common"; // constants import { NETWORK_CHOICES } from "constants/"; // fetch keys @@ -18,7 +18,7 @@ import { PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import useUser from "lib/hooks/useUser"; import useToast from "lib/hooks/useToast"; // ui -import { Button, Input, TextArea, Select } from "ui"; +import { Button, Input, TextArea, Select, EmojiIconPicker } from "ui"; // types import { IProject } from "types"; @@ -32,6 +32,7 @@ const defaultValues: Partial = { identifier: "", description: "", network: 0, + icon: getRandomEmoji(), }; const IsGuestCondition: React.FC<{ @@ -83,6 +84,7 @@ const CreateProjectModal: React.FC = ({ isOpen, setIsOpen }) => { reset, setError, clearErrors, + control, watch, setValue, } = useForm({ @@ -201,6 +203,22 @@ const CreateProjectModal: React.FC = ({ isOpen, setIsOpen }) => {

      +
      + + ( + + )} + /> +
      void; openIssuesListModal: () => void; removeIssueFromCycle: (bridgeId: string) => void; + partialUpdateIssue: (formData: Partial, issueId: string) => void; + handleDeleteIssue: React.Dispatch>; + setPreloadedData: React.Dispatch< + React.SetStateAction< + | (Partial & { + actionType: "createIssue" | "edit" | "delete"; + }) + | undefined + > + >; }; const CyclesBoardView: React.FC = ({ @@ -26,6 +36,9 @@ const CyclesBoardView: React.FC = ({ openCreateIssueModal, openIssuesListModal, removeIssueFromCycle, + partialUpdateIssue, + handleDeleteIssue, + setPreloadedData, }) => { const { states } = useUser(); @@ -57,6 +70,14 @@ const CyclesBoardView: React.FC = ({ removeIssueFromCycle={removeIssueFromCycle} openIssuesListModal={openIssuesListModal} openCreateIssueModal={openCreateIssueModal} + partialUpdateIssue={partialUpdateIssue} + handleDeleteIssue={handleDeleteIssue} + setPreloadedData={setPreloadedData} + stateId={ + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.id ?? null + : null + } /> ))}
      diff --git a/apps/app/components/project/cycles/board-view/single-board.tsx b/apps/app/components/project/cycles/board-view/single-board.tsx index 16b9cd0b4..d40b4ac97 100644 --- a/apps/app/components/project/cycles/board-view/single-board.tsx +++ b/apps/app/components/project/cycles/board-view/single-board.tsx @@ -1,44 +1,25 @@ // react import React, { useState } from "react"; -// next -import Link from "next/link"; -import Image from "next/image"; // swr import useSWR from "swr"; // services -import cycleServices from "lib/services/cycles.service"; +import workspaceService from "lib/services/workspace.service"; // hooks import useUser from "lib/hooks/useUser"; -// ui -import { Spinner } from "ui"; -// icons -import { - ArrowsPointingInIcon, - ArrowsPointingOutIcon, - CalendarDaysIcon, - PlusIcon, - EllipsisHorizontalIcon, - TrashIcon, -} from "@heroicons/react/24/outline"; -import User from "public/user.png"; -// types -import { - CycleIssueResponse, - ICycle, - IIssue, - IWorkspaceMember, - NestedKeyOf, - Properties, -} from "types"; -// constants -import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys"; -import { - addSpaceIfCamelCase, - findHowManyDaysLeft, - renderShortNumericDateFormat, -} from "constants/common"; +// components +import SingleIssue from "components/common/board-view/single-issue"; +// headless ui import { Menu, Transition } from "@headlessui/react"; -import workspaceService from "lib/services/workspace.service"; +// ui +import { CustomMenu } from "ui"; +// icons +import { PlusIcon } from "@heroicons/react/24/outline"; +// types +import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types"; +// fetch-keys +import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; +// common +import { addSpaceIfCamelCase, classNames } from "constants/common"; type Props = { properties: Properties; @@ -52,6 +33,17 @@ type Props = { openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; openIssuesListModal: () => void; removeIssueFromCycle: (bridgeId: string) => void; + partialUpdateIssue: (formData: Partial, issueId: string) => void; + handleDeleteIssue: React.Dispatch>; + setPreloadedData: React.Dispatch< + React.SetStateAction< + | (Partial & { + actionType: "createIssue" | "edit" | "delete"; + }) + | undefined + > + >; + stateId: string | null; }; const SingleCycleBoard: React.FC = ({ @@ -64,11 +56,15 @@ const SingleCycleBoard: React.FC = ({ openCreateIssueModal, openIssuesListModal, removeIssueFromCycle, + partialUpdateIssue, + handleDeleteIssue, + setPreloadedData, + stateId, }) => { // Collapse/Expand const [show, setState] = useState(true); - const { activeWorkspace, activeProject } = useUser(); + const { activeWorkspace } = useUser(); if (selectedGroup === "priority") groupTitle === "high" @@ -123,48 +119,25 @@ const SingleCycleBoard: React.FC = ({
      - - - - - - + { + openCreateIssueModal(); + if (selectedGroup !== null) { + setPreloadedData({ + state: stateId !== null ? stateId : undefined, + [selectedGroup]: groupTitle, + actionType: "createIssue", + }); + } + }} > - -
      - - {(active) => ( - - )} - - - {(active) => ( - - )} - -
      -
      -
      -
      + Create new + + openIssuesListModal()}> + Add an existing issue + +
      = ({ }); return ( -
      -
      - - - {properties.key && ( -
      - {activeProject?.identifier}-{childIssue.sequence_id} -
      - )} -
      - {childIssue.name} -
      -
      - -
      - {properties.priority && ( -
      - {/* {getPriorityIcon(childIssue.priority ?? "")} */} - {childIssue.priority ?? "None"} -
      -
      Priority
      -
      - {childIssue.priority ?? "None"} -
      -
      -
      - )} - {properties.state && ( -
      - - {addSpaceIfCamelCase(childIssue.state_detail.name)} -
      -
      State
      -
      {childIssue.state_detail.name}
      -
      -
      - )} - {properties.start_date && ( -
      - - {childIssue.start_date - ? renderShortNumericDateFormat(childIssue.start_date) - : "N/A"} -
      -
      Started at
      -
      {renderShortNumericDateFormat(childIssue.start_date ?? "")}
      -
      -
      - )} - {properties.target_date && ( -
      - - {childIssue.target_date - ? renderShortNumericDateFormat(childIssue.target_date) - : "N/A"} -
      -
      Target date
      -
      {renderShortNumericDateFormat(childIssue.target_date ?? "")}
      -
      - {childIssue.target_date && - (childIssue.target_date < new Date().toISOString() - ? `Target date has passed by ${findHowManyDaysLeft( - childIssue.target_date - )} days` - : findHowManyDaysLeft(childIssue.target_date) <= 3 - ? `Target date is in ${findHowManyDaysLeft( - childIssue.target_date - )} days` - : "Target date")} -
      -
      -
      - )} - {properties.assignee && ( -
      - {childIssue.assignee_details?.length > 0 ? ( - childIssue.assignee_details?.map((assignee, index: number) => ( -
      - {assignee.avatar && assignee.avatar !== "" ? ( -
      - {assignee.name} -
      - ) : ( -
      - {assignee.first_name.charAt(0)} -
      - )} -
      - )) - ) : ( -
      - No user -
      - )} -
      -
      Assigned to
      -
      - {childIssue.assignee_details?.length > 0 - ? childIssue.assignee_details - .map((assignee) => assignee.first_name) - .join(", ") - : "No one"} -
      -
      -
      - )} -
      -
      -
      + ); })} - + { + openCreateIssueModal(); + if (selectedGroup !== null) { + setPreloadedData({ + state: stateId !== null ? stateId : undefined, + [selectedGroup]: groupTitle, + actionType: "createIssue", + }); + } + }} + > + Create new + + openIssuesListModal()}> + Add an existing issue + +
      diff --git a/apps/app/components/project/cycles/create-update-cycle-modal.tsx b/apps/app/components/project/cycles/create-update-cycle-modal.tsx index 3f3127082..5d52183b8 100644 --- a/apps/app/components/project/cycles/create-update-cycle-modal.tsx +++ b/apps/app/components/project/cycles/create-update-cycle-modal.tsx @@ -195,6 +195,9 @@ const CreateUpdateCycleModal: React.FC = ({ isOpen, setIsOpen, data, proj placeholder="Enter start date" error={errors.start_date} register={register} + validations={{ + required: "Start date is required", + }} />
      @@ -206,6 +209,9 @@ const CreateUpdateCycleModal: React.FC = ({ isOpen, setIsOpen, data, proj placeholder="Enter end date" error={errors.end_date} register={register} + validations={{ + required: "End date is required", + }} />
      diff --git a/apps/app/components/project/cycles/cycle-issues-list-modal.tsx b/apps/app/components/project/cycles/cycle-issues-list-modal.tsx index 8132ca19f..54e526d8b 100644 --- a/apps/app/components/project/cycles/cycle-issues-list-modal.tsx +++ b/apps/app/components/project/cycles/cycle-issues-list-modal.tsx @@ -17,6 +17,8 @@ import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/out import { IIssue, IssueResponse } from "types"; // constants import { classNames } from "constants/common"; +import { mutate } from "swr"; +import { CYCLE_ISSUES } from "constants/fetch-keys"; type Props = { isOpen: boolean; @@ -26,7 +28,7 @@ type Props = { }; type FormInput = { - issue_ids: string[]; + issues: string[]; }; const CycleIssuesListModal: React.FC = ({ @@ -54,12 +56,12 @@ const CycleIssuesListModal: React.FC = ({ formState: { isSubmitting }, } = useForm({ defaultValues: { - issue_ids: [], + issues: [], }, }); const handleAddToCycle: SubmitHandler = (data) => { - if (!data.issue_ids || data.issue_ids.length === 0) { + if (!data.issues || data.issues.length === 0) { setToastAlert({ title: "Error", type: "error", @@ -70,9 +72,10 @@ const CycleIssuesListModal: React.FC = ({ if (activeWorkspace && activeProject) { issuesServices - .bulkAddIssuesToCycle(activeWorkspace.slug, activeProject.id, cycleId, data) + .addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, data) .then((res) => { console.log(res); + mutate(CYCLE_ISSUES(cycleId)); handleClose(); }) .catch((e) => { @@ -117,7 +120,7 @@ const CycleIssuesListModal: React.FC = ({
      (
      diff --git a/apps/app/components/project/cycles/list-view/index.tsx b/apps/app/components/project/cycles/list-view/index.tsx index aa658f2a6..8c34e3109 100644 --- a/apps/app/components/project/cycles/list-view/index.tsx +++ b/apps/app/components/project/cycles/list-view/index.tsx @@ -6,22 +6,21 @@ import Link from "next/link"; import useSWR from "swr"; // headless ui import { Disclosure, Transition, Menu } from "@headlessui/react"; -// services -import cycleServices from "lib/services/cycles.service"; // hooks import useUser from "lib/hooks/useUser"; // ui import { CustomMenu, Spinner } from "ui"; // icons -import { PlusIcon, EllipsisHorizontalIcon, ChevronDownIcon } from "@heroicons/react/20/solid"; +import { PlusIcon, ChevronDownIcon } from "@heroicons/react/20/solid"; import { CalendarDaysIcon } from "@heroicons/react/24/outline"; // types -import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, SelectSprintType } from "types"; +import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types"; // fetch keys -import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys"; +import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; // constants import { addSpaceIfCamelCase, + classNames, findHowManyDaysLeft, renderShortNumericDateFormat, } from "constants/common"; @@ -29,13 +28,21 @@ import workspaceService from "lib/services/workspace.service"; type Props = { groupedByIssues: { - [key: string]: IIssue[]; + [key: string]: (IIssue & { bridge?: string })[]; }; properties: Properties; selectedGroup: NestedKeyOf | null; openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; - openIssuesListModal: (cycleId: string) => void; + openIssuesListModal: () => void; removeIssueFromCycle: (bridgeId: string) => void; + setPreloadedData: React.Dispatch< + React.SetStateAction< + | (Partial & { + actionType: "createIssue" | "edit" | "delete"; + }) + | undefined + > + >; }; const CyclesListView: React.FC = ({ @@ -45,8 +52,9 @@ const CyclesListView: React.FC = ({ openIssuesListModal, properties, removeIssueFromCycle, + setPreloadedData, }) => { - const { activeWorkspace, activeProject } = useUser(); + const { activeWorkspace, activeProject, states } = useUser(); const { data: people } = useSWR( activeWorkspace ? WORKSPACE_MEMBERS : null, @@ -55,265 +63,260 @@ const CyclesListView: React.FC = ({ return (
      - {Object.keys(groupedByIssues).map((singleGroup) => ( - - {({ open }) => ( -
      -
      - -
      - - - - {selectedGroup !== null ? ( -

      - {singleGroup === null || singleGroup === "null" - ? selectedGroup === "priority" && "No priority" - : addSpaceIfCamelCase(singleGroup)} -

      - ) : ( -

      All Issues

      - )} -

      - {groupedByIssues[singleGroup as keyof IIssue].length} -

      -
      -
      -
      - - -
      - {groupedByIssues[singleGroup] ? ( - groupedByIssues[singleGroup].length > 0 ? ( - groupedByIssues[singleGroup].map((issue: IIssue) => { - const assignees = [ - ...(issue?.assignees_list ?? []), - ...(issue?.assignees ?? []), - ]?.map((assignee) => { - const tempPerson = people?.find( - (p) => p.member.id === assignee - )?.member; + {Object.keys(groupedByIssues).map((singleGroup) => { + const stateId = + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.id ?? null + : null; - return { - avatar: tempPerson?.avatar, - first_name: tempPerson?.first_name, - email: tempPerson?.email, - }; - }); - - return ( -
      - -
      - {properties.priority && ( -
      - {/* {getPriorityIcon(issue.priority ?? "")} */} - {issue.priority ?? "None"} -
      -
      Priority
      -
      - {issue.priority ?? "None"} -
      -
      -
      - )} - {properties.state && ( -
      - - {addSpaceIfCamelCase(issue?.state_detail.name)} -
      -
      State
      -
      {issue?.state_detail.name}
      -
      -
      - )} - {properties.start_date && ( -
      - - {issue.start_date - ? renderShortNumericDateFormat(issue.start_date) - : "N/A"} -
      -
      Started at
      -
      - {renderShortNumericDateFormat(issue.start_date ?? "")} -
      -
      -
      - )} - {properties.target_date && ( -
      - - {issue.target_date - ? renderShortNumericDateFormat(issue.target_date) - : "N/A"} -
      -
      - Target date -
      -
      - {renderShortNumericDateFormat(issue.target_date ?? "")} -
      -
      - {issue.target_date && - (issue.target_date < new Date().toISOString() - ? `Target date has passed by ${findHowManyDaysLeft( - issue.target_date - )} days` - : findHowManyDaysLeft(issue.target_date) <= 3 - ? `Target date is in ${findHowManyDaysLeft( - issue.target_date - )} days` - : "Target date")} -
      -
      -
      - )} - - openCreateIssueModal(issue, "edit")} - > - Edit - - removeIssueFromCycle(issue.bridge ?? "")} - > - Remove from cycle - - Delete permanently - -
      -
      - ); - }) + return ( + + {({ open }) => ( +
      +
      + +
      + + + + {selectedGroup !== null ? ( +

      + {singleGroup === null || singleGroup === "null" + ? selectedGroup === "priority" && "No priority" + : addSpaceIfCamelCase(singleGroup)} +

      ) : ( -

      No issues.

      - ) - ) : ( -
      - -
      - )} -
      - - -
      -
      +
      +
      + - - Add issue - + +
      + {groupedByIssues[singleGroup] ? ( + groupedByIssues[singleGroup].length > 0 ? ( + groupedByIssues[singleGroup].map((issue) => { + const assignees = [ + ...(issue?.assignees_list ?? []), + ...(issue?.assignees ?? []), + ]?.map((assignee) => { + const tempPerson = people?.find( + (p) => p.member.id === assignee + )?.member; + + return { + avatar: tempPerson?.avatar, + first_name: tempPerson?.first_name, + email: tempPerson?.email, + }; + }); + + return ( +
      + +
      + {properties.priority && ( +
      + {/* {getPriorityIcon(issue.priority ?? "")} */} + {issue.priority ?? "None"} +
      +
      Priority
      +
      + {issue.priority ?? "None"} +
      +
      +
      + )} + {properties.state && ( +
      + + {addSpaceIfCamelCase(issue?.state_detail.name)} +
      +
      State
      +
      {issue?.state_detail.name}
      +
      +
      + )} + {properties.start_date && ( +
      + + {issue.start_date + ? renderShortNumericDateFormat(issue.start_date) + : "N/A"} +
      +
      Started at
      +
      + {renderShortNumericDateFormat(issue.start_date ?? "")} +
      +
      +
      + )} + {properties.due_date && ( +
      + + {issue.target_date + ? renderShortNumericDateFormat(issue.target_date) + : "N/A"} +
      +
      Due date
      +
      + {renderShortNumericDateFormat(issue.target_date ?? "")} +
      +
      + {issue.target_date && + (issue.target_date < new Date().toISOString() + ? `Due date has passed by ${findHowManyDaysLeft( + issue.target_date + )} days` + : findHowManyDaysLeft(issue.target_date) <= 3 + ? `Due date is in ${findHowManyDaysLeft( + issue.target_date + )} days` + : "Due date")} +
      +
      +
      + )} + + openCreateIssueModal(issue, "edit")} + > + Edit + + removeIssueFromCycle(issue.bridge ?? "")} + > + Remove from cycle + + Delete permanently + +
      +
      + ); + }) + ) : ( +

      No issues.

      + ) + ) : ( +
      + +
      + )} +
      +
      +
      +
      + + + Add issue + + } + optionsPosition="left" + withoutBorder + > + { + openCreateIssueModal(); + if (selectedGroup !== null) { + setPreloadedData({ + state: stateId !== null ? stateId : undefined, + [selectedGroup]: singleGroup, + actionType: "createIssue", + }); + } + }} + > + Create new + + openIssuesListModal()}> + Add an existing issue + + +
      -
      - )} - - ))} + )} + + ); + })}
      - // - // - // - // - // = ({ cycle, handleEditCycle, handleDeleteCycle return ( <> -
      +
      - - -

      {cycle.name}

      +
      + + +

      {cycle.name}

      +
      + +
      - - {today.getDate() < startDate.getDate() - ? "Not started" - : today.getDate() > endDate.getDate() - ? "Over" - : "Active"} + endDate + ? "text-red-500 border-red-500" + : "text-green-500 border-green-500" + }`} + > + {today < startDate ? "Not started" : today > endDate ? "Over" : "Active"} - - Edit cycle - - Delete cycle permanently - -
      - - - -
      + + Edit cycle + + Delete cycle permanently + + +
      +
      +
      Cycle dates
      -
      +
      {renderShortNumericDateFormat(startDate)} - {renderShortNumericDateFormat(endDate)}
      Created by
      -
      +
      {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( = ({ cycle, handleEditCycle, handleDeleteCycle Active members
      -
      +
      -
      - - - {properties.key && ( -
      - {activeProject?.identifier}-{childIssue.sequence_id} -
      - )} -
      - {childIssue.name} -
      -
      - -
      - {properties.priority && ( - { - partialUpdateIssue({ priority: data }, childIssue.id); - }} - className="group relative flex-shrink-0" - > - {({ open }) => ( - <> -
      - - {childIssue.priority ?? "None"} - - - - - {PRIORITIES?.map((priority) => ( - - classNames( - active ? "bg-indigo-50" : "bg-white", - "cursor-pointer capitalize select-none px-3 py-2" - ) - } - value={priority} - > - {priority} - - ))} - - -
      -
      -
      - Priority -
      -
      - {childIssue.priority ?? "None"} -
      -
      - - )} -
      - )} - {properties.state && ( - { - partialUpdateIssue({ state: data }, childIssue.id); - }} - className="group relative flex-shrink-0" - > - {({ open }) => ( - <> -
      - - - {addSpaceIfCamelCase(childIssue.state_detail.name)} - - - - - {states?.map((state) => ( - - classNames( - active ? "bg-indigo-50" : "bg-white", - "cursor-pointer select-none px-3 py-2" - ) - } - value={state.id} - > - {addSpaceIfCamelCase(state.name)} - - ))} - - -
      -
      -
      State
      -
      {childIssue.state_detail.name}
      -
      - - )} -
      - )} - {properties.start_date && ( -
      - - {childIssue.start_date - ? renderShortNumericDateFormat(childIssue.start_date) - : "N/A"} -
      -
      Started at
      -
      - {renderShortNumericDateFormat(childIssue.start_date ?? "")} -
      -
      -
      - )} - {properties.target_date && ( -
      - - {childIssue.target_date - ? renderShortNumericDateFormat(childIssue.target_date) - : "N/A"} -
      -
      - Target date -
      -
      - {renderShortNumericDateFormat(childIssue.target_date ?? "")} -
      -
      - {childIssue.target_date && - (childIssue.target_date < new Date().toISOString() - ? `Target date has passed by ${findHowManyDaysLeft( - childIssue.target_date - )} days` - : findHowManyDaysLeft(childIssue.target_date) <= 3 - ? `Target date is in ${findHowManyDaysLeft( - childIssue.target_date - )} days` - : "Target date")} -
      -
      -
      - )} - {properties.assignee && ( - { - const newData = childIssue.assignees ?? []; - if (newData.includes(data)) { - newData.splice(newData.indexOf(data), 1); - } else { - newData.push(data); - } - partialUpdateIssue( - { assignees_list: newData }, - childIssue.id - ); - }} - className="group relative flex-shrink-0" - > - {({ open }) => ( - <> -
      - -
      - {assignees.length > 0 ? ( - assignees.map((assignee, index: number) => ( -
      - {assignee.avatar && assignee.avatar !== "" ? ( -
      - {assignee?.first_name} -
      - ) : ( -
      - {assignee.first_name?.charAt(0)} -
      - )} -
      - )) - ) : ( -
      - No user -
      - )} -
      -
      - - - - {people?.map((person) => ( - - classNames( - active ? "bg-indigo-50" : "bg-white", - "cursor-pointer select-none p-2" - ) - } - value={person.member.id} - > -
      - {person.member.avatar && - person.member.avatar !== "" ? ( -
      - avatar -
      - ) : ( -
      - {person.member.first_name && - person.member.first_name !== "" - ? person.member.first_name.charAt(0) - : person.member.email.charAt(0)} -
      - )} -

      - {person.member.first_name && - person.member.first_name !== "" - ? person.member.first_name - : person.member.email} -

      -
      -
      - ))} -
      -
      -
      -
      -
      Assigned to
      -
      - {childIssue.assignee_details?.length > 0 - ? childIssue.assignee_details - .map((assignee) => assignee.first_name) - .join(", ") - : "No one"} -
      -
      - - )} -
      - )} -
      -
      +
      )} diff --git a/apps/app/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx b/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx similarity index 82% rename from apps/app/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx rename to apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx index 448c50acc..8b884788c 100644 --- a/apps/app/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx +++ b/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx @@ -9,6 +9,8 @@ import stateServices from "lib/services/state.service"; import { STATE_LIST } from "constants/fetch-keys"; // hooks import useUser from "lib/hooks/useUser"; +// common +import { groupBy } from "constants/common"; // icons import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; // ui @@ -18,25 +20,27 @@ import { Button } from "ui"; import type { IState } from "types"; type Props = { isOpen: boolean; - setIsOpen: React.Dispatch>; - data?: IState; + onClose: () => void; + data: IState | null; }; -const ConfirmStateDeletion: React.FC = ({ isOpen, setIsOpen, data }) => { +const ConfirmStateDeletion: React.FC = ({ isOpen, onClose, data }) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const { activeWorkspace } = useUser(); + const [issuesWithThisStateExist, setIssuesWithThisStateExist] = useState(true); + + const { activeWorkspace, issues } = useUser(); const cancelButtonRef = useRef(null); const handleClose = () => { - setIsOpen(false); + onClose(); setIsDeleteLoading(false); }; const handleDeletion = async () => { setIsDeleteLoading(true); - if (!data || !activeWorkspace) return; + if (!data || !activeWorkspace || issuesWithThisStateExist) return; await stateServices .deleteState(activeWorkspace.slug, data.project, data.id) .then(() => { @@ -53,9 +57,11 @@ const ConfirmStateDeletion: React.FC = ({ isOpen, setIsOpen, data }) => { }); }; + const groupedIssues = groupBy(issues?.results ?? [], "state"); + useEffect(() => { - data && setIsOpen(true); - }, [data, setIsOpen]); + if (data) setIssuesWithThisStateExist(!!groupedIssues[data.id]); + }, [groupedIssues, data]); return ( @@ -109,6 +115,14 @@ const ConfirmStateDeletion: React.FC = ({ isOpen, setIsOpen, data }) => { This action cannot be undone.

      +
      + {issuesWithThisStateExist && ( +

      + There are issues with this state. Please move them to another state + before deleting this state. +

      + )} +
      @@ -117,7 +131,7 @@ const ConfirmStateDeletion: React.FC = ({ isOpen, setIsOpen, data }) => { type="button" onClick={handleDeletion} theme="danger" - disabled={isDeleteLoading} + disabled={isDeleteLoading || issuesWithThisStateExist} className="inline-flex sm:ml-3" > {isDeleteLoading ? "Deleting..." : "Delete"} diff --git a/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx b/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx new file mode 100644 index 000000000..22f224c09 --- /dev/null +++ b/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx @@ -0,0 +1,209 @@ +import React, { useEffect } from "react"; +// swr +import { mutate } from "swr"; +// react hook form +import { useForm, Controller } from "react-hook-form"; +// react color +import { TwitterPicker } from "react-color"; +// headless +import { Popover, Transition } from "@headlessui/react"; +// constants +import { GROUP_CHOICES } from "constants/"; +import { STATE_LIST } from "constants/fetch-keys"; +// services +import stateService from "lib/services/state.service"; +// ui +import { Button, Input, Select, Spinner } from "ui"; +// types +import type { IState } from "types"; + +type Props = { + workspaceSlug?: string; + projectId?: string; + data: IState | null; + onClose: () => void; + selectedGroup: StateGroup | null; +}; + +export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | null; + +const defaultValues: Partial = { + name: "", + color: "#000000", + group: "backlog", +}; + +export const CreateUpdateStateInline: React.FC = ({ + workspaceSlug, + projectId, + data, + onClose, + selectedGroup, +}) => { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + watch, + reset, + control, + } = useForm({ + defaultValues, + }); + + const handleClose = () => { + onClose(); + reset({ name: "", color: "#000000", group: "backlog" }); + }; + + const onSubmit = async (formData: IState) => { + if (!workspaceSlug || !projectId || isSubmitting) return; + const payload: IState = { + ...formData, + }; + if (!data) { + await stateService + .createState(workspaceSlug, projectId, { ...payload }) + .then((res) => { + mutate(STATE_LIST(projectId), (prevData) => [...(prevData ?? []), res], false); + handleClose(); + }) + .catch((err) => { + Object.keys(err).map((key) => { + setError(key as keyof IState, { + message: err[key].join(", "), + }); + }); + }); + } else { + await stateService + .updateState(workspaceSlug, projectId, data.id, { + ...payload, + }) + .then((res) => { + mutate( + STATE_LIST(projectId), + (prevData) => { + const newData = prevData?.map((item) => { + if (item.id === res.id) { + return res; + } + return item; + }); + return newData; + }, + false + ); + handleClose(); + }) + .catch((err) => { + Object.keys(err).map((key) => { + setError(key as keyof IState, { + message: err[key].join(", "), + }); + }); + }); + } + }; + + useEffect(() => { + if (data === null) return; + reset(data); + }, [data, reset]); + + useEffect(() => { + if (!data) + reset({ + ...defaultValues, + group: selectedGroup ?? "backlog", + }); + }, [selectedGroup, data, reset]); + + return ( +
      +
      + + {({ open }) => ( + <> + + {watch("color") && watch("color") !== "" && ( + + )} + + + + + ( + onChange(value.hex)} /> + )} + /> + + + + )} + +
      + + {data && ( + + + +
      + ); +}; diff --git a/apps/app/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx b/apps/app/components/project/issues/BoardView/state/create-update-state-modal.tsx similarity index 91% rename from apps/app/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx rename to apps/app/components/project/issues/BoardView/state/create-update-state-modal.tsx index 3ba149820..4cd5a1367 100644 --- a/apps/app/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx +++ b/apps/app/components/project/issues/BoardView/state/create-update-state-modal.tsx @@ -11,10 +11,12 @@ import { Dialog, Popover, Transition } from "@headlessui/react"; import stateService from "lib/services/state.service"; // fetch keys import { STATE_LIST } from "constants/fetch-keys"; +// constants +import { GROUP_CHOICES } from "constants/"; // hooks import useUser from "lib/hooks/useUser"; // ui -import { Button, Input, TextArea } from "ui"; +import { Button, Input, Select, TextArea } from "ui"; // icons import { ChevronDownIcon } from "@heroicons/react/24/outline"; @@ -31,6 +33,7 @@ const defaultValues: Partial = { name: "", description: "", color: "#000000", + group: "backlog", }; const CreateUpdateStateModal: React.FC = ({ isOpen, data, projectId, handleClose }) => { @@ -161,6 +164,22 @@ const CreateUpdateStateModal: React.FC = ({ isOpen, data, projectId, hand }} />
      +
      + { submitChanges({ target_date: e.target.value }); @@ -248,25 +257,31 @@ const IssueDetailSidebar: React.FC = ({
      - {issueDetail?.label_details.map((label) => ( - { - const updatedLabels = issueDetail?.labels.filter((l) => l !== label.id); - submitChanges({ - labels_list: updatedLabels, - }); - }} - > + {watchIssue("labels_list")?.map((label) => { + const singleLabel = issueLabels?.find((l) => l.id === label); + + if (!singleLabel) return null; + + return ( - {label.name} - - - ))} + key={singleLabel.id} + className="group flex items-center gap-1 border rounded-2xl text-xs px-1 py-0.5 hover:bg-red-50 hover:border-red-500 cursor-pointer" + onClick={() => { + const updatedLabels = watchIssue("labels_list")?.filter((l) => l !== label); + submitChanges({ + labels_list: updatedLabels, + }); + }} + > + + {singleLabel.name} + + + ); + })} = ({ submitChanges({ labels_list: val })} className="flex-shrink-0" + multiple > {({ open }) => ( <> @@ -410,6 +425,11 @@ const IssueDetailSidebar: React.FC = ({ )}
      + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueDetail} + /> ); }; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-assignee.tsx b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-assignee.tsx index c5451c548..f4b97789a 100644 --- a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-assignee.tsx +++ b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-assignee.tsx @@ -58,7 +58,7 @@ const SelectAssignee: React.FC = ({ control, submitChanges }) => { > {({ open }) => (
      - + = ({ control, submitChanges }) => { - +
      {people ? ( people.length > 0 ? ( @@ -135,7 +138,7 @@ const SelectAssignee: React.FC = ({ control, submitChanges }) => { className={({ active, selected }) => `${ active || selected ? "bg-indigo-50" : "" - } flex items-center gap-2 text-gray-900 cursor-pointer select-none relative p-2 rounded-md truncate` + } flex items-center gap-2 text-gray-900 cursor-pointer select-none p-2 truncate` } value={option.member.id} > @@ -150,13 +153,15 @@ const SelectAssignee: React.FC = ({ control, submitChanges }) => { />
      ) : ( -
      +
      {option.member.first_name && option.member.first_name !== "" ? option.member.first_name.charAt(0) : option.member.email.charAt(0)}
      )} - {option.member.first_name} + {option.member.first_name && option.member.first_name !== "" + ? option.member.first_name + : option.member.email} )) ) : ( diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-blocked.tsx b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-blocked.tsx index 9c3b10093..c4944d20f 100644 --- a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-blocked.tsx +++ b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-blocked.tsx @@ -10,32 +10,28 @@ import { Combobox, Dialog, Transition } from "@headlessui/react"; // ui import { Button } from "ui"; // icons -import { - FolderIcon, - MagnifyingGlassIcon, - UserGroupIcon, - XMarkIcon, -} from "@heroicons/react/24/outline"; +import { FolderIcon, MagnifyingGlassIcon, FlagIcon, XMarkIcon } from "@heroicons/react/24/outline"; // types -import { IIssue } from "types"; +import { IIssue, IssueResponse } from "types"; // constants import { classNames } from "constants/common"; +import issuesService from "lib/services/issues.service"; type FormInput = { issue_ids: string[]; }; type Props = { - submitChanges: (formData: Partial) => void; + issueDetail: IIssue | undefined; issuesList: IIssue[]; watch: UseFormWatch; }; -const SelectBlocked: React.FC = ({ submitChanges, issuesList, watch }) => { +const SelectBlocked: React.FC = ({ issueDetail, issuesList, watch }) => { const [query, setQuery] = useState(""); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); - const { activeProject, issues } = useUser(); + const { activeWorkspace, activeProject, issues, mutateIssues } = useUser(); const { setToastAlert } = useToast(); const { register, handleSubmit, reset, watch: watchIssues } = useForm(); @@ -54,16 +50,73 @@ const SelectBlocked: React.FC = ({ submitChanges, issuesList, watch }) => }); return; } - const newBlocked = [...watch("blocked_list"), ...data.issue_ids]; - submitChanges({ blocked_list: newBlocked }); - handleClose(); + + data.issue_ids.map((issue) => { + if (!activeWorkspace || !activeProject || !issueDetail) return; + + const currentBlockers = + issues?.results + .find((i) => i.id === issue) + ?.blocker_issues.map((b) => b.blocker_issue_detail?.id ?? "") ?? []; + + issuesService + .patchIssue(activeWorkspace.slug, activeProject.id, issue, { + blockers_list: [...currentBlockers, issueDetail.id], + }) + .then((response) => { + mutateIssues((prevData) => ({ + ...(prevData as IssueResponse), + results: (prevData?.results ?? []).map((issue) => { + if (issue.id === issueDetail.id) { + return { ...issue, ...response }; + } + return issue; + }), + })); + }) + .catch((error) => { + console.log(error); + }); + }); + + // handleClose(); + }; + + const removeBlocked = (issueId: string) => { + if (!activeWorkspace || !activeProject || !issueDetail) return; + + const currentBlockers = + issues?.results + .find((i) => i.id === issueId) + ?.blocker_issues.map((b) => b.blocker_issue_detail?.id ?? "") ?? []; + + const updatedBlockers = currentBlockers.filter((b) => b !== issueDetail.id); + + issuesService + .patchIssue(activeWorkspace.slug, activeProject.id, issueId, { + blockers_list: updatedBlockers, + }) + .then((response) => { + mutateIssues((prevData) => ({ + ...(prevData as IssueResponse), + results: (prevData?.results ?? []).map((issue) => { + if (issue.id === issueDetail.id) { + return { ...issue, ...response }; + } + return issue; + }), + })); + }) + .catch((error) => { + console.log(error); + }); }; return (
      - -

      Blocked issues

      + +

      Blocked by

      @@ -71,13 +124,8 @@ const SelectBlocked: React.FC = ({ submitChanges, issuesList, watch }) => ? watch("blocked_list").map((issue) => ( { - const updatedBlockers = watch("blocked_list").filter((i) => i !== issue); - submitChanges({ - blocked_list: updatedBlockers, - }); - }} + className="group flex items-center gap-1 border rounded-2xl text-xs px-1.5 py-0.5 text-red-500 hover:bg-red-50 border-red-500 cursor-pointer" + onClick={() => removeBlocked(issue)} > {`${activeProject?.identifier}-${ issues?.results.find((i) => i.id === issue)?.sequence_id @@ -145,7 +193,10 @@ const SelectBlocked: React.FC = ({ submitChanges, issuesList, watch }) => )}
        {issuesList.map((issue) => { - if (!watch("blocked_list").includes(issue.id)) { + if ( + !watch("blocked_list").includes(issue.id) && + !watch("blockers_list").includes(issue.id) + ) { return ( = ({ submitChanges, issuesList, watch }) => return (
        - -

        Blocker issues

        + +

        Blocking

        @@ -71,7 +66,7 @@ const SelectBlocker: React.FC = ({ submitChanges, issuesList, watch }) => ? watch("blockers_list").map((issue) => ( { const updatedBlockers = watch("blockers_list").filter((i) => i !== issue); submitChanges({ @@ -145,7 +140,10 @@ const SelectBlocker: React.FC = ({ submitChanges, issuesList, watch }) => )}
          {issuesList.map((issue) => { - if (!watch("blockers_list").includes(issue.id)) { + if ( + !watch("blockers_list").includes(issue.id) && + !watch("blocked_list").includes(issue.id) + ) { return ( ; - handleCycleChange: (cycleId: string) => void; + handleCycleChange: (cycle: ICycle) => void; }; const SelectCycle: React.FC = ({ control, handleCycleChange }) => { @@ -29,7 +30,7 @@ const SelectCycle: React.FC = ({ control, handleCycleChange }) => {
          ( <> = ({ control, handleCycleChange }) => { "hidden truncate sm:block text-left" )} > - {value ? cycles?.find((c) => c.id === value)?.name : "None"} + {value ? cycles?.find((c) => c.id === value.cycle_detail.id)?.name : "None"} } value={value} onChange={(value: any) => { - handleCycleChange(value); + handleCycleChange(cycles?.find((c) => c.id === value) as any); }} > {cycles ? ( diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-priority.tsx b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-priority.tsx index 7c098f957..974488697 100644 --- a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-priority.tsx +++ b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-priority.tsx @@ -12,6 +12,7 @@ import { IIssue } from "types"; import { classNames } from "constants/common"; import { PRIORITIES } from "constants/"; import CustomSelect from "ui/custom-select"; +import { getPriorityIcon } from "constants/global"; type Props = { control: Control; @@ -33,7 +34,18 @@ const SelectPriority: React.FC = ({ control, submitChanges, watch }) => { render={({ field: { value } }) => ( + + {getPriorityIcon( + watch("priority") && watch("priority") !== "" + ? watch("priority") ?? "" + : "None", + "text-sm" + )} {watch("priority") && watch("priority") !== "" ? watch("priority") : "None"} } @@ -44,7 +56,10 @@ const SelectPriority: React.FC = ({ control, submitChanges, watch }) => { > {PRIORITIES.map((option) => ( - {option} + <> + {getPriorityIcon(option, "text-sm")} + {option} + ))} diff --git a/apps/app/components/project/issues/ListView/index.tsx b/apps/app/components/project/issues/list-view/index.tsx similarity index 90% rename from apps/app/components/project/issues/ListView/index.tsx rename to apps/app/components/project/issues/list-view/index.tsx index a95c83606..9d3dadc18 100644 --- a/apps/app/components/project/issues/ListView/index.tsx +++ b/apps/app/components/project/issues/list-view/index.tsx @@ -8,7 +8,7 @@ import useSWR from "swr"; // headless ui import { Disclosure, Listbox, Menu, Transition } from "@headlessui/react"; // ui -import { Spinner } from "ui"; +import { CustomMenu, Spinner } from "ui"; // icons import { ChevronDownIcon, @@ -18,7 +18,7 @@ import { } from "@heroicons/react/24/outline"; import User from "public/user.png"; // components -import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; +import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal"; // types import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types"; // services @@ -93,6 +93,9 @@ const ListView: React.FC = ({

          {singleGroup === null || singleGroup === "null" ? selectedGroup === "priority" && "No priority" + : selectedGroup === "created_by" + ? people?.find((p) => p.member.id === singleGroup)?.member + ?.first_name ?? "Loading..." : addSpaceIfCamelCase(singleGroup)}

          ) : ( @@ -152,11 +155,11 @@ const ListView: React.FC = ({ {activeProject?.identifier}-{issue.sequence_id} )} - {issue.name} -
          + {issue.name} + {/*
          Name
          {issue.name}
          -
          +
          */}
          @@ -307,7 +310,7 @@ const ListView: React.FC = ({
        )} - {properties.target_date && ( + {properties.due_date && (
        = ({
        {issue.target_date && (issue.target_date < new Date().toISOString() - ? `Target date has passed by ${findHowManyDaysLeft( + ? `Due date has passed by ${findHowManyDaysLeft( issue.target_date )} days` : findHowManyDaysLeft(issue.target_date) <= 3 - ? `Target date is in ${findHowManyDaysLeft( + ? `Due date is in ${findHowManyDaysLeft( issue.target_date )} days` - : "Target date")} + : "Due date")}
        @@ -480,43 +483,25 @@ const ListView: React.FC = ({ )} )} - - + { + setSelectedIssue({ + ...issue, + actionType: "edit", + }); + }} > - - - - - - - -
        - -
        -
        -
        -
        + Edit + + { + handleDeleteIssue(issue.id); + }} + > + Delete permanently + +
      ); @@ -548,6 +533,10 @@ const ListView: React.FC = ({ [selectedGroup]: singleGroup, actionType: "createIssue", }); + } else { + setPreloadedData({ + actionType: "createIssue", + }); } }} > diff --git a/apps/app/components/project/memberInvitations.tsx b/apps/app/components/project/member-invitations.tsx similarity index 84% rename from apps/app/components/project/memberInvitations.tsx rename to apps/app/components/project/member-invitations.tsx index bd25db10a..484d9fed5 100644 --- a/apps/app/components/project/memberInvitations.tsx +++ b/apps/app/components/project/member-invitations.tsx @@ -3,14 +3,13 @@ import React, { useState } from "react"; // next import Link from "next/link"; import useSWR from "swr"; +import { useRouter } from "next/router"; +// services +import projectService from "lib/services/project.service"; // hooks import useUser from "lib/hooks/useUser"; -// Services -import projectService from "lib/services/project.service"; -// fetch keys -import { PROJECT_MEMBERS } from "constants/fetch-keys"; -// commons -import { renderShortNumericDateFormat } from "constants/common"; +// ui +import { Button } from "ui"; // icons import { CalendarDaysIcon, @@ -20,9 +19,15 @@ import { PencilIcon, PlusIcon, TrashIcon, + ClipboardDocumentListIcon, } from "@heroicons/react/24/outline"; // types import type { IProject } from "types"; +// fetch-keys +import { PROJECT_MEMBERS } from "constants/fetch-keys"; +// common +import { renderShortNumericDateFormat } from "constants/common"; + type Props = { project: IProject; slug: string; @@ -40,6 +45,8 @@ const ProjectMemberInvitations: React.FC = ({ }) => { const { user } = useUser(); + const router = useRouter(); + const { data: members } = useSWR(PROJECT_MEMBERS(project.id), () => projectService.projectMembers(slug, project.id) ); @@ -59,7 +66,7 @@ const ProjectMemberInvitations: React.FC = ({ return ( <>
      @@ -93,13 +100,13 @@ const ProjectMemberInvitations: React.FC = ({ {isMember ? (
      - + )} - - - - View - - +
      diff --git a/apps/app/components/project/SendProjectInvitationModal.tsx b/apps/app/components/project/send-project-invitation-modal.tsx similarity index 100% rename from apps/app/components/project/SendProjectInvitationModal.tsx rename to apps/app/components/project/send-project-invitation-modal.tsx diff --git a/apps/app/components/project/settings/ControlSettings.tsx b/apps/app/components/project/settings/ControlSettings.tsx deleted file mode 100644 index 007389834..000000000 --- a/apps/app/components/project/settings/ControlSettings.tsx +++ /dev/null @@ -1,197 +0,0 @@ -// react -import React from "react"; -// swr -import useSWR from "swr"; -// react-hook-form -import { Control, Controller } from "react-hook-form"; -// services -import workspaceService from "lib/services/workspace.service"; -// hooks -import useUser from "lib/hooks/useUser"; -// headless ui -import { Listbox, Transition } from "@headlessui/react"; -// ui -import { Button } from "ui"; -// icons -import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; -// types -import { IProject } from "types"; -// fetch-keys -import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; - -type Props = { - control: Control; - isSubmitting: boolean; -}; - -const ControlSettings: React.FC = ({ control, isSubmitting }) => { - const { activeWorkspace } = useUser(); - - const { data: people } = useSWR( - activeWorkspace ? WORKSPACE_MEMBERS : null, - activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null - ); - return ( - <> -
      -
      -

      Control

      -

      Set the control for the project.

      -
      -
      -
      - ( - - {({ open }) => ( - <> - -
      Project Lead
      -
      -
      - - - {people?.find((person) => person.member.id === value)?.member - .first_name ?? "Select Lead"} - - - - - - - - {people?.map((person) => ( - - `${ - active ? "text-white bg-theme" : "text-gray-900" - } cursor-default select-none relative py-2 pl-3 pr-9` - } - value={person.member.id} - > - {({ selected, active }) => ( - <> - - {person.member.first_name} - - - {selected ? ( - - - ) : null} - - )} - - ))} - - -
      - - )} -
      - )} - /> -
      -
      - ( - - {({ open }) => ( - <> - -
      Default Assignee
      -
      -
      - - - {people?.find((p) => p.member.id === value)?.member.first_name ?? - "Select Default Assignee"} - - - - - - - - {people?.map((person) => ( - - `${ - active ? "text-white bg-theme" : "text-gray-900" - } cursor-default select-none relative py-2 pl-3 pr-9` - } - value={person.member.id} - > - {({ selected, active }) => ( - <> - - {person.member.first_name} - - - {selected ? ( - - - ) : null} - - )} - - ))} - - -
      - - )} -
      - )} - /> -
      -
      -
      - -
      -
      - - ); -}; - -export default ControlSettings; diff --git a/apps/app/components/project/settings/GeneralSettings.tsx b/apps/app/components/project/settings/GeneralSettings.tsx deleted file mode 100644 index 3f0c6ac4a..000000000 --- a/apps/app/components/project/settings/GeneralSettings.tsx +++ /dev/null @@ -1,125 +0,0 @@ -// react -import { useCallback } from "react"; -// react-hook-form -import { UseFormRegister, UseFormSetError } from "react-hook-form"; -// services -import projectServices from "lib/services/project.service"; -// hooks -import useUser from "lib/hooks/useUser"; -// ui -import { Button, Input, Select, TextArea } from "ui"; -// types -import { IProject } from "types"; -// constants -import { debounce } from "constants/common"; - -type Props = { - register: UseFormRegister; - errors: any; - setError: UseFormSetError; - isSubmitting: boolean; -}; - -const NETWORK_CHOICES = { "0": "Secret", "2": "Public" }; - -const GeneralSettings: React.FC = ({ register, errors, setError, isSubmitting }) => { - const { activeWorkspace } = useUser(); - - const checkIdentifier = (slug: string, value: string) => { - projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => { - console.log(response); - if (response.exists) setError("identifier", { message: "Identifier already exists" }); - }); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - const checkIdentifierAvailability = useCallback(debounce(checkIdentifier, 1500), []); - - return ( - <> -
      -
      -

      General

      -

      - This information will be displayed to every member of the project. -

      -
      -
      -
      - -
      -
      - { - if (!activeWorkspace || !e.target.value) return; - checkIdentifierAvailability(activeWorkspace.slug, e.target.value); - }} - validations={{ - required: "Identifier is required", - minLength: { - value: 1, - message: "Identifier must at least be of 1 character", - }, - maxLength: { - value: 9, - message: "Identifier must at most be of 9 characters", - }, - }} - /> -
      -
      -
      -