From 46ae0f98dcf14601346196e0abb1a8560f32ddb3 Mon Sep 17 00:00:00 2001 From: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:35:05 +0530 Subject: [PATCH 01/30] app_release value handled (#3571) --- deploy/selfhost/install.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 3f306c559..4e505cff9 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -49,7 +49,7 @@ function buildLocalImage() { cd $PLANE_TEMP_CODE_DIR if [ "$BRANCH" == "master" ]; then - APP_RELEASE=latest + export APP_RELEASE=latest fi docker compose -f build.yml build --no-cache >&2 @@ -205,6 +205,11 @@ else PULL_POLICY=never fi +if [ "$BRANCH" == "master" ]; +then + export APP_RELEASE=latest +fi + # REMOVE SPECIAL CHARACTERS FROM BRANCH NAME if [ "$BRANCH" != "master" ]; then From ac2276922091eff084f8dd26f5e7d3b332d03d1b Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Tue, 6 Feb 2024 16:47:14 +0530 Subject: [PATCH 02/30] chore: email trigger for new assignee (#3572) --- .../plane/bgtasks/issue_activites_task.py | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index b9f6bd411..b86ab5e78 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -353,13 +353,18 @@ def track_assignees( issue_activities, epoch, ): - requested_assignees = set( - [str(asg) for asg in requested_data.get("assignee_ids", [])] + requested_assignees = ( + set([str(asg) for asg in requested_data.get("assignee_ids", [])]) + if requested_data is not None + else set() ) - current_assignees = set( - [str(asg) for asg in current_instance.get("assignee_ids", [])] + current_assignees = ( + set([str(asg) for asg in current_instance.get("assignee_ids", [])]) + if current_instance is not None + else set() ) + added_assignees = requested_assignees - current_assignees dropped_assginees = current_assignees - requested_assignees @@ -547,6 +552,20 @@ def create_issue_activity( epoch=epoch, ) ) + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) + if requested_data.get("assignee_ids") is not None: + track_assignees( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, + ) def update_issue_activity( From 751b15a7a72f57a4bf0b3990c2f60491965994b2 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 6 Feb 2024 17:00:42 +0530 Subject: [PATCH 03/30] dev: update the response for conflicting errors (#3568) --- apiserver/plane/api/views/cycle.py | 4 +- apiserver/plane/api/views/issue.py | 61 ++++++++++++++++++++++++++--- apiserver/plane/api/views/module.py | 4 +- apiserver/plane/api/views/state.py | 4 +- 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 7a9cfb1b5..6f66c373e 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -262,7 +262,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): return Response( { "error": "Cycle with the same external id and external source already exists", - "cycle": str(cycle.id), + "id": str(cycle.id), }, status=status.HTTP_409_CONFLICT, ) @@ -325,7 +325,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): return Response( { "error": "Cycle with the same external id and external source already exists", - "cycle_id": str(cycle.id), + "id": str(cycle.id), }, status=status.HTTP_409_CONFLICT, ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 530eef5bf..a759b15f6 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -239,7 +239,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): return Response( { "error": "Issue with the same external id and external source already exists", - "issue_id": str(issue.id), + "id": str(issue.id), }, status=status.HTTP_409_CONFLICT, ) @@ -286,14 +286,16 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): and Issue.objects.filter( project_id=project_id, workspace__slug=slug, - external_source=request.data.get("external_source", issue.external_source), + external_source=request.data.get( + "external_source", issue.external_source + ), external_id=request.data.get("external_id"), ).exists() ): return Response( { "error": "Issue with the same external id and external source already exists", - "issue_id": str(issue.id), + "id": str(issue.id), }, status=status.HTTP_409_CONFLICT, ) @@ -362,6 +364,30 @@ class LabelAPIEndpoint(BaseAPIView): try: serializer = LabelSerializer(data=request.data) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Label.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + label = Label.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save(project_id=project_id) return Response( serializer.data, status=status.HTTP_201_CREATED @@ -370,11 +396,17 @@ class LabelAPIEndpoint(BaseAPIView): serializer.errors, status=status.HTTP_400_BAD_REQUEST ) except IntegrityError: + label = Label.objects.filter( + workspace__slug=slug, + project_id=project_id, + name=request.data.get("name"), + ).first() return Response( { - "error": "Label with the same name already exists in the project" + "error": "Label with the same name already exists in the project", + "id": str(label.id), }, - status=status.HTTP_400_BAD_REQUEST, + status=status.HTTP_409_CONFLICT, ) def get(self, request, slug, project_id, pk=None): @@ -401,6 +433,25 @@ class LabelAPIEndpoint(BaseAPIView): label = self.get_queryset().get(pk=pk) serializer = LabelSerializer(label, data=request.data, partial=True) if serializer.is_valid(): + if ( + str(request.data.get("external_id")) + and (label.external_id != str(request.data.get("external_id"))) + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", label.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index e2d59e126..d509a53c7 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -151,7 +151,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): return Response( { "error": "Module with the same external id and external source already exists", - "module_id": str(module.id), + "id": str(module.id), }, status=status.HTTP_409_CONFLICT, ) @@ -185,7 +185,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): return Response( { "error": "Module with the same external id and external source already exists", - "module_id": str(module.id), + "id": str(module.id), }, status=status.HTTP_409_CONFLICT, ) diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 88fb083f0..0a262a071 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -57,7 +57,7 @@ class StateAPIEndpoint(BaseAPIView): return Response( { "error": "State with the same external id and external source already exists", - "state_id": str(state.id), + "id": str(state.id), }, status=status.HTTP_409_CONFLICT, ) @@ -128,7 +128,7 @@ class StateAPIEndpoint(BaseAPIView): return Response( { "error": "State with the same external id and external source already exists", - "state_id": str(state.id), + "id": str(state.id), }, status=status.HTTP_409_CONFLICT, ) From 4b2a9c833594594c682d86666ca381f5cf0b7dc7 Mon Sep 17 00:00:00 2001 From: Ramesh Kumar Chandra <31303617+rameshkumarchandra@users.noreply.github.com> Date: Wed, 7 Feb 2024 13:44:03 +0530 Subject: [PATCH 04/30] style: responsive breadcrumbs and headers for dashboard, projects, project issues, cycles, cycle issues, module issues (#3580) --- .../analytics/project-modal/modal.tsx | 10 +- web/components/cycles/cycle-mobile-header.tsx | 168 +++++++++++++ web/components/cycles/cycles-list-item.tsx | 164 ++++++------- web/components/headers/cycle-issues.tsx | 227 ++++++++++-------- web/components/headers/cycles.tsx | 142 +++++++---- web/components/headers/module-issues.tsx | 225 +++++++++-------- web/components/headers/project-issues.tsx | 209 ++++++++-------- web/components/headers/projects.tsx | 11 +- .../headers/workspace-analytics.tsx | 9 - .../headers/workspace-dashboard.tsx | 8 +- .../filters/header/helpers/dropdown.tsx | 9 +- .../issues/issues-mobile-header.tsx | 166 +++++++++++++ .../modules/module-mobile-header.tsx | 162 +++++++++++++ .../projects/[projectId]/cycles/index.tsx | 55 +++-- 14 files changed, 1065 insertions(+), 500 deletions(-) create mode 100644 web/components/cycles/cycle-mobile-header.tsx create mode 100644 web/components/issues/issues-mobile-header.tsx create mode 100644 web/components/modules/module-mobile-header.tsx diff --git a/web/components/analytics/project-modal/modal.tsx b/web/components/analytics/project-modal/modal.tsx index a4b82c4b6..df61411f2 100644 --- a/web/components/analytics/project-modal/modal.tsx +++ b/web/components/analytics/project-modal/modal.tsx @@ -38,14 +38,12 @@ export const ProjectAnalyticsModal: React.FC = observer((props) => { >
{ + const [analyticsModal, setAnalyticsModal] = useState(false); + const { getCycleById } = useCycle(); + const layouts = [ + { key: "list", title: "List", icon: List }, + { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "calendar", title: "Calendar", icon: Calendar }, + ]; + + const { workspaceSlug, projectId, cycleId } = router.query as { + workspaceSlug: string; + projectId: string; + cycleId: string; + }; + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.CYCLE); + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: TIssueLayouts) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId); + }, + [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + cycleDetails={cycleDetails ?? undefined} + /> +
+ Layout} + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {layouts.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{layout.title}
+
+ ))} +
+
+ + Filters + + + } + > + + +
+
+ + Display + + + } + > + + +
+ + setAnalyticsModal(true)} + className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200" + > + Analytics + +
+ + ); +}; diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index 86de8d0c0..a6d467091 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -159,10 +159,10 @@ export const CyclesListItem: FC = (props) => { projectId={projectId} /> -
-
-
- +
+
+
+
{isCompleted ? ( progress === 100 ? ( @@ -176,95 +176,97 @@ export const CyclesListItem: FC = (props) => { {`${progress}%`} )} - +
-
- - - +
+ - {cycleDetails.name} + + {cycleDetails.name} +
-
- -
-
-
- {currentCycle && ( - - {currentCycle.value === "current" - ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` - : `${currentCycle.label}`} - - )} +
- {renderDate && ( - - {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} - - )} - - -
- {cycleDetails.assignees.length > 0 ? ( - - {cycleDetails.assignees.map((assignee) => ( - - ))} - - ) : ( - - - - )} + {currentCycle && ( +
+ {currentCycle.value === "current" + ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` + : `${currentCycle.label}`}
- - {isEditingAllowed && - (cycleDetails.is_favorite ? ( - - ) : ( - - ))} + )} +
+
+
+ {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} +
- - {!isCompleted && isEditingAllowed && ( +
+ +
+ {cycleDetails.assignees.length > 0 ? ( + + {cycleDetails.assignees.map((assignee) => ( + + ))} + + ) : ( + + + + )} +
+
+ + {isEditingAllowed && ( <> - - - - Edit cycle - - - - - - Delete cycle - - + {cycleDetails.is_favorite ? ( + + ) : ( + + )} + + + {!isCompleted && isEditingAllowed && ( + <> + + + + Edit cycle + + + + + + Delete cycle + + + + )} + + + + Copy cycle link + + + )} - - - - Copy cycle link - - - +
diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 7cfc492f4..44f4f944d 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -23,7 +23,7 @@ import { BreadcrumbLink } from "components/common"; // ui import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui"; // icons -import { ArrowRight, Plus } from "lucide-react"; +import { ArrowRight, Plus, PanelRight } from "lucide-react"; // helpers import { truncateText } from "helpers/string.helper"; import { renderEmoji } from "helpers/emoji.helper"; @@ -32,6 +32,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption // constants import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; import { EUserProjectRoles } from "constants/project"; +import { cn } from "helpers/common.helper"; +import { CycleMobileHeader } from "components/cycles/cycle-mobile-header"; const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => { // router @@ -147,117 +149,136 @@ export const CycleIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} cycleDetails={cycleDetails ?? undefined} /> -
-
- - - - {currentProjectDetails?.name.charAt(0)} - - ) - } - /> - } +
+
+
+ + + + + + {currentProjectDetails?.name.charAt(0)} + + ) + } + /> + + ... + + } + /> + } + /> + } + /> + + + {cycleDetails?.name && truncateText(cycleDetails.name, 40)} + + } + className="ml-1.5 flex-shrink-0" + placement="bottom-start" + > + {currentProjectCycleIds?.map((cycleId) => ( + + ))} + + } + /> + +
+
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} /> - } - /> - } - /> - - - {cycleDetails?.name && truncateText(cycleDetails.name, 40)} - - } - className="ml-1.5 flex-shrink-0" - placement="bottom-start" - > - {currentProjectCycleIds?.map((cycleId) => ( - - ))} - - } - /> - -
-
- handleLayoutChange(layout)} - selectedLayout={activeLayout} - /> - - - - - - + + + + + + - {canUserCreateIssue && ( - <> - - - - )} + {canUserCreateIssue && ( + <> + + + + )} + +
+
+ +
); }); + + diff --git a/web/components/headers/cycles.tsx b/web/components/headers/cycles.tsx index a9ad3a01f..a4bf963ab 100644 --- a/web/components/headers/cycles.tsx +++ b/web/components/headers/cycles.tsx @@ -1,22 +1,24 @@ -import { FC } from "react"; +import { FC, useCallback } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { Plus } from "lucide-react"; +import { List, Plus } from "lucide-react"; // hooks import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; // ui -import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; +import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui"; // helpers import { renderEmoji } from "helpers/emoji.helper"; import { EUserProjectRoles } from "constants/project"; // components import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { BreadcrumbLink } from "components/common"; +import { TCycleLayout } from "@plane/types"; +import { CYCLE_VIEW_LAYOUTS } from "constants/cycle"; +import useLocalStorage from "hooks/use-local-storage"; export const CyclesHeader: FC = observer(() => { // router const router = useRouter(); - const { workspaceSlug } = router.query; // store hooks const { commandPalette: { toggleCreateCycleModal }, @@ -30,54 +32,96 @@ export const CyclesHeader: FC = observer(() => { const canUserCreateCycle = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const { workspaceSlug } = router.query as { + workspaceSlug: string; + }; + const { setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); + + const handleCurrentLayout = useCallback( + (_layout: TCycleLayout) => { + setCycleLayout(_layout); + }, + [setCycleLayout] + ); + return ( -
-
- -
- - - {currentProjectDetails?.name.charAt(0)} - - ) - } - /> - } - /> - } />} - /> - +
+
+
+ +
+ + + {currentProjectDetails?.name.charAt(0)} + + ) + } + /> + } + /> + } />} + /> + +
+ {canUserCreateCycle && ( +
+ +
+ )} +
+
+ + + Layout + + } + customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm" + closeOnSelect + > + {CYCLE_VIEW_LAYOUTS.map((layout) => ( + { + // handleLayoutChange(ISSUE_LAYOUTS[index].key); + handleCurrentLayout(layout.key as TCycleLayout); + }} + className="flex items-center gap-2" + > + +
{layout.title}
+
+ ))} +
- {canUserCreateCycle && ( -
- -
- )}
); }); diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index 70e4dbeea..6287223b0 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -23,7 +23,7 @@ import { BreadcrumbLink } from "components/common"; // ui import { Breadcrumbs, Button, CustomMenu, DiceIcon } from "@plane/ui"; // icons -import { ArrowRight, Plus } from "lucide-react"; +import { ArrowRight, PanelRight, Plus } from "lucide-react"; // helpers import { truncateText } from "helpers/string.helper"; import { renderEmoji } from "helpers/emoji.helper"; @@ -32,6 +32,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption // constants import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; import { EUserProjectRoles } from "constants/project"; +import { cn } from "helpers/common.helper"; +import { ModuleMobileHeader } from "components/modules/module-mobile-header"; const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => { // router @@ -150,116 +152,127 @@ export const ModuleIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} moduleDetails={moduleDetails ?? undefined} /> -
-
- - - - {currentProjectDetails?.name.charAt(0)} - - ) +
+
+
+ + + + + + {currentProjectDetails?.name.charAt(0)} + + ) + } + /> + + ... + + } + /> + } + /> + } + /> + + + {moduleDetails?.name && truncateText(moduleDetails.name, 40)} + + } + className="ml-1.5 flex-shrink-0" + placement="bottom-start" + > + {projectModuleIds?.map((moduleId) => ( + + ))} + + } + /> + +
+
+
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> + + - } - /> - } - /> - } - /> - - - {moduleDetails?.name && truncateText(moduleDetails.name, 40)} - + + + - {projectModuleIds?.map((moduleId) => ( - - ))} - - } - /> - -
-
- handleLayoutChange(layout)} - selectedLayout={activeLayout} - /> - - - - - - + displayFilters={issueFilters?.displayFilters ?? {}} + handleDisplayFiltersUpdate={handleDisplayFilters} + displayProperties={issueFilters?.displayProperties ?? {}} + handleDisplayPropertiesUpdate={handleDisplayProperties} + /> + +
- {canUserCreateIssue && ( - <> - - - - )} - + {canUserCreateIssue && ( + <> + + + + )} + +
+
); diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 769d6c945..c819b25c3 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { ArrowLeft, Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; +import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; // hooks import { useApplication, @@ -29,6 +29,7 @@ import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } f import { renderEmoji } from "helpers/emoji.helper"; import { EUserProjectRoles } from "constants/project"; import { useIssues } from "hooks/store/use-issues"; +import { IssuesMobileHeader } from "components/issues/issues-mobile-header"; export const ProjectIssuesHeader: React.FC = observer(() => { // states @@ -114,118 +115,111 @@ export const ProjectIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} projectDetails={currentProjectDetails ?? undefined} /> -
-
- -
- +
+
+
+ +
+ + + {renderEmoji(currentProjectDetails.emoji)} + + ) : currentProjectDetails?.icon_prop ? ( +
+ {renderEmoji(currentProjectDetails.icon_prop)} +
+ ) : ( + + {currentProjectDetails?.name.charAt(0)} + + ) + ) : ( + + + + ) + } + /> + } + /> + + } />} + /> +
+
+ {currentProjectDetails?.is_deployed && deployUrl && ( + + + Public + + + )}
-
- - - {renderEmoji(currentProjectDetails.emoji)} - - ) : currentProjectDetails?.icon_prop ? ( -
- {renderEmoji(currentProjectDetails.icon_prop)} -
- ) : ( - - {currentProjectDetails?.name.charAt(0)} - - ) - ) : ( - - - - ) - } - /> +
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> + + + + + + + + {currentProjectDetails?.inbox_view && inboxDetails && ( + + + + + + )} - } />} - /> -
- {currentProjectDetails?.is_deployed && deployUrl && ( - - - Public - - - )} -
-
- handleLayoutChange(layout)} - selectedLayout={activeLayout} - /> - - - - - - - - {currentProjectDetails?.inbox_view && inboxDetails && ( - - - - - - )} - {canUserCreateIssue && ( <> -
+
+ +
); diff --git a/web/components/headers/projects.tsx b/web/components/headers/projects.tsx index 34e8ffa08..34b1a6ef8 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -23,7 +23,7 @@ export const ProjectsHeader = observer(() => { return (
-
+
@@ -34,12 +34,12 @@ export const ProjectsHeader = observer(() => {
-
+
{workspaceProjectIds && workspaceProjectIds?.length > 0 && ( -
- +
+ setSearchQuery(e.target.value)} placeholder="Search" @@ -54,6 +54,7 @@ export const ProjectsHeader = observer(() => { setTrackElement("Projects page"); commandPaletteStore.toggleCreateProjectModal(true); }} + className="items-center" > Add Project diff --git a/web/components/headers/workspace-analytics.tsx b/web/components/headers/workspace-analytics.tsx index 8bb4c9251..4d54dd965 100644 --- a/web/components/headers/workspace-analytics.tsx +++ b/web/components/headers/workspace-analytics.tsx @@ -16,15 +16,6 @@ export const WorkspaceAnalyticsHeader = () => { >
-
- -
{ href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer" - className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 text-xs font-medium" + className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5" > - {"What's new?"} + {"What's new?"} { width={16} alt="GitHub Logo" /> - Star us on GitHub + Star us on GitHub
diff --git a/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx b/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx index 425f53b46..33b86ada1 100644 --- a/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx +++ b/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx @@ -13,10 +13,11 @@ type Props = { placement?: Placement; disabled?: boolean; tabIndex?: number; + menuButton?: React.ReactNode; }; export const FiltersDropdown: React.FC = (props) => { - const { children, title = "Dropdown", placement, disabled = false, tabIndex } = props; + const { children, title = "Dropdown", placement, disabled = false, tabIndex, menuButton } = props; const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -33,7 +34,9 @@ export const FiltersDropdown: React.FC = (props) => { return ( <> - : + } { + const layouts = [ + { key: "list", title: "List", icon: List }, + { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "calendar", title: "Calendar", icon: Calendar }, + ]; + const [analyticsModal, setAnalyticsModal] = useState(false); + const { workspaceSlug, projectId } = router.query as { + workspaceSlug: string; + projectId: string; + }; + const { currentProjectDetails } = useProject(); + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT); + const { + project: { projectMemberIds }, + } = useMember(); + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: TIssueLayouts) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); + }, + [workspaceSlug, projectId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); + }, + [workspaceSlug, projectId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + projectDetails={currentProjectDetails ?? undefined} + /> +
+ Layout} + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {layouts.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{layout.title}
+
+ ))} +
+
+ + Filters + + + } + > + + +
+
+ + Display + + + } + > + + +
+ + +
+ + ); +}; diff --git a/web/components/modules/module-mobile-header.tsx b/web/components/modules/module-mobile-header.tsx new file mode 100644 index 000000000..b08c443d2 --- /dev/null +++ b/web/components/modules/module-mobile-header.tsx @@ -0,0 +1,162 @@ +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +import { ProjectAnalyticsModal } from "components/analytics"; +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues"; +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue"; +import { useIssues, useLabel, useMember, useModule, useProjectState } from "hooks/store"; +import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +import router from "next/router"; +import { useCallback, useState } from "react"; + +export const ModuleMobileHeader = () => { + const [analyticsModal, setAnalyticsModal] = useState(false); + const { getModuleById } = useModule(); + const layouts = [ + { key: "list", title: "List", icon: List }, + { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "calendar", title: "Calendar", icon: Calendar }, + ]; + const { workspaceSlug, projectId, moduleId } = router.query as { + workspaceSlug: string; + projectId: string; + moduleId: string; + }; + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; + + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.MODULE); + const activeLayout = issueFilters?.displayFilters?.layout; + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); + + const handleLayoutChange = useCallback( + (layout: TIssueLayouts) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, moduleId); + }, + [workspaceSlug, projectId, moduleId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + moduleDetails={moduleDetails ?? undefined} + /> +
+ Layout} + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {layouts.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{layout.title}
+
+ ))} +
+
+ + Filters + + + } + > + + +
+
+ + Display + + + } + > + + +
+ + +
+ + ); +}; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx index 22ec70b31..0541dfce4 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx @@ -108,14 +108,13 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { selectedIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleTab)} onChange={(i) => handleCurrentView(CYCLE_TAB_LIST[i]?.key ?? "active")} > -
+
{CYCLE_TAB_LIST.map((tab) => ( - `border-b-2 p-4 text-sm font-medium outline-none ${ - selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent" + `border-b-2 p-4 text-sm font-medium outline-none ${selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent" }` } > @@ -123,32 +122,32 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { ))} - {cycleTab !== "active" && ( -
- {CYCLE_VIEW_LAYOUTS.map((layout) => { - if (layout.key === "gantt" && cycleTab === "draft") return null; +
+ {cycleTab !== "active" && ( +
+ {CYCLE_VIEW_LAYOUTS.map((layout) => { + if (layout.key === "gantt" && cycleTab === "draft") return null; - return ( - - - - ); - })} -
- )} + return ( + + + + ); + })} +
+ )} +
From 0a99a1a09131ae9f5566fbf0fe455c7306561d8e Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 7 Feb 2024 13:44:45 +0530 Subject: [PATCH 05/30] fix: dashboard header z index and workspace active cycles fix (#3581) * fix: dashboard header z index fix * chore: workspace active cycles upgrade page improvement --- web/components/headers/workspace-dashboard.tsx | 2 +- .../workspace/workspace-active-cycles-upgrade.tsx | 15 ++------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/web/components/headers/workspace-dashboard.tsx b/web/components/headers/workspace-dashboard.tsx index 2210a4270..d074132e2 100644 --- a/web/components/headers/workspace-dashboard.tsx +++ b/web/components/headers/workspace-dashboard.tsx @@ -18,7 +18,7 @@ export const WorkspaceDashboardHeader = () => { return ( <> -
+
diff --git a/web/components/workspace/workspace-active-cycles-upgrade.tsx b/web/components/workspace/workspace-active-cycles-upgrade.tsx index 31d31380d..086e95182 100644 --- a/web/components/workspace/workspace-active-cycles-upgrade.tsx +++ b/web/components/workspace/workspace-active-cycles-upgrade.tsx @@ -19,7 +19,7 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => { const isDarkMode = currentUser?.theme.theme === "dark"; return ( - -
+
{WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => (
From 065226f8b2409048b2e485aef3691bdfd745ec84 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 7 Feb 2024 15:06:07 +0530 Subject: [PATCH 06/30] fix: draft issue peek overview (#3582) * chore: project, view and shortcut modal alignment consistency * chore: issue highlight list layout improvement * fix: draft issue peek overview fix * fix: draft issue layout inline editing --- web/components/command-palette/shortcuts-modal/modal.tsx | 2 +- web/components/issues/issue-layouts/list/block.tsx | 3 ++- .../issues/issue-layouts/roots/draft-issue-layout-root.tsx | 3 +++ web/components/project/create-project-modal.tsx | 2 +- web/components/views/modal.tsx | 2 +- web/store/issue/draft/issue.store.ts | 2 +- 6 files changed, 9 insertions(+), 5 deletions(-) diff --git a/web/components/command-palette/shortcuts-modal/modal.tsx b/web/components/command-palette/shortcuts-modal/modal.tsx index bc7d67d88..3054bdb28 100644 --- a/web/components/command-palette/shortcuts-modal/modal.tsx +++ b/web/components/command-palette/shortcuts-modal/modal.tsx @@ -47,7 +47,7 @@ export const ShortcutsModal: FC = (props) => { leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > -
+
Keyboard shortcuts diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 6b353d8c3..c4ced9326 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -51,10 +51,11 @@ export const IssueBlock: React.FC = observer((props: IssueBlock <>
diff --git a/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx index 075a16aa2..8071b40e5 100644 --- a/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx @@ -8,6 +8,7 @@ import { useIssues } from "hooks/store"; import { DraftIssueAppliedFiltersRoot } from "../filters/applied-filters/roots/draft-issue"; import { DraftIssueListLayout } from "../list/roots/draft-issue-root"; import { ProjectDraftEmptyState } from "../empty-states"; +import { IssuePeekOverview } from "components/issues/peek-overview"; // ui import { Spinner } from "@plane/ui"; import { DraftKanBanLayout } from "../kanban/roots/draft-issue-root"; @@ -57,6 +58,8 @@ export const DraftIssueLayoutRoot: React.FC = observer(() => { ) : activeLayout === "kanban" ? ( ) : null} + {/* issue peek overview */} +
)} diff --git a/web/components/project/create-project-modal.tsx b/web/components/project/create-project-modal.tsx index 377ef06c1..7d6a0c5e9 100644 --- a/web/components/project/create-project-modal.tsx +++ b/web/components/project/create-project-modal.tsx @@ -208,7 +208,7 @@ export const CreateProjectModal: FC = observer((props) => {
-
+
= observer((props) => {
-
+
Date: Wed, 7 Feb 2024 15:13:35 +0530 Subject: [PATCH 07/30] chore: module issue count (#3566) --- apiserver/plane/app/views/module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index fafcfed4b..4792a1f79 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -334,7 +334,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): def get_queryset(self): return ( - Issue.objects.filter( + Issue.issue_objects.filter( project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), issue_module__module_id=self.kwargs.get("module_id") From 76db394ab144fb52cdb3816b5f4060872df6fe34 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Wed, 7 Feb 2024 15:14:30 +0530 Subject: [PATCH 08/30] chore: mention notification and webhook faliure (#3573) * fix: mention rstrip error * chore: webhook deactivation email * chore: changed template * chore: current site for external api's * chore: mention in template displayed * chore: mention tag fix * chore: comment user name displayed --- apiserver/plane/api/views/base.py | 8 + apiserver/plane/app/views/base.py | 1 + .../plane/bgtasks/email_notification_task.py | 254 +-- apiserver/plane/bgtasks/notification_task.py | 5 +- apiserver/plane/bgtasks/webhook_task.py | 79 +- apiserver/plane/settings/common.py | 2 - .../emails/notifications/issue-updates.html | 46 +- .../notifications/webhook-deactivate.html | 1544 +++++++++++++++++ 8 files changed, 1803 insertions(+), 136 deletions(-) create mode 100644 apiserver/templates/emails/notifications/webhook-deactivate.html diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index b069ef78c..edb89f9b1 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -1,6 +1,8 @@ # Python imports import zoneinfo import json +from urllib.parse import urlparse + # Django imports from django.conf import settings @@ -51,6 +53,11 @@ class WebhookMixin: and self.request.method in ["POST", "PATCH", "DELETE"] and response.status_code in [200, 201, 204] ): + url = request.build_absolute_uri() + parsed_url = urlparse(url) + # Extract the scheme and netloc + scheme = parsed_url.scheme + netloc = parsed_url.netloc # Push the object to delay send_webhook.delay( event=self.webhook_event, @@ -59,6 +66,7 @@ class WebhookMixin: action=self.request.method, slug=self.workspace_slug, bulk=self.bulk, + current_site=f"{scheme}://{netloc}", ) return response diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index e07cb811c..fa1e7559b 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -64,6 +64,7 @@ class WebhookMixin: action=self.request.method, slug=self.workspace_slug, bulk=self.bulk, + current_site=request.META.get("HTTP_ORIGIN"), ) return response diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 713835033..9e9b348e1 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -1,5 +1,6 @@ -import json from datetime import datetime +from bs4 import BeautifulSoup + # Third party imports from celery import shared_task @@ -9,7 +10,6 @@ from django.utils import timezone from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags -from django.conf import settings # Module imports from plane.db.models import EmailNotificationLog, User, Issue @@ -40,7 +40,7 @@ def stack_email_notification(): processed_notifications = [] # Loop through all the issues to create the emails for receiver_id in receivers: - # Notifcation triggered for the receiver + # Notification triggered for the receiver receiver_notifications = [ notification for notification in email_notifications @@ -124,119 +124,153 @@ def create_payload(notification_data): return data +def process_mention(mention_component): + soup = BeautifulSoup(mention_component, 'html.parser') + mentions = soup.find_all('mention-component') + for mention in mentions: + user_id = mention['id'] + user = User.objects.get(pk=user_id) + user_name = user.display_name + highlighted_name = f"@{user_name}" + mention.replace_with(highlighted_name) + return str(soup) + +def process_html_content(content): + processed_content_list = [] + for html_content in content: + processed_content = process_mention(html_content) + processed_content_list.append(processed_content) + return processed_content_list @shared_task def send_email_notification( issue_id, notification_data, receiver_id, email_notification_ids ): - ri = redis_instance() - base_api = (ri.get(str(issue_id)).decode()) - data = create_payload(notification_data=notification_data) - - # Get email configurations - ( - EMAIL_HOST, - EMAIL_HOST_USER, - EMAIL_HOST_PASSWORD, - EMAIL_PORT, - EMAIL_USE_TLS, - EMAIL_FROM, - ) = get_email_configuration() - - receiver = User.objects.get(pk=receiver_id) - issue = Issue.objects.get(pk=issue_id) - template_data = [] - total_changes = 0 - comments = [] - actors_involved = [] - for actor_id, changes in data.items(): - actor = User.objects.get(pk=actor_id) - total_changes = total_changes + len(changes) - comment = changes.pop("comment", False) - actors_involved.append(actor_id) - if comment: - comments.append( - { - "actor_comments": comment, - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - } - ) - activity_time = changes.pop("activity_time") - # Parse the input string into a datetime object - formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") - - if changes: - template_data.append( - { - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - "changes": changes, - "issue_details": { - "name": issue.name, - "identifier": f"{issue.project.identifier}-{issue.sequence_id}", - }, - "activity_time": str(formatted_time), - } - ) - - summary = "Updates were made to the issue by" - - # Send the mail - subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" - context = { - "data": template_data, - "summary": summary, - "actors_involved": len(set(actors_involved)), - "issue": { - "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", - "name": issue.name, - "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", - }, - "receiver": { - "email": receiver.email, - }, - "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", - "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", - "workspace":str(issue.project.workspace.slug), - "project": str(issue.project.name), - "user_preference": f"{base_api}/profile/preferences/email", - "comments": comments, - } - html_content = render_to_string( - "emails/notifications/issue-updates.html", context - ) - text_content = strip_tags(html_content) - try: - connection = get_connection( - host=EMAIL_HOST, - port=int(EMAIL_PORT), - username=EMAIL_HOST_USER, - password=EMAIL_HOST_PASSWORD, - use_tls=EMAIL_USE_TLS == "1", - ) + ri = redis_instance() + base_api = (ri.get(str(issue_id)).decode()) + data = create_payload(notification_data=notification_data) - msg = EmailMultiAlternatives( - subject=subject, - body=text_content, - from_email=EMAIL_FROM, - to=[receiver.email], - connection=connection, - ) - msg.attach_alternative(html_content, "text/html") - msg.send() + # Get email configurations + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() - EmailNotificationLog.objects.filter( - pk__in=email_notification_ids - ).update(sent_at=timezone.now()) - return - except Exception as e: - print(e) + receiver = User.objects.get(pk=receiver_id) + issue = Issue.objects.get(pk=issue_id) + template_data = [] + total_changes = 0 + comments = [] + actors_involved = [] + for actor_id, changes in data.items(): + actor = User.objects.get(pk=actor_id) + total_changes = total_changes + len(changes) + comment = changes.pop("comment", False) + mention = changes.pop("mention", False) + actors_involved.append(actor_id) + if comment: + comments.append( + { + "actor_comments": comment, + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + if mention: + mention["new_value"] = process_html_content(mention.get("new_value")) + mention["old_value"] = process_html_content(mention.get("old_value")) + comments.append( + { + "actor_comments": mention, + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + activity_time = changes.pop("activity_time") + # Parse the input string into a datetime object + formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") + + if changes: + template_data.append( + { + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + "changes": changes, + "issue_details": { + "name": issue.name, + "identifier": f"{issue.project.identifier}-{issue.sequence_id}", + }, + "activity_time": str(formatted_time), + } + ) + + summary = "Updates were made to the issue by" + + # Send the mail + subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" + context = { + "data": template_data, + "summary": summary, + "actors_involved": len(set(actors_involved)), + "issue": { + "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", + "name": issue.name, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + }, + "receiver": { + "email": receiver.email, + }, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", + "workspace":str(issue.project.workspace.slug), + "project": str(issue.project.name), + "user_preference": f"{base_api}/profile/preferences/email", + "comments": comments, + } + html_content = render_to_string( + "emails/notifications/issue-updates.html", context + ) + text_content = strip_tags(html_content) + + try: + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver.email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + EmailNotificationLog.objects.filter( + pk__in=email_notification_ids + ).update(sent_at=timezone.now()) + return + except Exception as e: + print(e) + return + except Issue.DoesNotExist: return diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 6cfbec72a..0a843e4a6 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -515,7 +515,7 @@ def notifications( bulk_email_logs.append( EmailNotificationLog( triggered_by_id=actor_id, - receiver_id=subscriber, + receiver_id=mention_id, entity_identifier=issue_id, entity_name="issue", data={ @@ -552,6 +552,7 @@ def notifications( "old_value": str( issue_activity.get("old_value") ), + "activity_time": issue_activity.get("created_at"), }, }, ) @@ -639,6 +640,7 @@ def notifications( "old_value": str( last_activity.old_value ), + "activity_time": issue_activity.get("created_at"), }, }, ) @@ -695,6 +697,7 @@ def notifications( "old_value" ) ), + "activity_time": issue_activity.get("created_at"), }, }, ) diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index 34bba0cf8..605f48dd9 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -7,6 +7,9 @@ import hmac # Django imports from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags # Third party imports from celery import shared_task @@ -22,10 +25,10 @@ from plane.db.models import ( ModuleIssue, CycleIssue, IssueComment, + User, ) from plane.api.serializers import ( ProjectSerializer, - IssueSerializer, CycleSerializer, ModuleSerializer, CycleIssueSerializer, @@ -34,6 +37,9 @@ from plane.api.serializers import ( IssueExpandSerializer, ) +# Module imports +from plane.license.utils.instance_value import get_email_configuration + SERIALIZER_MAPPER = { "project": ProjectSerializer, "issue": IssueExpandSerializer, @@ -72,7 +78,7 @@ def get_model_data(event, event_id, many=False): max_retries=5, retry_jitter=True, ) -def webhook_task(self, webhook, slug, event, event_data, action): +def webhook_task(self, webhook, slug, event, event_data, action, current_site): try: webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) @@ -151,7 +157,18 @@ def webhook_task(self, webhook, slug, event, event_data, action): response_body=str(e), retry_count=str(self.request.retries), ) - + # Retry logic + if self.request.retries >= self.max_retries: + Webhook.objects.filter(pk=webhook.id).update(is_active=False) + if webhook: + # send email for the deactivation of the webhook + send_webhook_deactivation_email( + webhook_id=webhook.id, + receiver_id=webhook.created_by_id, + reason=str(e), + current_site=current_site, + ) + return raise requests.RequestException() except Exception as e: @@ -162,7 +179,7 @@ def webhook_task(self, webhook, slug, event, event_data, action): @shared_task() -def send_webhook(event, payload, kw, action, slug, bulk): +def send_webhook(event, payload, kw, action, slug, bulk, current_site): try: webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) @@ -216,6 +233,7 @@ def send_webhook(event, payload, kw, action, slug, bulk): event=event, event_data=data, action=action, + current_site=current_site, ) except Exception as e: @@ -223,3 +241,56 @@ def send_webhook(event, payload, kw, action, slug, bulk): print(e) capture_exception(e) return + + +@shared_task +def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason): + # Get email configurations + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() + + receiver = User.objects.get(pk=receiver_id) + webhook = Webhook.objects.get(pk=webhook_id) + subject="Webhook Deactivated" + message=f"Webhook {webhook.url} has been deactivated due to failed requests." + + # Send the mail + context = { + "email": receiver.email, + "message": message, + "webhook_url":f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}", + } + html_content = render_to_string( + "emails/notifications/webhook-deactivate.html", context + ) + text_content = strip_tags(html_content) + + try: + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver.email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + return + except Exception as e: + print(e) + return diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 444248382..f03209250 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -282,10 +282,8 @@ if REDIS_SSL: redis_url = os.environ.get("REDIS_URL") broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" CELERY_BROKER_URL = broker_url - CELERY_RESULT_BACKEND = broker_url else: CELERY_BROKER_URL = REDIS_URL - CELERY_RESULT_BACKEND = REDIS_URL CELERY_IMPORTS = ( "plane.bgtasks.issue_automation_task", diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index fa50631c5..a7990562d 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -108,25 +108,33 @@ margin-bottom: 15px; " /> - {% if actors_involved == 1 %} -

- {{summary}} - - {{ data.0.actor_detail.first_name}} - {{data.0.actor_detail.last_name}} - . -

- {% else %} -

- {{summary}} - - {{ data.0.actor_detail.first_name}} - {{data.0.actor_detail.last_name }} - and others. -

- {% endif %} - - + {% if actors_involved == 1 %} +

+ {{summary}} + + {% if data|length > 0 %} + {{ data.0.actor_detail.first_name}} + {{data.0.actor_detail.last_name}} + {% else %} + {{ comments.0.actor_detail.first_name}} + {{comments.0.actor_detail.last_name}} + {% endif %} + . +

+ {% else %} +

+ {{summary}} + + {% if data|length > 0 %} + {{ data.0.actor_detail.first_name}} + {{data.0.actor_detail.last_name}} + {% else %} + {{ comments.0.actor_detail.first_name}} + {{comments.0.actor_detail.last_name}} + {% endif %} + and others. +

+ {% endif %} + + + + + + + + + + From 4a2e648f6d3af455ba32b453d5161bc1d61c7b41 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 7 Feb 2024 16:21:20 +0530 Subject: [PATCH 09/30] chore: workspace dashboard refactor (#3584) --- web/components/headers/workspace-dashboard.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/web/components/headers/workspace-dashboard.tsx b/web/components/headers/workspace-dashboard.tsx index d074132e2..d8306ab40 100644 --- a/web/components/headers/workspace-dashboard.tsx +++ b/web/components/headers/workspace-dashboard.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { LayoutGrid, Zap } from "lucide-react"; import Image from "next/image"; import { useTheme } from "next-themes"; @@ -6,18 +5,16 @@ import { useTheme } from "next-themes"; import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; // components -import { BreadcrumbLink, ProductUpdatesModal } from "components/common"; +import { BreadcrumbLink } from "components/common"; import { Breadcrumbs } from "@plane/ui"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; export const WorkspaceDashboardHeader = () => { - const [isProductUpdatesModalOpen, setIsProductUpdatesModalOpen] = useState(false); // hooks const { resolvedTheme } = useTheme(); return ( <> -
From a1d6c406272371e6dfe43f7a803063139753af31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= <55464917+jlucaso1@users.noreply.github.com> Date: Wed, 7 Feb 2024 08:40:44 -0300 Subject: [PATCH 10/30] fix: show window closing alert only when page is not saved (#3577) * fix: show window closing alert only when page is not saved * chore: Refactor useReloadConfirmations hook - Removed the `message` parameter, as it was not being used and not supported in modern browsers - Changed the `isActive` flag to a temporary flag and added a TODO comment to remove it later. - Implemented the `handleRouteChangeStart` function to handle route change events and prompt the user with a confirmation dialog before leaving the page. - Updated the dependencies of the `handleBeforeUnload` and `handleRouteChangeStart` callbacks. - Added event listeners for `beforeunload` and `routeChangeStart` events in the `useEffect` hook. - Cleaned up the event listeners in the cleanup function of the `useEffect` hook. fix: Fix reload confirmations in PageDetailsPage - Removed the TODO comment regarding fixing reload confirmations with MobX, as it has been resolved. - Passed the `pageStore?.isSubmitting === "submitting"` flag to the `useReloadConfirmations` hook instead of an undefined message. This commit refactors the `useReloadConfirmations` hook to improve its functionality and fixes the usage in the `PageDetailsPage` component. --------- Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> --- web/hooks/use-reload-confirmation.tsx | 35 +++++++++++++------ .../projects/[projectId]/pages/[pageId].tsx | 5 ++- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/web/hooks/use-reload-confirmation.tsx b/web/hooks/use-reload-confirmation.tsx index cdaff7365..8343ea78d 100644 --- a/web/hooks/use-reload-confirmation.tsx +++ b/web/hooks/use-reload-confirmation.tsx @@ -1,26 +1,41 @@ import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/router"; -const useReloadConfirmations = (message?: string) => { +//TODO: remove temp flag isActive later and use showAlert as the source of truth +const useReloadConfirmations = (isActive = true) => { const [showAlert, setShowAlert] = useState(false); + const router = useRouter(); const handleBeforeUnload = useCallback( (event: BeforeUnloadEvent) => { + if (!isActive || !showAlert) return; event.preventDefault(); event.returnValue = ""; - return message ?? "Are you sure you want to leave?"; }, - [message] + [isActive, showAlert] + ); + + const handleRouteChangeStart = useCallback( + (url: string) => { + if (!isActive || !showAlert) return; + const leave = confirm("Are you sure you want to leave? Changes you made may not be saved."); + if (!leave) { + router.events.emit("routeChangeError"); + throw `Route change to "${url}" was aborted (this error can be safely ignored).`; + } + }, + [isActive, showAlert, router.events] ); useEffect(() => { - if (!showAlert) { - window.removeEventListener("beforeunload", handleBeforeUnload); - return; - } - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, [handleBeforeUnload, showAlert]); + router.events.on("routeChangeStart", handleRouteChangeStart); + + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + router.events.off("routeChangeStart", handleRouteChangeStart); + }; + }, [handleBeforeUnload, handleRouteChangeStart, router.events]); return { setShowAlert }; }; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index be512dda0..93a814d57 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -56,9 +56,6 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { // toast alert const { setToastAlert } = useToast(); - //TODO:fix reload confirmations, with mobx - const { setShowAlert } = useReloadConfirmations(); - const { handleSubmit, setValue, watch, getValues, control, reset } = useForm({ defaultValues: { name: "", description_html: "" }, }); @@ -89,6 +86,8 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { const pageStore = usePage(pageId as string); + const { setShowAlert } = useReloadConfirmations(pageStore?.isSubmitting === "submitting"); + useEffect( () => () => { if (pageStore) { From 75b4c6e7d6d5c874830cd04d6d775dbbf9a6cf9b Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 7 Feb 2024 17:29:39 +0530 Subject: [PATCH 11/30] chore: posthog code refactor (#3586) --- web/pages/_document.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index 425862839..cc0411068 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -50,12 +50,6 @@ class MyDocument extends Document { src="https://plausible.io/js/script.js" /> )} - {process.env.NEXT_PUBLIC_POSTHOG_KEY && process.env.NEXT_PUBLIC_POSTHOG_HOST && ( - - )} ); From 0a35fcfbc0a1223b50dbf8efe5a462ec9fc0196e Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Wed, 7 Feb 2024 20:20:54 +0530 Subject: [PATCH 12/30] chore: email template logo changed (#3575) * chore: email template logo changed * fix: icons image extensions * fix: state icons --------- Co-authored-by: LAKHAN BAHETI --- .../emails/notifications/issue-updates.html | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index a7990562d..3c561f37a 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -66,7 +66,7 @@ style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px" > - + {% if update.changes.state.old_value.0 == 'Backlog' or update.changes.state.old_value.0 == 'In Progress' or update.changes.state.old_value.0 == 'Done' or update.changes.state.old_value.0 == 'Cancelled' %} + + {% endif %}

- + {% if update.changes.state.new_value|last == 'Backlog' or update.changes.state.new_value|last == 'In Progress' or update.changes.state.new_value|last == 'Done' or update.changes.state.new_value|last == 'Cancelled' %} + + {% endif %}

Date: Wed, 7 Feb 2024 20:45:05 +0530 Subject: [PATCH 13/30] fix: Handled the draft issue from issue create modal and optimised the draft issue store (#3588) Co-authored-by: gurusainath --- apiserver/plane/app/views/issue.py | 26 +++++----- .../command-palette/command-palette.tsx | 3 ++ .../headers/project-draft-issues.tsx | 2 +- .../issues/issue-layouts/kanban/block.tsx | 20 +++++--- .../kanban/headers/group-by-card.tsx | 23 ++++----- .../issues/issue-layouts/list/block.tsx | 20 +++++--- .../list/headers/group-by-card.tsx | 22 +++------ .../quick-action-dropdowns/project-issue.tsx | 5 ++ .../issues/issue-modal/draft-issue-layout.tsx | 3 ++ web/components/issues/issue-modal/form.tsx | 47 ++++++++++++++++--- web/components/issues/issue-modal/modal.tsx | 27 +++++++++-- web/store/issue/draft/issue.store.ts | 32 +++++++++---- 12 files changed, 151 insertions(+), 79 deletions(-) diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index 0b5c612d3..34bce8a0a 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -1668,15 +1668,9 @@ class IssueDraftViewSet(BaseViewSet): def get_queryset(self): return ( - Issue.objects.annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") + Issue.objects.filter( + project_id=self.kwargs.get("project_id") ) - .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) .filter(is_draft=True) .select_related("workspace", "project", "state", "parent") @@ -1710,7 +1704,7 @@ class IssueDraftViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - ) + ).distinct() @method_decorator(gzip_page) def list(self, request, slug, project_id): @@ -1832,7 +1826,10 @@ class IssueDraftViewSet(BaseViewSet): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) - return Response(serializer.data, status=status.HTTP_201_CREATED) + issue = ( + self.get_queryset().filter(pk=serializer.data["id"]).first() + ) + return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, pk): @@ -1868,10 +1865,13 @@ class IssueDraftViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True + issue = self.get_queryset().filter(pk=pk).first() + return Response( + IssueSerializer( + issue, fields=self.fields, expand=self.expand + ).data, + status=status.HTTP_200_OK, ) - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) def destroy(self, request, slug, project_id, pk=None): issue = Issue.objects.get( diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index e6349e0b4..396003589 100644 --- a/web/components/command-palette/command-palette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -163,6 +163,8 @@ export const CommandPalette: FC = observer(() => { return () => document.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); + const isDraftIssue = router?.asPath?.includes("draft-issues") || false; + if (!currentUser) return null; return ( @@ -217,6 +219,7 @@ export const CommandPalette: FC = observer(() => { onClose={() => toggleCreateIssueModal(false)} data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined} storeType={createIssueStoreType} + isDraft={isDraftIssue} /> {workspaceSlug && projectId && issueId && issueDetails && ( diff --git a/web/components/headers/project-draft-issues.tsx b/web/components/headers/project-draft-issues.tsx index 0fe6a74c5..139ec0257 100644 --- a/web/components/headers/project-draft-issues.tsx +++ b/web/components/headers/project-draft-issues.tsx @@ -103,7 +103,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => { } /> + } /> } /> diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 99d774bd5..203ac4938 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -66,16 +66,22 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop

- handleIssuePeekOverview(issue)} - className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" - > + {issue?.is_draft ? ( {issue.name} - + ) : ( + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > + + {issue.name} + + + )} = observer((props) => { return ( <> - {isDraftIssue ? ( - setIsOpen(false)} - prePopulateData={issuePayload} - fieldsToShow={["all"]} - /> - ) : ( - setIsOpen(false)} - data={issuePayload} - storeType={storeType} - /> - )} + setIsOpen(false)} + data={issuePayload} + storeType={storeType} + isDraft={isDraftIssue} + /> + {renderExistingIssueModal && ( = observer((props: IssueBlock
)} - handleIssuePeekOverview(issue)} - className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" - > + {issue?.is_draft ? ( {issue.name} - + ) : ( + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > + + {issue.name} + + + )}
{!issue?.tempId ? ( diff --git a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx index 49c9f7e40..90270e1a1 100644 --- a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -109,21 +109,13 @@ export const HeaderGroupByCard = observer(
))} - {isDraftIssue ? ( - setIsOpen(false)} - prePopulateData={issuePayload} - fieldsToShow={["all"]} - /> - ) : ( - setIsOpen(false)} - data={issuePayload} - storeType={storeType} - /> - )} + setIsOpen(false)} + data={issuePayload} + storeType={storeType} + isDraft={isDraftIssue} + /> {renderExistingIssueModal && ( = (props) => }; delete duplicateIssuePayload.id; + const isDraftIssue = router?.asPath?.includes("draft-issues") || false; + return ( <> = (props) => handleClose={() => setDeleteIssueModal(false)} onSubmit={handleDelete} /> + { @@ -73,7 +76,9 @@ export const ProjectIssueQuickActions: React.FC = (props) => if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} storeType={EIssuesStoreType.PROJECT} + isDraft={isDraftIssue} /> + void; onSubmit: (formData: Partial) => Promise; projectId: string; + isDraft: boolean; } const issueDraftService = new IssueDraftService(); @@ -35,6 +36,7 @@ export const DraftIssueLayout: React.FC = observer((props) => { projectId, isCreateMoreToggleEnabled, onCreateMoreToggleChange, + isDraft, } = props; // states const [issueDiscardModal, setIssueDiscardModal] = useState(false); @@ -107,6 +109,7 @@ export const DraftIssueLayout: React.FC = observer((props) => { onClose={handleClose} onSubmit={onSubmit} projectId={projectId} + isDraft={isDraft} /> ); diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index 31cb9dd66..430aa4920 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -1,4 +1,4 @@ -import React, { FC, useState, useRef, useEffect } from "react"; +import React, { FC, useState, useRef, useEffect, Fragment } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; @@ -55,8 +55,9 @@ export interface IssueFormProps { onCreateMoreToggleChange: (value: boolean) => void; onChange?: (formData: Partial | null) => void; onClose: () => void; - onSubmit: (values: Partial) => Promise; + onSubmit: (values: Partial, is_draft_issue?: boolean) => Promise; projectId: string; + isDraft: boolean; } // services @@ -72,6 +73,7 @@ export const IssueFormRoot: FC = observer((props) => { projectId: defaultProjectId, isCreateMoreToggleEnabled, onCreateMoreToggleChange, + isDraft, } = props; // states const [labelModal, setLabelModal] = useState(false); @@ -137,8 +139,8 @@ export const IssueFormRoot: FC = observer((props) => { const issueName = watch("name"); - const handleFormSubmit = async (formData: Partial) => { - await onSubmit(formData); + const handleFormSubmit = async (formData: Partial, is_draft_issue = false) => { + await onSubmit(formData, is_draft_issue); setGptAssistantModal(false); @@ -248,7 +250,7 @@ export const IssueFormRoot: FC = observer((props) => { }} /> )} -
+
{/* Don't show project selection if editing an issue */} @@ -670,7 +672,40 @@ export const IssueFormRoot: FC = observer((props) => { - + ) : ( + + )} + + )} + +
diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx index 3b5b35cea..02a087314 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -20,10 +20,19 @@ export interface IssuesModalProps { onSubmit?: (res: TIssue) => Promise; withDraftIssueWrapper?: boolean; storeType?: TCreateModalStoreTypes; + isDraft?: boolean; } export const CreateUpdateIssueModal: React.FC = observer((props) => { - const { data, isOpen, onClose, onSubmit, withDraftIssueWrapper = true, storeType = EIssuesStoreType.PROJECT } = props; + const { + data, + isOpen, + onClose, + onSubmit, + withDraftIssueWrapper = true, + storeType = EIssuesStoreType.PROJECT, + isDraft = false, + } = props; // states const [changesMade, setChangesMade] = useState | null>(null); const [createMore, setCreateMore] = useState(false); @@ -42,6 +51,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop const { issues: cycleIssues } = useIssues(EIssuesStoreType.CYCLE); const { issues: viewIssues } = useIssues(EIssuesStoreType.PROJECT_VIEW); const { issues: profileIssues } = useIssues(EIssuesStoreType.PROFILE); + const { issues: draftIssueStore } = useIssues(EIssuesStoreType.DRAFT); // store mapping based on current store const issueStores = { [EIssuesStoreType.PROJECT]: { @@ -122,11 +132,16 @@ export const CreateUpdateIssueModal: React.FC = observer((prop onClose(); }; - const handleCreateIssue = async (payload: Partial): Promise => { + const handleCreateIssue = async ( + payload: Partial, + is_draft_issue: boolean = false + ): Promise => { if (!workspaceSlug || !payload.project_id) return; try { - const response = await currentIssueStore.createIssue(workspaceSlug, payload.project_id, payload, viewId); + const response = is_draft_issue + ? await draftIssueStore.createIssue(workspaceSlug, payload.project_id, payload) + : await currentIssueStore.createIssue(workspaceSlug, payload.project_id, payload, viewId); if (!response) throw new Error(); currentIssueStore.fetchIssues(workspaceSlug, payload.project_id, "mutation", viewId); @@ -213,7 +228,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop } }; - const handleFormSubmit = async (formData: Partial) => { + const handleFormSubmit = async (formData: Partial, is_draft_issue: boolean = false) => { if (!workspaceSlug || !formData.project_id || !storeType) return; const payload: Partial = { @@ -222,7 +237,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop }; let response: TIssue | undefined = undefined; - if (!data?.id) response = await handleCreateIssue(payload); + if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue); else response = await handleUpdateIssue(payload); if (response != undefined && onSubmit) await onSubmit(response); @@ -274,6 +289,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop projectId={activeProjectId} isCreateMoreToggleEnabled={createMore} onCreateMoreToggleChange={handleCreateMoreToggleChange} + isDraft={isDraft} /> ) : ( = observer((prop onCreateMoreToggleChange={handleCreateMoreToggleChange} onSubmit={handleFormSubmit} projectId={activeProjectId} + isDraft={isDraft} /> )} diff --git a/web/store/issue/draft/issue.store.ts b/web/store/issue/draft/issue.store.ts index 13d14484a..dc0f601eb 100644 --- a/web/store/issue/draft/issue.store.ts +++ b/web/store/issue/draft/issue.store.ts @@ -1,5 +1,9 @@ import { action, observable, makeObservable, computed, runInAction } from "mobx"; import set from "lodash/set"; +import update from "lodash/update"; +import uniq from "lodash/uniq"; +import concat from "lodash/concat"; +import pull from "lodash/pull"; // base class import { IssueHelperStore } from "../helpers/issue-helper.store"; // services @@ -123,7 +127,7 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues { const response = await this.issueDraftService.createDraftIssue(workspaceSlug, projectId, data); runInAction(() => { - this.issues[projectId].push(response.id); + update(this.issues, [projectId], (issueIds = []) => uniq(concat(issueIds, response.id))); }); this.rootStore.issues.addIssue([response]); @@ -136,8 +140,17 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues { updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { try { - this.rootStore.issues.updateIssue(issueId, data); - const response = await this.issueDraftService.updateDraftIssue(workspaceSlug, projectId, issueId, data); + const response = await this.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, issueId, data); + + if (data.hasOwnProperty("is_draft") && data?.is_draft === false) { + runInAction(() => { + update(this.issues, [projectId], (issueIds = []) => { + if (issueIds.includes(issueId)) pull(issueIds, issueId); + return issueIds; + }); + }); + } + return response; } catch (error) { this.fetchIssues(workspaceSlug, projectId, "mutation"); @@ -147,15 +160,14 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues { removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { try { - const response = await this.issueDraftService.deleteDraftIssue(workspaceSlug, projectId, issueId); + const response = await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); - const issueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === issueId); - if (issueIndex >= 0) - runInAction(() => { - this.issues[projectId].splice(issueIndex, 1); + runInAction(() => { + update(this.issues, [projectId], (issueIds = []) => { + if (issueIds.includes(issueId)) pull(issueIds, issueId); + return issueIds; }); - - this.rootStore.issues.removeIssue(issueId); + }); return response; } catch (error) { From 346c6f5414b250e2498d393fe60fa8cc9bdd56db Mon Sep 17 00:00:00 2001 From: Ramesh Kumar Chandra <31303617+rameshkumarchandra@users.noreply.github.com> Date: Thu, 8 Feb 2024 00:16:00 +0530 Subject: [PATCH 14/30] fix: module header hide on bigger screens (#3590) * fix: module header hide on bigger screens * fix: Add Inbox back on mobile --------- Co-authored-by: Maximilian Engel Co-authored-by: sriram veeraghanta --- web/components/headers/project-issues.tsx | 8 +++----- web/components/modules/module-mobile-header.tsx | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index c819b25c3..81e2d2d76 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -200,8 +200,8 @@ export const ProjectIssuesHeader: React.FC = observer(() => { handleDisplayPropertiesUpdate={handleDisplayProperties} /> - - {currentProjectDetails?.inbox_view && inboxDetails && ( +
+ {currentProjectDetails?.inbox_view && inboxDetails && ( - )} - -
+ )} {canUserCreateIssue && ( <>
- +
); }; From a43dfc097d11d0b5f84e8d53c4ee8da029241291 Mon Sep 17 00:00:00 2001 From: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Date: Thu, 8 Feb 2024 11:47:52 +0530 Subject: [PATCH 15/30] feat: muti-arch build (#3569) * arm build, supporting centos & alpine --- .github/workflows/build-branch.yml | 152 ++++++++++++--------- deploy/1-click/install.sh | 5 +- deploy/1-click/plane-app | 203 +++++++++++++++++------------ deploy/selfhost/docker-compose.yml | 4 - deploy/selfhost/install.sh | 10 +- deploy/selfhost/variables.env | 35 +++-- 6 files changed, 236 insertions(+), 173 deletions(-) diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 38694a62e..c43305fc0 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -2,11 +2,6 @@ name: Branch Build on: workflow_dispatch: - inputs: - branch_name: - description: "Branch Name" - required: true - default: "preview" push: branches: - master @@ -16,49 +11,71 @@ on: types: [released, prereleased] env: - TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }} + TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }} jobs: branch_build_setup: name: Build-Push Web/Space/API/Proxy Docker Image - runs-on: ubuntu-20.04 - steps: - - name: Check out the repo - uses: actions/checkout@v3.3.0 + runs-on: ubuntu-latest outputs: - gh_branch_name: ${{ env.TARGET_BRANCH }} + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} + gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} + gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} + gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} + + steps: + - id: set_env_variables + name: Set Environment Variables + run: | + if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT + else + echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=local" >> $GITHUB_OUTPUT + fi + echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT branch_build_push_frontend: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - name: Set Frontend Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable else TAG=${{ env.FRONTEND_TAG }} fi echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV - - name: Docker Setup QEMU - uses: docker/setup-qemu-action@v3.0.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.0.0 - with: - platforms: linux/amd64,linux/arm64 - buildkitd-flags: "--allow-insecure-entitlement security.insecure" - name: Login to Docker Hub - uses: docker/login-action@v3.0.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + - name: Check out the repo uses: actions/checkout@v4.1.1 @@ -67,7 +84,7 @@ jobs: with: context: . file: ./web/Dockerfile.web - platforms: linux/amd64 + platforms: ${{ env.BUILDX_PLATFORMS }} tags: ${{ env.FRONTEND_TAG }} push: true env: @@ -80,33 +97,36 @@ jobs: needs: [branch_build_setup] env: SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - name: Set Space Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable else TAG=${{ env.SPACE_TAG }} fi echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV - - name: Docker Setup QEMU - uses: docker/setup-qemu-action@v3.0.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.0.0 - with: - platforms: linux/amd64,linux/arm64 - buildkitd-flags: "--allow-insecure-entitlement security.insecure" - - name: Login to Docker Hub - uses: docker/login-action@v3.0.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + - name: Check out the repo uses: actions/checkout@v4.1.1 @@ -115,7 +135,7 @@ jobs: with: context: . file: ./space/Dockerfile.space - platforms: linux/amd64 + platforms: ${{ env.BUILDX_PLATFORMS }} tags: ${{ env.SPACE_TAG }} push: true env: @@ -128,33 +148,36 @@ jobs: needs: [branch_build_setup] env: BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - name: Set Backend Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable else TAG=${{ env.BACKEND_TAG }} fi echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV - - name: Docker Setup QEMU - uses: docker/setup-qemu-action@v3.0.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.0.0 - with: - platforms: linux/amd64,linux/arm64 - buildkitd-flags: "--allow-insecure-entitlement security.insecure" - - name: Login to Docker Hub - uses: docker/login-action@v3.0.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + - name: Check out the repo uses: actions/checkout@v4.1.1 @@ -163,7 +186,7 @@ jobs: with: context: ./apiserver file: ./apiserver/Dockerfile.api - platforms: linux/amd64 + platforms: ${{ env.BUILDX_PLATFORMS }} push: true tags: ${{ env.BACKEND_TAG }} env: @@ -171,38 +194,42 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + branch_build_push_proxy: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - name: Set Proxy Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable else TAG=${{ env.PROXY_TAG }} fi echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV - - name: Docker Setup QEMU - uses: docker/setup-qemu-action@v3.0.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.0.0 - with: - platforms: linux/amd64,linux/arm64 - buildkitd-flags: "--allow-insecure-entitlement security.insecure" - - name: Login to Docker Hub - uses: docker/login-action@v3.0.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + - name: Check out the repo uses: actions/checkout@v4.1.1 @@ -211,10 +238,11 @@ jobs: with: context: ./nginx file: ./nginx/Dockerfile - platforms: linux/amd64 + platforms: ${{ env.BUILDX_PLATFORMS }} tags: ${{ env.PROXY_TAG }} push: true env: DOCKER_BUILDKIT: 1 DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + diff --git a/deploy/1-click/install.sh b/deploy/1-click/install.sh index f32be504d..917d08fdf 100644 --- a/deploy/1-click/install.sh +++ b/deploy/1-click/install.sh @@ -1,5 +1,6 @@ #!/bin/bash +# Check if the user has sudo access if command -v curl &> /dev/null; then sudo curl -sSL \ -o /usr/local/bin/plane-app \ @@ -11,6 +12,6 @@ else fi sudo chmod +x /usr/local/bin/plane-app -sudo sed -i 's/export BRANCH=${BRANCH:-master}/export BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app +sudo sed -i 's/export DEPLOY_BRANCH=${BRANCH:-master}/export DEPLOY_BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app -sudo plane-app --help \ No newline at end of file +plane-app --help diff --git a/deploy/1-click/plane-app b/deploy/1-click/plane-app index 445f39d69..2d6ef0a6f 100644 --- a/deploy/1-click/plane-app +++ b/deploy/1-click/plane-app @@ -17,7 +17,7 @@ Project management tool from the future EOF } -function update_env_files() { +function update_env_file() { config_file=$1 key=$2 value=$3 @@ -25,14 +25,16 @@ function update_env_files() { # Check if the config file exists if [ ! -f "$config_file" ]; then echo "Config file not found. Creating a new one..." >&2 - touch "$config_file" + sudo touch "$config_file" fi # Check if the key already exists in the config file - if grep -q "^$key=" "$config_file"; then - awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" + if sudo grep "^$key=" "$config_file"; then + sudo awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" | sudo tee "$config_file.tmp" > /dev/null + sudo mv "$config_file.tmp" "$config_file" &> /dev/null else - echo "$key=$value" >> "$config_file" + # sudo echo "$key=$value" >> "$config_file" + echo -e "$key=$value" | sudo tee -a "$config_file" > /dev/null fi } function read_env_file() { @@ -42,12 +44,12 @@ function read_env_file() { # Check if the config file exists if [ ! -f "$config_file" ]; then echo "Config file not found. Creating a new one..." >&2 - touch "$config_file" + sudo touch "$config_file" fi # Check if the key already exists in the config file - if grep -q "^$key=" "$config_file"; then - value=$(awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file") + if sudo grep -q "^$key=" "$config_file"; then + value=$(sudo awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file") echo "$value" else echo "" @@ -55,19 +57,19 @@ function read_env_file() { } function update_config() { config_file="$PLANE_INSTALL_DIR/config.env" - update_env_files "$config_file" "$1" "$2" + update_env_file $config_file $1 $2 } function read_config() { config_file="$PLANE_INSTALL_DIR/config.env" - read_env_file "$config_file" "$1" + read_env_file $config_file $1 } function update_env() { config_file="$PLANE_INSTALL_DIR/.env" - update_env_files "$config_file" "$1" "$2" + update_env_file $config_file $1 $2 } function read_env() { config_file="$PLANE_INSTALL_DIR/.env" - read_env_file "$config_file" "$1" + read_env_file $config_file $1 } function show_message() { print_header @@ -87,14 +89,14 @@ function prepare_environment() { show_message "Prepare Environment..." >&2 show_message "- Updating OS with required tools ✋" >&2 - sudo apt-get update -y &> /dev/null - sudo apt-get upgrade -y &> /dev/null + sudo "$PACKAGE_MANAGER" update -y + sudo "$PACKAGE_MANAGER" upgrade -y - required_tools=("curl" "awk" "wget" "nano" "dialog" "git") + local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap") for tool in "${required_tools[@]}"; do if ! command -v $tool &> /dev/null; then - sudo apt install -y $tool &> /dev/null + sudo "$PACKAGE_MANAGER" install -y $tool fi done @@ -103,11 +105,30 @@ function prepare_environment() { # Install Docker if not installed if ! command -v docker &> /dev/null; then show_message "- Installing Docker ✋" >&2 - sudo curl -o- https://get.docker.com | bash - + # curl -o- https://get.docker.com | bash - - if [ "$EUID" -ne 0 ]; then - dockerd-rootless-setuptool.sh install &> /dev/null + if [ "$PACKAGE_MANAGER" == "yum" ]; then + sudo $PACKAGE_MANAGER install -y yum-utils + sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo &> /dev/null + elif [ "$PACKAGE_MANAGER" == "apt-get" ]; then + # Add Docker's official GPG key: + sudo $PACKAGE_MANAGER update + sudo $PACKAGE_MANAGER install ca-certificates curl &> /dev/null + sudo install -m 0755 -d /etc/apt/keyrings &> /dev/null + sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc &> /dev/null + sudo chmod a+r /etc/apt/keyrings/docker.asc &> /dev/null + + # Add the repository to Apt sources: + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + + sudo $PACKAGE_MANAGER update fi + + sudo $PACKAGE_MANAGER install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y + show_message "- Docker Installed ✅" "replace_last_line" >&2 else show_message "- Docker is already installed ✅" >&2 @@ -127,17 +148,17 @@ function prepare_environment() { function download_plane() { # Download Docker Compose File from github url show_message "Downloading Plane Setup Files ✋" >&2 - curl -H 'Cache-Control: no-cache, no-store' \ + sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o $PLANE_INSTALL_DIR/docker-compose.yaml \ - https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s) + https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s) - curl -H 'Cache-Control: no-cache, no-store' \ + sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o $PLANE_INSTALL_DIR/variables-upgrade.env \ - https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s) + https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s) # if .env does not exists rename variables-upgrade.env to .env if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then - mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env + sudo mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env fi show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2 @@ -186,7 +207,7 @@ function build_local_image() { PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null - sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch -q > /dev/null + sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $DEPLOY_BRANCH --single-branch -q > /dev/null sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml @@ -199,25 +220,26 @@ function check_for_docker_images() { show_message "" >&2 # show_message "Building Plane Images" >&2 - update_env "DOCKERHUB_USER" "makeplane" - update_env "PULL_POLICY" "always" CURR_DIR=$(pwd) - if [ "$BRANCH" == "master" ]; then + if [ "$DEPLOY_BRANCH" == "master" ]; then update_env "APP_RELEASE" "latest" export APP_RELEASE=latest else - update_env "APP_RELEASE" "$BRANCH" - export APP_RELEASE=$BRANCH + update_env "APP_RELEASE" "$DEPLOY_BRANCH" + export APP_RELEASE=$DEPLOY_BRANCH fi - if [ $CPU_ARCH == "amd64" ] || [ $CPU_ARCH == "x86_64" ]; then + if [ $USE_GLOBAL_IMAGES == 1 ]; then # show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2 + export DOCKERHUB_USER=makeplane + update_env "DOCKERHUB_USER" "$DOCKERHUB_USER" + update_env "PULL_POLICY" "always" echo "Building Plane Images for $CPU_ARCH is not required. Skipping..." else export DOCKERHUB_USER=myplane show_message "Building Plane Images for $CPU_ARCH " >&2 - update_env "DOCKERHUB_USER" "myplane" + update_env "DOCKERHUB_USER" "$DOCKERHUB_USER" update_env "PULL_POLICY" "never" build_local_image @@ -233,7 +255,7 @@ function check_for_docker_images() { sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2 - docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull + sudo docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull show_message "Plane Images Downloaded ✅" "replace_last_line" >&2 } function configure_plane() { @@ -453,9 +475,11 @@ function install() { show_message "" if [ "$(uname)" == "Linux" ]; then OS="linux" - OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) - # check the OS - if [ "$OS_NAME" == "ubuntu" ]; then + OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release) + OS_NAME=$(echo "$OS_NAME" | tr -d '"') + print_header + if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || + [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then OS_SUPPORTED=true show_message "******** Installing Plane ********" show_message "" @@ -488,7 +512,8 @@ function install() { fi else - PROGRESS_MSG="❌❌❌ Unsupported OS Detected ❌❌❌" + OS_SUPPORTED=false + PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌" show_message "" exit 1 fi @@ -499,12 +524,17 @@ function install() { fi } function upgrade() { + print_header if [ "$(uname)" == "Linux" ]; then OS="linux" - OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) - # check the OS - if [ "$OS_NAME" == "ubuntu" ]; then + OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release) + OS_NAME=$(echo "$OS_NAME" | tr -d '"') + if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || + [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then + OS_SUPPORTED=true + show_message "******** Upgrading Plane ********" + show_message "" prepare_environment @@ -528,53 +558,49 @@ function upgrade() { exit 1 fi else - PROGRESS_MSG="Unsupported OS Detected" + PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌" show_message "" exit 1 fi else - PROGRESS_MSG="Unsupported OS Detected : $(uname)" + PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌" show_message "" exit 1 fi } function uninstall() { + print_header if [ "$(uname)" == "Linux" ]; then OS="linux" OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) - # check the OS - if [ "$OS_NAME" == "ubuntu" ]; then + OS_NAME=$(echo "$OS_NAME" | tr -d '"') + if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || + [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then + OS_SUPPORTED=true show_message "******** Uninstalling Plane ********" show_message "" stop_server - # CHECK IF PLANE SERVICE EXISTS - # if [ -f "/etc/systemd/system/plane.service" ]; then - # sudo systemctl stop plane.service &> /dev/null - # sudo systemctl disable plane.service &> /dev/null - # sudo rm /etc/systemd/system/plane.service &> /dev/null - # sudo systemctl daemon-reload &> /dev/null - # fi - # show_message "- Plane Service removed ✅" if ! [ -x "$(command -v docker)" ]; then echo "DOCKER_NOT_INSTALLED" &> /dev/null else # Ask of user input to confirm uninstall docker ? - CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3) + CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --defaultno --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3) if [ $? -eq 0 ]; then show_message "- Uninstalling Docker ✋" - sudo apt-get purge -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null - sudo apt-get autoremove -y --purge docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null + sudo docker images -q | xargs -r sudo docker rmi -f &> /dev/null + sudo "$PACKAGE_MANAGER" remove -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null + sudo "$PACKAGE_MANAGER" autoremove -y docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null show_message "- Docker Uninstalled ✅" "replace_last_line" >&2 fi fi - rm $PLANE_INSTALL_DIR/.env &> /dev/null - rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null - rm $PLANE_INSTALL_DIR/config.env &> /dev/null - rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null + sudo rm $PLANE_INSTALL_DIR/.env &> /dev/null + sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null + sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null + sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null # rm -rf $PLANE_INSTALL_DIR &> /dev/null show_message "- Configuration Cleaned ✅" @@ -593,12 +619,12 @@ function uninstall() { show_message "" show_message "" else - PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌" + PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌" show_message "" exit 1 fi else - PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌" + PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌" show_message "" exit 1 fi @@ -608,15 +634,15 @@ function start_server() { env_file="$PLANE_INSTALL_DIR/.env" # check if both the files exits if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then - show_message "Starting Plane Server ✋" - docker compose -f $docker_compose_file --env-file=$env_file up -d + show_message "Starting Plane Server ($APP_RELEASE) ✋" + sudo docker compose -f $docker_compose_file --env-file=$env_file up -d # Wait for containers to be running echo "Waiting for containers to start..." - while ! docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do + while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do sleep 1 done - show_message "Plane Server Started ✅" "replace_last_line" >&2 + show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2 else show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 fi @@ -626,11 +652,11 @@ function stop_server() { env_file="$PLANE_INSTALL_DIR/.env" # check if both the files exits if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then - show_message "Stopping Plane Server ✋" - docker compose -f $docker_compose_file --env-file=$env_file down - show_message "Plane Server Stopped ✅" "replace_last_line" >&2 + show_message "Stopping Plane Server ($APP_RELEASE) ✋" + sudo docker compose -f $docker_compose_file --env-file=$env_file down + show_message "Plane Server Stopped ($APP_RELEASE) ✅" "replace_last_line" >&2 else - show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 + show_message "Plane Server not installed [Skipping] ✅" "replace_last_line" >&2 fi } function restart_server() { @@ -638,9 +664,9 @@ function restart_server() { env_file="$PLANE_INSTALL_DIR/.env" # check if both the files exits if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then - show_message "Restarting Plane Server ✋" - docker compose -f $docker_compose_file --env-file=$env_file restart - show_message "Plane Server Restarted ✅" "replace_last_line" >&2 + show_message "Restarting Plane Server ($APP_RELEASE) ✋" + sudo docker compose -f $docker_compose_file --env-file=$env_file restart + show_message "Plane Server Restarted ($APP_RELEASE) ✅" "replace_last_line" >&2 else show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 fi @@ -666,28 +692,45 @@ function show_help() { } function update_installer() { show_message "Updating Plane Installer ✋" >&2 - curl -H 'Cache-Control: no-cache, no-store' \ + sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/1-click/install.sh?token=$(date +%s) + https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s) - chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null + sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null show_message "Plane Installer Updated ✅" "replace_last_line" >&2 } -export BRANCH=${BRANCH:-master} -export APP_RELEASE=$BRANCH +export DEPLOY_BRANCH=${BRANCH:-master} +export APP_RELEASE=$DEPLOY_BRANCH export DOCKERHUB_USER=makeplane export PULL_POLICY=always +if [ "$DEPLOY_BRANCH" == "master" ]; then + export APP_RELEASE=latest +fi + PLANE_INSTALL_DIR=/opt/plane DATA_DIR=$PLANE_INSTALL_DIR/data LOG_DIR=$PLANE_INSTALL_DIR/log OS_SUPPORTED=false CPU_ARCH=$(uname -m) PROGRESS_MSG="" -USE_GLOBAL_IMAGES=1 +USE_GLOBAL_IMAGES=0 +PACKAGE_MANAGER="" -mkdir -p $PLANE_INSTALL_DIR/{data,log} +if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then + USE_GLOBAL_IMAGES=1 +fi + +sudo mkdir -p $PLANE_INSTALL_DIR/{data,log} + +if command -v apt-get &> /dev/null; then + PACKAGE_MANAGER="apt-get" +elif command -v yum &> /dev/null; then + PACKAGE_MANAGER="yum" +elif command -v apk &> /dev/null; then + PACKAGE_MANAGER="apk" +fi if [ "$1" == "start" ]; then start_server @@ -704,7 +747,7 @@ elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then upgrade elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then uninstall -elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ] ; then +elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ]; then update_installer elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then show_help diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index b223e722a..60861878c 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -38,10 +38,6 @@ x-app-env : &app-env - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - # OPENAI SETTINGS - Deprecated can be configured through admin panel - - OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} - - OPENAI_API_KEY=${OPENAI_API_KEY:-""} - - GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"} # LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1} diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 4e505cff9..30f2d15d7 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -20,8 +20,8 @@ function buildLocalImage() { DO_BUILD="2" else printf "\n" >&2 - printf "${YELLOW}You are on ${ARCH} cpu architecture. ${NC}\n" >&2 - printf "${YELLOW}Since the prebuilt ${ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2 + printf "${YELLOW}You are on ${CPU_ARCH} cpu architecture. ${NC}\n" >&2 + printf "${YELLOW}Since the prebuilt ${CPU_ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2 printf "${YELLOW}This might take ${YELLOW}5-30 min based on your system's hardware configuration. \n ${NC} \n" >&2 printf "\n" >&2 printf "${GREEN}Select an option to proceed: ${NC}\n" >&2 @@ -149,7 +149,7 @@ function upgrade() { function askForAction() { echo echo "Select a Action you want to perform:" - echo " 1) Install (${ARCH})" + echo " 1) Install (${CPU_ARCH})" echo " 2) Start" echo " 3) Stop" echo " 4) Restart" @@ -193,8 +193,8 @@ function askForAction() { } # CPU ARCHITECHTURE BASED SETTINGS -ARCH=$(uname -m) -if [ $ARCH == "amd64" ] || [ $ARCH == "x86_64" ]; +CPU_ARCH=$(uname -m) +if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then USE_GLOBAL_IMAGES=1 DOCKERHUB_USER=makeplane diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index 4a3781811..6d2cde0ff 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -8,13 +8,13 @@ NGINX_PORT=80 WEB_URL=http://localhost DEBUG=0 NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces -SENTRY_DSN="" -SENTRY_ENVIRONMENT="production" -GOOGLE_CLIENT_ID="" -GITHUB_CLIENT_ID="" -GITHUB_CLIENT_SECRET="" +SENTRY_DSN= +SENTRY_ENVIRONMENT=production +GOOGLE_CLIENT_ID= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= DOCKERIZED=1 # deprecated -CORS_ALLOWED_ORIGINS="http://localhost" +CORS_ALLOWED_ORIGINS=http://localhost #DB SETTINGS PGHOST=plane-db @@ -31,19 +31,14 @@ REDIS_PORT=6379 REDIS_URL=redis://${REDIS_HOST}:6379/ # EMAIL SETTINGS -EMAIL_HOST="" -EMAIL_HOST_USER="" -EMAIL_HOST_PASSWORD="" +EMAIL_HOST= +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= EMAIL_PORT=587 -EMAIL_FROM="Team Plane " +EMAIL_FROM=Team Plane EMAIL_USE_TLS=1 EMAIL_USE_SSL=0 -# OPENAI SETTINGS -OPENAI_API_BASE=https://api.openai.com/v1 # deprecated -OPENAI_API_KEY="sk-" # deprecated -GPT_ENGINE="gpt-3.5-turbo" # deprecated - # LOGIN/SIGNUP SETTINGS ENABLE_SIGNUP=1 ENABLE_EMAIL_PASSWORD=1 @@ -52,13 +47,13 @@ SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 # DATA STORE SETTINGS USE_MINIO=1 -AWS_REGION="" -AWS_ACCESS_KEY_ID="access-key" -AWS_SECRET_ACCESS_KEY="secret-key" +AWS_REGION= +AWS_ACCESS_KEY_ID=access-key +AWS_SECRET_ACCESS_KEY=secret-key AWS_S3_ENDPOINT_URL=http://plane-minio:9000 AWS_S3_BUCKET_NAME=uploads -MINIO_ROOT_USER="access-key" -MINIO_ROOT_PASSWORD="secret-key" +MINIO_ROOT_USER=access-key +MINIO_ROOT_PASSWORD=secret-key BUCKET_NAME=uploads FILE_SIZE_LIMIT=5242880 From fb3dd77b66bed774cb4bbf58bd9f7502c8faffeb Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Thu, 8 Feb 2024 11:49:00 +0530 Subject: [PATCH 16/30] feat: Keyboard navigation spreadsheet layout for issues (#3564) * enable keyboard navigation for spreadsheet layout * move the logic to table level instead of cell level * fix perf issue that made it unusable * fix scroll issue with navigation * fix build errors --- packages/ui/src/dropdowns/custom-menu.tsx | 36 +++++++--- packages/ui/src/dropdowns/helper.tsx | 2 + .../ui/src/hooks/use-dropdown-key-down.tsx | 13 +++- web/components/common/breadcrumb-link.tsx | 2 +- web/components/dropdowns/cycle.tsx | 8 ++- web/components/dropdowns/date.tsx | 8 ++- web/components/dropdowns/estimate.tsx | 10 ++- .../dropdowns/member/project-member.tsx | 10 ++- web/components/dropdowns/member/types.d.ts | 1 + .../dropdowns/member/workspace-member.tsx | 9 ++- web/components/dropdowns/module.tsx | 10 ++- web/components/dropdowns/priority.tsx | 10 ++- web/components/dropdowns/project.tsx | 10 ++- web/components/dropdowns/state.tsx | 10 ++- .../issue-layouts/properties/labels.tsx | 20 ++++-- .../spreadsheet/columns/assignee-column.tsx | 4 +- .../spreadsheet/columns/due-date-column.tsx | 4 +- .../spreadsheet/columns/estimate-column.tsx | 4 +- .../spreadsheet/columns/header-column.tsx | 9 ++- .../spreadsheet/columns/label-column.tsx | 6 +- .../spreadsheet/columns/priority-column.tsx | 6 +- .../spreadsheet/columns/start-date-column.tsx | 4 +- .../spreadsheet/columns/state-column.tsx | 4 +- .../spreadsheet/issue-column.tsx | 68 +++++++++++++++++++ .../issue-layouts/spreadsheet/issue-row.tsx | 60 ++++++---------- .../spreadsheet/spreadsheet-header-column.tsx | 46 +++++++++++++ .../spreadsheet/spreadsheet-header.tsx | 36 ++++------ .../spreadsheet/spreadsheet-table.tsx | 5 +- web/constants/spreadsheet.ts | 1 + web/hooks/use-dropdown-key-down.tsx | 22 ++++-- web/hooks/use-table-keyboard-navigation.tsx | 56 +++++++++++++++ 31 files changed, 368 insertions(+), 126 deletions(-) create mode 100644 web/components/issues/issue-layouts/spreadsheet/issue-column.tsx create mode 100644 web/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx create mode 100644 web/hooks/use-table-keyboard-navigation.tsx diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 7ef99370f..c7cce2475 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -15,6 +15,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const { buttonClassName = "", customButtonClassName = "", + customButtonTabIndex = 0, placement, children, className = "", @@ -29,6 +30,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { verticalEllipsis = false, portalElement, menuButtonOnClick, + onMenuClose, tabIndex, closeOnSelect, } = props; @@ -47,18 +49,27 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { setIsOpen(true); if (referenceElement) referenceElement.focus(); }; - const closeDropdown = () => setIsOpen(false); - const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + const closeDropdown = () => { + isOpen && onMenuClose && onMenuClose(); + setIsOpen(false); + }; + + const handleOnChange = () => { + if (closeOnSelect) closeDropdown(); + }; + + const selectActiveItem = () => { + const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector( + `[data-headlessui-state="active"] button` + ); + activeItem?.click(); + }; + + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem); useOutsideClickDetector(dropdownRef, closeDropdown); let menuItems = ( - { - if (closeOnSelect) closeDropdown(); - }} - static - > +
{ ref={dropdownRef} tabIndex={tabIndex} className={cn("relative w-min text-left", className)} - onKeyDown={handleKeyDown} + onKeyDownCapture={handleKeyDown} + onChange={handleOnChange} > {({ open }) => ( <> @@ -103,6 +115,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { if (menuButtonOnClick) menuButtonOnClick(); }} className={customButtonClassName} + tabIndex={customButtonTabIndex} > {customButton} @@ -122,6 +135,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${ disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} + tabIndex={customButtonTabIndex} > @@ -142,6 +156,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { openDropdown(); if (menuButtonOnClick) menuButtonOnClick(); }} + tabIndex={customButtonTabIndex} > {label} {!noChevron && } @@ -159,6 +174,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const MenuItem: React.FC = (props) => { const { children, onClick, className = "" } = props; + return ( {({ active, close }) => ( diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 06f1c44c0..930f332b9 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -3,6 +3,7 @@ import { Placement } from "@blueprintjs/popover2"; export interface IDropdownProps { customButtonClassName?: string; + customButtonTabIndex?: number; buttonClassName?: string; className?: string; customButton?: JSX.Element; @@ -23,6 +24,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps { noBorder?: boolean; verticalEllipsis?: boolean; menuButtonOnClick?: (...args: any) => void; + onMenuClose?: () => void; closeOnSelect?: boolean; portalElement?: Element | null; } diff --git a/packages/ui/src/hooks/use-dropdown-key-down.tsx b/packages/ui/src/hooks/use-dropdown-key-down.tsx index 1bb861477..b93a4d551 100644 --- a/packages/ui/src/hooks/use-dropdown-key-down.tsx +++ b/packages/ui/src/hooks/use-dropdown-key-down.tsx @@ -1,16 +1,23 @@ import { useCallback } from "react"; type TUseDropdownKeyDown = { - (onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent) => void; + ( + onOpen: () => void, + onClose: () => void, + isOpen: boolean, + selectActiveItem?: () => void + ): (event: React.KeyboardEvent) => void; }; -export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => { +export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen, selectActiveItem?) => { const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Enter") { - event.stopPropagation(); if (!isOpen) { + event.stopPropagation(); onOpen(); + } else { + selectActiveItem && selectActiveItem(); } } else if (event.key === "Escape" && isOpen) { event.stopPropagation(); diff --git a/web/components/common/breadcrumb-link.tsx b/web/components/common/breadcrumb-link.tsx index aebd7fc02..e5f1dbce6 100644 --- a/web/components/common/breadcrumb-link.tsx +++ b/web/components/common/breadcrumb-link.tsx @@ -11,7 +11,7 @@ export const BreadcrumbLink: React.FC = (props) => { const { href, label, icon } = props; return ( -
  • +
  • {href ? ( void; + onClose?: () => void; projectId: string; value: string | null; }; @@ -47,6 +48,7 @@ export const CycleDropdown: React.FC = observer((props) => { dropdownArrowClassName = "", hideIcon = false, onChange, + onClose, placeholder = "Cycle", placement, projectId, @@ -123,8 +125,10 @@ export const CycleDropdown: React.FC = observer((props) => { }; const handleClose = () => { - if (isOpen) setIsOpen(false); + if (!isOpen) return; + setIsOpen(false); if (referenceElement) referenceElement.blur(); + onClose && onClose(); }; const toggleDropdown = () => { @@ -163,7 +167,7 @@ export const CycleDropdown: React.FC = observer((props) => {
    @@ -216,10 +226,10 @@ export const IssuePropertyLabels: React.FC = observer((pro + className={({ active, selected }) => `flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${ - selected ? "text-custom-text-100" : "text-custom-text-200" - }` + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` } > {({ selected }) => ( diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx index e63a94b8c..b9450141b 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx @@ -7,12 +7,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetAssigneeColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -37,6 +38,7 @@ export const SpreadsheetAssigneeColumn: React.FC = observer((props: Props } buttonClassName="text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx index 775275ca4..c5674cee9 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx @@ -9,12 +9,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetDueDateColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -36,6 +37,7 @@ export const SpreadsheetDueDateColumn: React.FC = observer((props: Props) buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx index 0c86b24c0..f7a472b49 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx @@ -6,12 +6,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetEstimateColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -25,6 +26,7 @@ export const SpreadsheetEstimateColumn: React.FC = observer((props: Props buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx index dc9f8c7c6..73478c6ac 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx @@ -20,10 +20,11 @@ interface Props { property: keyof IIssueDisplayProperties; displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => void; + onClose: () => void; } -export const SpreadsheetHeaderColumn = (props: Props) => { - const { displayFilters, handleDisplayFilterUpdate, property } = props; +export const HeaderColumn = (props: Props) => { + const { displayFilters, handleDisplayFilterUpdate, property, onClose } = props; const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( "spreadsheetViewSorting", @@ -44,7 +45,8 @@ export const SpreadsheetHeaderColumn = (props: Props) => { return ( @@ -62,6 +64,7 @@ export const SpreadsheetHeaderColumn = (props: Props) => {
  • } + onMenuClose={onClose} placement="bottom-end" > handleOrderBy(propertyDetails.ascendingOrderKey, property)}> diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx index 2812fb1ec..60e429c9f 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx @@ -9,12 +9,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetLabelColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; // hooks const { labelMap } = useLabel(); @@ -25,13 +26,14 @@ export const SpreadsheetLabelColumn: React.FC = observer((props: Props) = projectId={issue.project_id ?? null} value={issue.label_ids} defaultOptions={defaultLabelOptions} - onChange={(data) => onChange(issue, { label_ids: data },{ changed_property: "labels", change_details: data })} + onChange={(data) => onChange(issue, { label_ids: data }, { changed_property: "labels", change_details: data })} className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" buttonClassName="px-2.5 h-full" hideDropdownArrow maxRender={1} disabled={disabled} placeholderText="Select labels" + onClose={onClose} /> ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx index 1961b8717..b8801559c 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx @@ -7,22 +7,24 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial,updates:any) => void; disabled: boolean; }; export const SpreadsheetPriorityColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    onChange(issue, { priority: data },{changed_property:"priority",change_details:data})} + onChange={(data) => onChange(issue, { priority: data }, { changed_property: "priority", change_details: data })} disabled={disabled} buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx index 076464f27..fcbd817b6 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx @@ -9,12 +9,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetStartDateColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -36,6 +37,7 @@ export const SpreadsheetStartDateColumn: React.FC = observer((props: Prop buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx index 83a7c8d0f..1a029db12 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx @@ -7,12 +7,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetStateColumn: React.FC = observer((props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -24,6 +25,7 @@ export const SpreadsheetStateColumn: React.FC = observer((props) => { buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx new file mode 100644 index 000000000..5d2e62fa5 --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx @@ -0,0 +1,68 @@ +import { useRef } from "react"; +import { useRouter } from "next/router"; +// types +import { IIssueDisplayProperties, TIssue } from "@plane/types"; +import { EIssueActions } from "../types"; +// constants +import { SPREADSHEET_PROPERTY_DETAILS } from "constants/spreadsheet"; +// components +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { useEventTracker } from "hooks/store"; +import { observer } from "mobx-react"; + +type Props = { + displayProperties: IIssueDisplayProperties; + issueDetail: TIssue; + disableUserActions: boolean; + property: keyof IIssueDisplayProperties; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; + isEstimateEnabled: boolean; +}; + +export const IssueColumn = observer((props: Props) => { + const { displayProperties, issueDetail, disableUserActions, property, handleIssues, isEstimateEnabled } = props; + // router + const router = useRouter(); + const tableCellRef = useRef(null); + const { captureIssueEvent } = useEventTracker(); + + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; + + return ( + + + , updates: any) => + handleIssues({ ...issue, ...data }, EIssueActions.UPDATE).then(() => { + captureIssueEvent({ + eventName: "Issue updated", + payload: { + ...issue, + ...data, + element: "Spreadsheet layout", + }, + updates: updates, + path: router.asPath, + }); + }) + } + disabled={disableUserActions} + onClose={() => { + tableCellRef?.current?.focus(); + }} + /> + + + ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 40ee85df7..2a97045fe 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -4,14 +4,15 @@ import { observer } from "mobx-react-lite"; // icons import { ChevronRight, MoreHorizontal } from "lucide-react"; // constants -import { SPREADSHEET_PROPERTY_DETAILS, SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; +import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; // components import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { IssueColumn } from "./issue-column"; // ui import { ControlLink, Tooltip } from "@plane/ui"; // hooks import useOutsideClickDetector from "hooks/use-outside-click-detector"; -import { useEventTracker, useIssueDetail, useProject } from "hooks/store"; +import { useIssueDetail, useProject } from "hooks/store"; // helper import { cn } from "helpers/common.helper"; // types @@ -51,7 +52,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => { //hooks const { getProjectById } = useProject(); const { peekIssue, setPeekIssue } = useIssueDetail(); - const { captureIssueEvent } = useEventTracker(); // states const [isMenuActive, setIsMenuActive] = useState(false); const [isExpanded, setExpanded] = useState(false); @@ -106,11 +106,12 @@ export const SpreadsheetIssueRow = observer((props: Props) => { {/* first column/ issue name and key column */}
    { href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`} target="_blank" onClick={() => handleIssuePeekOverview(issueDetail)} - className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" >
    -
    +
    {issueDetail.name}
    @@ -161,40 +165,16 @@ export const SpreadsheetIssueRow = observer((props: Props) => { {/* Rest of the columns */} - {SPREADSHEET_PROPERTY_LIST.map((property) => { - const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; - - const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; - - return ( - - - , updates: any) => - handleIssues({ ...issue, ...data }, EIssueActions.UPDATE).then(() => { - captureIssueEvent({ - eventName: "Issue updated", - payload: { - ...issue, - ...data, - element: "Spreadsheet layout", - }, - updates: updates, - path: router.asPath, - }); - }) - } - disabled={disableUserActions} - /> - - - ); - })} + {SPREADSHEET_PROPERTY_LIST.map((property) => ( + + ))} {isExpanded && diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx new file mode 100644 index 000000000..588c7be9e --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx @@ -0,0 +1,46 @@ +import { useRef } from "react"; +//types +import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +//components +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { HeaderColumn } from "./columns/header-column"; +import { observer } from "mobx-react"; + +interface Props { + displayProperties: IIssueDisplayProperties; + property: keyof IIssueDisplayProperties; + isEstimateEnabled: boolean; + displayFilters: IIssueDisplayFilterOptions; + handleDisplayFilterUpdate: (data: Partial) => void; +} +export const SpreadsheetHeaderColumn = observer((props: Props) => { + const { displayProperties, displayFilters, property, isEstimateEnabled, handleDisplayFilterUpdate } = props; + + //hooks + const tableHeaderCellRef = useRef(null); + + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + return ( + + + { + tableHeaderCellRef?.current?.focus(); + }} + /> + + + ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx index 704c9f904..64d1ec0e1 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx @@ -6,8 +6,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/type import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; // components import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; -import { SpreadsheetHeaderColumn } from "./columns/header-column"; - +import { SpreadsheetHeaderColumn } from "./spreadsheet-header-column"; interface Props { displayProperties: IIssueDisplayProperties; @@ -22,7 +21,10 @@ export const SpreadsheetHeader = (props: Props) => { return ( - + #ID @@ -34,25 +36,15 @@ export const SpreadsheetHeader = (props: Props) => { - {SPREADSHEET_PROPERTY_LIST.map((property) => { - const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; - - return ( - - - - - - ); - })} + {SPREADSHEET_PROPERTY_LIST.map((property) => ( + + ))} ); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx index 369e6633c..e63b01dfb 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -5,6 +5,7 @@ import { EIssueActions } from "../types"; //components import { SpreadsheetIssueRow } from "./issue-row"; import { SpreadsheetHeader } from "./spreadsheet-header"; +import { useTableKeyboardNavigation } from "hooks/use-table-keyboard-navigation"; type Props = { displayProperties: IIssueDisplayProperties; @@ -35,8 +36,10 @@ export const SpreadsheetTable = observer((props: Props) => { canEditProperties, } = props; + const handleKeyBoardNavigation = useTableKeyboardNavigation(); + return ( - +
    ; Column: React.FC<{ issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }>; diff --git a/web/hooks/use-dropdown-key-down.tsx b/web/hooks/use-dropdown-key-down.tsx index 99511b0fc..228e35575 100644 --- a/web/hooks/use-dropdown-key-down.tsx +++ b/web/hooks/use-dropdown-key-down.tsx @@ -1,23 +1,31 @@ import { useCallback } from "react"; type TUseDropdownKeyDown = { - (onEnterKeyDown: () => void, onEscKeyDown: () => void): (event: React.KeyboardEvent) => void; + (onEnterKeyDown: () => void, onEscKeyDown: () => void, stopPropagation?: boolean): ( + event: React.KeyboardEvent + ) => void; }; -export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown) => { +export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => { + const stopEventPropagation = (event: React.KeyboardEvent) => { + if (stopPropagation) { + event.stopPropagation(); + event.preventDefault(); + } + }; + const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Enter") { - event.stopPropagation(); - event.preventDefault(); + stopEventPropagation(event); + onEnterKeyDown(); } else if (event.key === "Escape") { - event.stopPropagation(); - event.preventDefault(); + stopEventPropagation(event); onEscKeyDown(); } }, - [onEnterKeyDown, onEscKeyDown] + [onEnterKeyDown, onEscKeyDown, stopEventPropagation] ); return handleKeyDown; diff --git a/web/hooks/use-table-keyboard-navigation.tsx b/web/hooks/use-table-keyboard-navigation.tsx new file mode 100644 index 000000000..0d1c26f3c --- /dev/null +++ b/web/hooks/use-table-keyboard-navigation.tsx @@ -0,0 +1,56 @@ +export const useTableKeyboardNavigation = () => { + const getPreviousRow = (element: HTMLElement) => { + const previousRow = element.closest("tr")?.previousSibling; + + if (previousRow) return previousRow; + //if previous row does not exist in the parent check the row with the header of the table + return element.closest("tbody")?.previousSibling?.childNodes?.[0]; + }; + + const getNextRow = (element: HTMLElement) => { + const nextRow = element.closest("tr")?.nextSibling; + + if (nextRow) return nextRow; + //if next row does not exist in the parent check the row with the body of the table + return element.closest("thead")?.nextSibling?.childNodes?.[0]; + }; + + const handleKeyBoardNavigation = function (e: React.KeyboardEvent) { + const element = e.target as HTMLElement; + + if (!(element?.tagName === "TD" || element?.tagName === "TH")) return; + + let c: HTMLElement | null = null; + if (e.key == "ArrowRight") { + // Right Arrow + c = element.nextSibling as HTMLElement; + } else if (e.key == "ArrowLeft") { + // Left Arrow + c = element.previousSibling as HTMLElement; + } else if (e.key == "ArrowUp") { + // Up Arrow + const index = Array.prototype.indexOf.call(element?.parentNode?.childNodes || [], element); + const prevRow = getPreviousRow(element); + + c = prevRow?.childNodes?.[index] as HTMLElement; + } else if (e.key == "ArrowDown") { + // Down Arrow + const index = Array.prototype.indexOf.call(element?.parentNode?.childNodes || [], element); + const nextRow = getNextRow(element); + + c = nextRow?.childNodes[index] as HTMLElement; + } else if (e.key == "Enter" || e.key == "Space") { + e.preventDefault(); + (element?.querySelector(".clickable") as HTMLElement)?.click(); + return; + } + + if (!c) return; + + e.preventDefault(); + c?.focus(); + c?.scrollIntoView({ behavior: "smooth", block: "center", inline: "end" }); + }; + + return handleKeyBoardNavigation; +}; From 1a7b5d72226e29f0084396c1008d23cd8fc5e09c Mon Sep 17 00:00:00 2001 From: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Date: Thu, 8 Feb 2024 12:26:55 +0530 Subject: [PATCH 17/30] build fix (#3594) --- .github/workflows/build-branch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index c43305fc0..603f08e94 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -37,7 +37,7 @@ jobs: echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT - echo "BUILDX_ENDPOINT=local" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT fi echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT From c1c0297b6d2ae6562d948c3da08c2904702cd459 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Thu, 8 Feb 2024 13:08:19 +0530 Subject: [PATCH 18/30] fix: handled issue create modal submission on clicking enter key (#3593) --- web/components/issues/issue-modal/form.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index 430aa4920..544ebeb15 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -250,7 +250,7 @@ export const IssueFormRoot: FC = observer((props) => { }} /> )} - + handleFormSubmit(data))}>
    {/* Don't show project selection if editing an issue */} @@ -699,13 +699,7 @@ export const IssueFormRoot: FC = observer((props) => { )} -
    From 9545dc77d6173059390da8bf842a2de43fea2676 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 8 Feb 2024 13:30:16 +0530 Subject: [PATCH 19/30] fix: cycle and module reordering in the gantt chart (#3570) * fix: cycle and module reordering in the gantt chart * chore: hide duration from sidebar if no dates are assigned * chore: updated date helper functions to accept undefined params * chore: update cycle sidebar condition --- .../cycles/active-cycle-details.tsx | 2 +- web/components/cycles/cycles-board-card.tsx | 2 +- web/components/cycles/cycles-list-item.tsx | 2 +- .../cycles/gantt-chart/cycles-list-layout.tsx | 44 ++-------- web/components/cycles/sidebar.tsx | 5 +- .../widgets/issue-panels/issue-list-item.tsx | 4 +- .../gantt-chart/helpers/block-structure.tsx | 3 +- .../gantt-chart/sidebar/cycle-sidebar.tsx | 10 ++- .../gantt-chart/sidebar/module-sidebar.tsx | 10 ++- .../sidebar/project-view-sidebar.tsx | 10 ++- .../gantt-chart/sidebar/sidebar.tsx | 13 ++- web/components/issues/index.ts | 1 - .../issues/view-select/due-date.tsx | 81 ------------------- .../issues/view-select/estimate.tsx | 64 --------------- web/components/issues/view-select/index.ts | 3 - .../issues/view-select/start-date.tsx | 72 ----------------- .../gantt-chart/modules-list-layout.tsx | 42 +++++----- web/helpers/date-time.helper.ts | 15 ++-- web/store/cycle.store.ts | 12 +-- web/store/module.store.ts | 4 +- 20 files changed, 75 insertions(+), 324 deletions(-) delete mode 100644 web/components/issues/view-select/due-date.tsx delete mode 100644 web/components/issues/view-select/estimate.tsx delete mode 100644 web/components/issues/view-select/index.ts delete mode 100644 web/components/issues/view-select/start-date.tsx diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index a0101b1c1..2fa79ec3a 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -150,7 +150,7 @@ export const ActiveCycleDetails: React.FC = observer((props color: group.color, })); - const daysLeft = findHowManyDaysLeft(activeCycle.end_date ?? new Date()); + const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0; return (
    diff --git a/web/components/cycles/cycles-board-card.tsx b/web/components/cycles/cycles-board-card.tsx index 8da2be9ec..bad7df0e5 100644 --- a/web/components/cycles/cycles-board-card.tsx +++ b/web/components/cycles/cycles-board-card.tsx @@ -137,7 +137,7 @@ export const CyclesBoardCard: FC = (props) => { }); }; - const daysLeft = findHowManyDaysLeft(cycleDetails.end_date ?? new Date()); + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; return (
    diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index a6d467091..725480241 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -140,7 +140,7 @@ export const CyclesListItem: FC = (props) => { const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); - const daysLeft = findHowManyDaysLeft(cycleDetails.end_date ?? new Date()); + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; return ( <> diff --git a/web/components/cycles/gantt-chart/cycles-list-layout.tsx b/web/components/cycles/gantt-chart/cycles-list-layout.tsx index 26d04e103..797fc9e39 100644 --- a/web/components/cycles/gantt-chart/cycles-list-layout.tsx +++ b/web/components/cycles/gantt-chart/cycles-list-layout.tsx @@ -1,11 +1,8 @@ import { FC } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { KeyedMutator } from "swr"; // hooks import { useCycle, useUser } from "hooks/store"; -// services -import { CycleService } from "services/cycle.service"; // components import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart"; import { CycleGanttBlock } from "components/cycles"; @@ -17,14 +14,10 @@ import { EUserProjectRoles } from "constants/project"; type Props = { workspaceSlug: string; cycleIds: string[]; - mutateCycles?: KeyedMutator; }; -// services -const cycleService = new CycleService(); - export const CyclesListGanttChartView: FC = observer((props) => { - const { cycleIds, mutateCycles } = props; + const { cycleIds } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -32,38 +25,15 @@ export const CyclesListGanttChartView: FC = observer((props) => { const { membership: { currentProjectRole }, } = useUser(); - const { getCycleById } = useCycle(); + const { getCycleById, updateCycleDetails } = useCycle(); - const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => { - if (!workspaceSlug) return; - mutateCycles && - mutateCycles((prevData: any) => { - if (!prevData) return prevData; + const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => { + if (!workspaceSlug || !cycle) return; - const newList = prevData.map((p: any) => ({ - ...p, - ...(p.id === cycle.id - ? { - start_date: payload.start_date ? payload.start_date : p.start_date, - target_date: payload.target_date ? payload.target_date : p.end_date, - sort_order: payload.sort_order ? payload.sort_order.newSortOrder : p.sort_order, - } - : {}), - })); + const payload: any = { ...data }; + if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder; - if (payload.sort_order) { - const removedElement = newList.splice(payload.sort_order.sourceIndex, 1)[0]; - newList.splice(payload.sort_order.destinationIndex, 0, removedElement); - } - - return newList; - }, false); - - const newPayload: any = { ...payload }; - - if (newPayload.sort_order && payload.sort_order) newPayload.sort_order = payload.sort_order.newSortOrder; - - cycleService.patchCycle(workspaceSlug.toString(), cycle.project, cycle.id, newPayload); + await updateCycleDetails(workspaceSlug.toString(), cycle.project, cycle.id, payload); }; const blockFormat = (blocks: (ICycle | null)[]) => { diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index c61679304..299c71008 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -318,6 +318,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const issueCount = cycleDetails.total_issues === 0 ? "0 Issue" : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; @@ -375,8 +376,8 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { backgroundColor: `${currentCycle.color}20`, }} > - {currentCycle.value === "current" - ? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}` + {currentCycle.value === "current" && daysLeft !== undefined + ? `${daysLeft} ${currentCycle.label}` : `${currentCycle.label}`} )} diff --git a/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx b/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx index 3da862d91..fe003e167 100644 --- a/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx +++ b/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx @@ -83,7 +83,7 @@ export const AssignedOverdueIssueListItem: React.FC = observ const blockedByIssueProjectDetails = blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null; - const dueBy = findTotalDaysInRange(new Date(issueDetails.target_date ?? ""), new Date(), false); + const dueBy = findTotalDaysInRange(new Date(issueDetails.target_date ?? ""), new Date(), false) ?? 0; return ( = observe const projectDetails = getProjectById(issue.project_id); - const dueBy = findTotalDaysInRange(new Date(issue.target_date ?? ""), new Date(), false); + const dueBy = findTotalDaysInRange(new Date(issue.target_date ?? ""), new Date(), false) ?? 0; return ( - blocks && - blocks.map((block) => ({ + blocks?.map((block) => ({ data: block, id: block.id, sort_order: block.sort_order, diff --git a/web/components/gantt-chart/sidebar/cycle-sidebar.tsx b/web/components/gantt-chart/sidebar/cycle-sidebar.tsx index 1af1529c2..dddccda5a 100644 --- a/web/components/gantt-chart/sidebar/cycle-sidebar.tsx +++ b/web/components/gantt-chart/sidebar/cycle-sidebar.tsx @@ -93,7 +93,7 @@ export const CycleGanttSidebar: React.FC = (props) => { <> {blocks ? ( blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? ""); + const duration = findTotalDaysInRange(block.start_date, block.target_date); return ( = (props) => {
    -
    - {duration} day{duration > 1 ? "s" : ""} -
    + {duration !== undefined && ( +
    + {duration} day{duration > 1 ? "s" : ""} +
    + )}
    diff --git a/web/components/gantt-chart/sidebar/module-sidebar.tsx b/web/components/gantt-chart/sidebar/module-sidebar.tsx index 30f146dc5..8f8788787 100644 --- a/web/components/gantt-chart/sidebar/module-sidebar.tsx +++ b/web/components/gantt-chart/sidebar/module-sidebar.tsx @@ -93,7 +93,7 @@ export const ModuleGanttSidebar: React.FC = (props) => { <> {blocks ? ( blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? ""); + const duration = findTotalDaysInRange(block.start_date, block.target_date); return ( = (props) => {
    -
    - {duration} day{duration > 1 ? "s" : ""} -
    + {duration !== undefined && ( +
    + {duration} day{duration > 1 ? "s" : ""} +
    + )} diff --git a/web/components/gantt-chart/sidebar/project-view-sidebar.tsx b/web/components/gantt-chart/sidebar/project-view-sidebar.tsx index da7382859..6e31215c1 100644 --- a/web/components/gantt-chart/sidebar/project-view-sidebar.tsx +++ b/web/components/gantt-chart/sidebar/project-view-sidebar.tsx @@ -94,7 +94,7 @@ export const ProjectViewGanttSidebar: React.FC = (props) => { <> {blocks ? ( blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? ""); + const duration = findTotalDaysInRange(block.start_date, block.target_date); return ( = (props) => {
    -
    - {duration} day{duration > 1 ? "s" : ""} -
    + {duration !== undefined && ( +
    + {duration} day{duration > 1 ? "s" : ""} +
    + )} diff --git a/web/components/gantt-chart/sidebar/sidebar.tsx b/web/components/gantt-chart/sidebar/sidebar.tsx index bca39a0bd..12de8e127 100644 --- a/web/components/gantt-chart/sidebar/sidebar.tsx +++ b/web/components/gantt-chart/sidebar/sidebar.tsx @@ -119,10 +119,7 @@ export const IssueGanttSidebar: React.FC = (props) => { // hide the block if it doesn't have start and target dates and showAllBlocks is false if (!showAllBlocks && !isBlockVisibleOnSidebar) return; - const duration = - !block.start_date || !block.target_date - ? null - : findTotalDaysInRange(block.start_date, block.target_date); + const duration = findTotalDaysInRange(block.start_date, block.target_date); return ( = (props) => {
    -
    - {duration && ( + {duration !== undefined && ( +
    {duration} day{duration > 1 ? "s" : ""} - )} -
    +
    + )} diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index 3cf88cb7c..3904049e9 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -1,6 +1,5 @@ export * from "./attachment"; export * from "./issue-modal"; -export * from "./view-select"; export * from "./delete-issue-modal"; export * from "./description-form"; export * from "./issue-layouts"; diff --git a/web/components/issues/view-select/due-date.tsx b/web/components/issues/view-select/due-date.tsx deleted file mode 100644 index d61e7586a..000000000 --- a/web/components/issues/view-select/due-date.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// ui -import { CustomDatePicker } from "components/ui"; -import { Tooltip } from "@plane/ui"; -import { CalendarCheck } from "lucide-react"; -// helpers -import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; -// types -import { TIssue } from "@plane/types"; - -type Props = { - issue: TIssue; - onChange: (date: string | null) => void; - handleOnOpen?: () => void; - handleOnClose?: () => void; - tooltipPosition?: "top" | "bottom"; - className?: string; - noBorder?: boolean; - disabled: boolean; -}; - -export const ViewDueDateSelect: React.FC = ({ - issue, - onChange, - handleOnOpen, - handleOnClose, - tooltipPosition = "top", - className = "", - noBorder = false, - disabled, -}) => { - const minDate = issue.start_date ? new Date(issue.start_date) : null; - minDate?.setDate(minDate.getDate()); - - return ( - -
    - - {issue.target_date ? ( - <> - - {renderFormattedDate(issue.target_date) ?? "_ _"} - - ) : ( - <> - - Due Date - - )} -
    - } - minDate={minDate ?? undefined} - noBorder={noBorder} - handleOnOpen={handleOnOpen} - handleOnClose={handleOnClose} - disabled={disabled} - /> - -
    - ); -}; diff --git a/web/components/issues/view-select/estimate.tsx b/web/components/issues/view-select/estimate.tsx deleted file mode 100644 index 1739f3aaa..000000000 --- a/web/components/issues/view-select/estimate.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react-lite"; -import { Triangle } from "lucide-react"; -import sortBy from "lodash/sortBy"; -// store hooks -import { useEstimate } from "hooks/store"; -// ui -import { CustomSelect, Tooltip } from "@plane/ui"; -// types -import { TIssue } from "@plane/types"; - -type Props = { - issue: TIssue; - onChange: (data: number) => void; - tooltipPosition?: "top" | "bottom"; - customButton?: boolean; - disabled: boolean; -}; - -export const ViewEstimateSelect: React.FC = observer((props) => { - const { issue, onChange, tooltipPosition = "top", customButton = false, disabled } = props; - const { areEstimatesEnabledForCurrentProject, activeEstimateDetails, getEstimatePointValue } = useEstimate(); - - const estimateValue = getEstimatePointValue(issue.estimate_point, issue.project_id); - - const estimateLabels = ( - -
    - - {estimateValue ?? "None"} -
    -
    - ); - - if (!areEstimatesEnabledForCurrentProject) return null; - - return ( - - - <> - - - - None - - - {sortBy(activeEstimateDetails?.points, "key")?.map((estimate) => ( - - <> - - {estimate.value} - - - ))} - - ); -}); diff --git a/web/components/issues/view-select/index.ts b/web/components/issues/view-select/index.ts deleted file mode 100644 index 8eb88cb0d..000000000 --- a/web/components/issues/view-select/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./due-date"; -export * from "./estimate"; -export * from "./start-date"; diff --git a/web/components/issues/view-select/start-date.tsx b/web/components/issues/view-select/start-date.tsx deleted file mode 100644 index 039bc0cb5..000000000 --- a/web/components/issues/view-select/start-date.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// ui -import { CustomDatePicker } from "components/ui"; -import { Tooltip } from "@plane/ui"; -import { CalendarClock } from "lucide-react"; -// helpers -import { renderFormattedDate } from "helpers/date-time.helper"; -// types -import { TIssue } from "@plane/types"; - -type Props = { - issue: TIssue; - onChange: (date: string | null) => void; - handleOnOpen?: () => void; - handleOnClose?: () => void; - tooltipPosition?: "top" | "bottom"; - className?: string; - noBorder?: boolean; - disabled: boolean; -}; - -export const ViewStartDateSelect: React.FC = ({ - issue, - onChange, - handleOnOpen, - handleOnClose, - tooltipPosition = "top", - className = "", - noBorder = false, - disabled, -}) => { - const maxDate = issue.target_date ? new Date(issue.target_date) : null; - maxDate?.setDate(maxDate.getDate()); - - return ( - -
    - - {issue?.start_date ? ( - <> - - {renderFormattedDate(issue?.start_date ?? "_ _")} - - ) : ( - <> - - Start Date - - )} -
    - } - handleOnClose={handleOnClose} - disabled={disabled} - /> - -
    - ); -}; diff --git a/web/components/modules/gantt-chart/modules-list-layout.tsx b/web/components/modules/gantt-chart/modules-list-layout.tsx index d1cbd0dfa..53948f71d 100644 --- a/web/components/modules/gantt-chart/modules-list-layout.tsx +++ b/web/components/modules/gantt-chart/modules-list-layout.tsx @@ -13,37 +13,32 @@ export const ModulesListGanttChartView: React.FC = observer(() => { const router = useRouter(); const { workspaceSlug } = router.query; // store - const { projectModuleIds, moduleMap } = useModule(); const { currentProjectDetails } = useProject(); + const { projectModuleIds, moduleMap, updateModuleDetails } = useModule(); - const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => { - if (!workspaceSlug) return; - // FIXME - //updateModuleGanttStructure(workspaceSlug.toString(), module.project, module, payload); + const handleModuleUpdate = async (module: IModule, data: IBlockUpdateData) => { + if (!workspaceSlug || !module) return; + + const payload: any = { ...data }; + if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder; + + await updateModuleDetails(workspaceSlug.toString(), module.project, module.id, payload); }; const blockFormat = (blocks: string[]) => - blocks && blocks.length > 0 - ? blocks - .filter((blockId) => { - const block = moduleMap[blockId]; - return block.start_date && block.target_date && new Date(block.start_date) <= new Date(block.target_date); - }) - .map((blockId) => { - const block = moduleMap[blockId]; - return { - data: block, - id: block.id, - sort_order: block.sort_order, - start_date: new Date(block.start_date ?? ""), - target_date: new Date(block.target_date ?? ""), - }; - }) - : []; + blocks?.map((blockId) => { + const block = moduleMap[blockId]; + return { + data: block, + id: block.id, + sort_order: block.sort_order, + start_date: block.start_date ? new Date(block.start_date) : null, + target_date: block.target_date ? new Date(block.target_date) : null, + }; + }); const isAllowed = currentProjectDetails?.member_role === 20 || currentProjectDetails?.member_role === 15; - const modules = projectModuleIds; return (
    { enableBlockRightResize={isAllowed} enableBlockMove={isAllowed} enableReorder={isAllowed} + showAllBlocks />
    ); diff --git a/web/helpers/date-time.helper.ts b/web/helpers/date-time.helper.ts index bc5daa2a3..b629e60ec 100644 --- a/web/helpers/date-time.helper.ts +++ b/web/helpers/date-time.helper.ts @@ -87,11 +87,11 @@ export const renderFormattedTime = (date: string | Date, timeFormat: "12-hour" | * @example checkIfStringIsDate("2021-01-01", "2021-01-08") // 8 */ export const findTotalDaysInRange = ( - startDate: Date | string, - endDate: Date | string, + startDate: Date | string | undefined | null, + endDate: Date | string | undefined | null, inclusive: boolean = true -): number => { - if (!startDate || !endDate) return 0; +): number | undefined => { + if (!startDate || !endDate) return undefined; // Parse the dates to check if they are valid const parsedStartDate = new Date(startDate); const parsedEndDate = new Date(endDate); @@ -110,8 +110,11 @@ export const findTotalDaysInRange = ( * @param {boolean} inclusive (optional) // default true * @example findHowManyDaysLeft("2024-01-01") // 3 */ -export const findHowManyDaysLeft = (date: string | Date, inclusive: boolean = true): number => { - if (!date) return 0; +export const findHowManyDaysLeft = ( + date: Date | string | undefined | null, + inclusive: boolean = true +): number | undefined => { + if (!date) return undefined; // Pass the date to findTotalDaysInRange function to find the total number of days in range from today return findTotalDaysInRange(new Date(), date, inclusive); }; diff --git a/web/store/cycle.store.ts b/web/store/cycle.store.ts index bb6824c08..51340d740 100644 --- a/web/store/cycle.store.ts +++ b/web/store/cycle.store.ts @@ -103,7 +103,7 @@ export class CycleStore implements ICycleStore { const projectId = this.rootStore.app.router.projectId; if (!projectId || !this.fetchedMap[projectId]) return null; let allCycles = Object.values(this.cycleMap ?? {}).filter((c) => c?.project === projectId); - allCycles = sortBy(allCycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + allCycles = sortBy(allCycles, [(c) => c.sort_order]); const allCycleIds = allCycles.map((c) => c.id); return allCycleIds; } @@ -118,7 +118,7 @@ export class CycleStore implements ICycleStore { const hasEndDatePassed = isPast(new Date(c.end_date ?? "")); return c.project === projectId && hasEndDatePassed; }); - completedCycles = sortBy(completedCycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + completedCycles = sortBy(completedCycles, [(c) => c.sort_order]); const completedCycleIds = completedCycles.map((c) => c.id); return completedCycleIds; } @@ -133,7 +133,7 @@ export class CycleStore implements ICycleStore { const isStartDateUpcoming = isFuture(new Date(c.start_date ?? "")); return c.project === projectId && isStartDateUpcoming; }); - upcomingCycles = sortBy(upcomingCycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + upcomingCycles = sortBy(upcomingCycles, [(c) => c.sort_order]); const upcomingCycleIds = upcomingCycles.map((c) => c.id); return upcomingCycleIds; } @@ -148,7 +148,7 @@ export class CycleStore implements ICycleStore { const hasEndDatePassed = isPast(new Date(c.end_date ?? "")); return c.project === projectId && !hasEndDatePassed; }); - incompleteCycles = sortBy(incompleteCycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + incompleteCycles = sortBy(incompleteCycles, [(c) => c.sort_order]); const incompleteCycleIds = incompleteCycles.map((c) => c.id); return incompleteCycleIds; } @@ -162,7 +162,7 @@ export class CycleStore implements ICycleStore { let draftCycles = Object.values(this.cycleMap ?? {}).filter( (c) => c.project === projectId && !c.start_date && !c.end_date ); - draftCycles = sortBy(draftCycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + draftCycles = sortBy(draftCycles, [(c) => c.sort_order]); const draftCycleIds = draftCycles.map((c) => c.id); return draftCycleIds; } @@ -203,7 +203,7 @@ export class CycleStore implements ICycleStore { if (!this.fetchedMap[projectId]) return null; let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project === projectId); - cycles = sortBy(cycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + cycles = sortBy(cycles, [(c) => c.sort_order]); const cycleIds = cycles.map((c) => c.id); return cycleIds || null; }); diff --git a/web/store/module.store.ts b/web/store/module.store.ts index 7ebccc23c..5c80e39d0 100644 --- a/web/store/module.store.ts +++ b/web/store/module.store.ts @@ -100,7 +100,7 @@ export class ModulesStore implements IModuleStore { const projectId = this.rootStore.app.router.projectId; if (!projectId || !this.fetchedMap[projectId]) return null; let projectModules = Object.values(this.moduleMap).filter((m) => m.project === projectId); - projectModules = sortBy(projectModules, [(m) => !m.is_favorite, (m) => m.name.toLowerCase()]); + projectModules = sortBy(projectModules, [(m) => m.sort_order]); const projectModuleIds = projectModules.map((m) => m.id); return projectModuleIds || null; } @@ -120,7 +120,7 @@ export class ModulesStore implements IModuleStore { if (!this.fetchedMap[projectId]) return null; let projectModules = Object.values(this.moduleMap).filter((m) => m.project === projectId); - projectModules = sortBy(projectModules, [(m) => !m.is_favorite, (m) => m.name.toLowerCase()]); + projectModules = sortBy(projectModules, [(m) => m.sort_order]); const projectModuleIds = projectModules.map((m) => m.id); return projectModuleIds; }); From 55afef204d415096dfad1a27899aaf19557adcc9 Mon Sep 17 00:00:00 2001 From: Ramesh Kumar Chandra <31303617+rameshkumarchandra@users.noreply.github.com> Date: Thu, 8 Feb 2024 17:49:26 +0530 Subject: [PATCH 20/30] style: responsive profile (#3596) * style: responsive profile * style: profile header drop down, sidebar auto show hide depending on the screen width * fix: item tap on white space in the drop down menu in profile header * fix: profile layout breaking on big screens in page visit --- .../sidebar/sidebar-menu-hamburger-toggle.tsx | 2 +- web/components/headers/user-profile.tsx | 72 ++- web/components/profile/navbar.tsx | 11 +- web/components/profile/sidebar.tsx | 43 +- .../profile/preferences/layout.tsx | 55 +- .../profile/preferences/sidebar.tsx | 29 +- .../settings-layout/profile/sidebar.tsx | 92 ++- web/layouts/user-profile-layout/layout.tsx | 7 +- .../profile/[userId]/assigned.tsx | 2 +- .../profile/[userId]/created.tsx | 2 +- .../profile/[userId]/index.tsx | 2 +- .../profile/[userId]/subscribed.tsx | 2 +- web/pages/profile/activity.tsx | 20 +- web/pages/profile/change-password.tsx | 6 + web/pages/profile/index.tsx | 575 +++++++++--------- web/pages/profile/preferences/theme.tsx | 2 +- web/store/application/theme.store.ts | 18 + 17 files changed, 571 insertions(+), 369 deletions(-) diff --git a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx index 0e34eac2c..fe7b8d177 100644 --- a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx +++ b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx @@ -3,7 +3,7 @@ import { Menu } from "lucide-react"; import { useApplication } from "hooks/store"; import { observer } from "mobx-react"; -export const SidebarHamburgerToggle: FC = observer (() => { +export const SidebarHamburgerToggle: FC = observer(() => { const { theme: themStore } = useApplication(); return (
    ( -
    +type TUserProfileHeader = { + type?: string | undefined +} + +export const UserProfileHeader: FC = observer((props) => { + const { type = undefined } = props + + const router = useRouter(); + const { workspaceSlug, userId } = router.query; + + const AUTHORIZED_ROLES = [20, 15, 10]; + const { + membership: { currentWorkspaceRole }, + } = useUser(); + + if (!currentWorkspaceRole) return null; + + const isAuthorized = AUTHORIZED_ROLES.includes(currentWorkspaceRole); + const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; + + const { theme: themStore } = useApplication(); + + return (
    -
    +
    } /> +
    + + {type} + +
    + } + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + <> + {tabsList.map((tab) => ( + + {tab.label} + + ))} + + +
    -
    -); +
    ) +}); + + diff --git a/web/components/profile/navbar.tsx b/web/components/profile/navbar.tsx index 44dfe57d1..4361b7a9d 100644 --- a/web/components/profile/navbar.tsx +++ b/web/components/profile/navbar.tsx @@ -22,16 +22,15 @@ export const ProfileNavbar: React.FC = (props) => { const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; return ( -
    +
    {tabsList.map((tab) => ( {tab.label} diff --git a/web/components/profile/sidebar.tsx b/web/components/profile/sidebar.tsx index 3ce7747c9..b356b5adb 100644 --- a/web/components/profile/sidebar.tsx +++ b/web/components/profile/sidebar.tsx @@ -4,7 +4,7 @@ import useSWR from "swr"; import { Disclosure, Transition } from "@headlessui/react"; import { observer } from "mobx-react-lite"; // hooks -import { useUser } from "hooks/store"; +import { useApplication, useUser } from "hooks/store"; // services import { UserService } from "services/user.service"; // components @@ -18,6 +18,8 @@ import { renderFormattedDate } from "helpers/date-time.helper"; import { renderEmoji } from "helpers/emoji.helper"; // fetch-keys import { USER_PROFILE_PROJECT_SEGREGATION } from "constants/fetch-keys"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { useEffect, useRef } from "react"; // services const userService = new UserService(); @@ -28,6 +30,8 @@ export const ProfileSidebar = observer(() => { const { workspaceSlug, userId } = router.query; // store hooks const { currentUser } = useUser(); + const { theme: themStore } = useApplication(); + const ref = useRef(null); const { data: userProjectsData } = useSWR( workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null, @@ -36,6 +40,14 @@ export const ProfileSidebar = observer(() => { : null ); + useOutsideClickDetector(ref, () => { + if (themStore.profileSidebarCollapsed === false) { + if (window.innerWidth < 768) { + themStore.toggleProfileSidebar(); + } + } + }); + const userDetails = [ { label: "Joined on", @@ -47,8 +59,26 @@ export const ProfileSidebar = observer(() => { }, ]; + useEffect(() => { + const handleToggleProfileSidebar = () => { + if (window && window.innerWidth < 768) { + themStore.toggleProfileSidebar(true); + } + if (window && themStore.profileSidebarCollapsed && window.innerWidth >= 768) { + themStore.toggleProfileSidebar(false); + } + }; + + window.addEventListener("resize", handleToggleProfileSidebar); + handleToggleProfileSidebar(); + return () => window.removeEventListener("resize", handleToggleProfileSidebar); + }, [themStore]); + return ( -
    +
    {userProjectsData ? ( <>
    @@ -132,13 +162,12 @@ export const ProfileSidebar = observer(() => { {project.assigned_issues > 0 && (
    {completedIssuePercentage}%
    diff --git a/web/layouts/settings-layout/profile/preferences/layout.tsx b/web/layouts/settings-layout/profile/preferences/layout.tsx index 9d17350a9..b25935f4e 100644 --- a/web/layouts/settings-layout/profile/preferences/layout.tsx +++ b/web/layouts/settings-layout/profile/preferences/layout.tsx @@ -2,6 +2,11 @@ import { FC, ReactNode } from "react"; // layout import { ProfileSettingsLayout } from "layouts/settings-layout"; import { ProfilePreferenceSettingsSidebar } from "./sidebar"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { CustomMenu } from "@plane/ui"; +import { ChevronDown } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; interface IProfilePreferenceSettingsLayout { children: ReactNode; @@ -10,9 +15,57 @@ interface IProfilePreferenceSettingsLayout { export const ProfilePreferenceSettingsLayout: FC = (props) => { const { children, header } = props; + const router = useRouter(); + + const showMenuItem = () => { + const item = router.asPath.split('/'); + let splittedItem = item[item.length - 1]; + splittedItem = splittedItem.replace(splittedItem[0], splittedItem[0].toUpperCase()); + console.log(splittedItem); + return splittedItem; + } + + const profilePreferenceLinks: Array<{ + label: string; + href: string; + }> = [ + { + label: "Theme", + href: `/profile/preferences/theme`, + }, + { + label: "Email", + href: `/profile/preferences/email`, + }, + ]; return ( - + + + + {showMenuItem()} + +
    + } + customButtonClassName="flex flex-grow justify-start text-custom-text-200 text-sm" + > + <> + {profilePreferenceLinks.map((link) => ( + + {link.label} + + ))} + +
    + }>
    diff --git a/web/layouts/settings-layout/profile/preferences/sidebar.tsx b/web/layouts/settings-layout/profile/preferences/sidebar.tsx index d1eec1233..7f43f3cad 100644 --- a/web/layouts/settings-layout/profile/preferences/sidebar.tsx +++ b/web/layouts/settings-layout/profile/preferences/sidebar.tsx @@ -9,28 +9,27 @@ export const ProfilePreferenceSettingsSidebar = () => { label: string; href: string; }> = [ - { - label: "Theme", - href: `/profile/preferences/theme`, - }, - { - label: "Email", - href: `/profile/preferences/email`, - }, - ]; + { + label: "Theme", + href: `/profile/preferences/theme`, + }, + { + label: "Email", + href: `/profile/preferences/email`, + }, + ]; return ( -
    +
    Preference
    {profilePreferenceLinks.map((link) => (
    {link.label}
    diff --git a/web/layouts/settings-layout/profile/sidebar.tsx b/web/layouts/settings-layout/profile/sidebar.tsx index 0a97b3364..4b8a1b854 100644 --- a/web/layouts/settings-layout/profile/sidebar.tsx +++ b/web/layouts/settings-layout/profile/sidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { mutate } from "swr"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -12,6 +12,7 @@ import useToast from "hooks/use-toast"; import { Tooltip } from "@plane/ui"; // constants import { PROFILE_ACTION_LINKS } from "constants/profile"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; const WORKSPACE_ACTION_LINKS = [ { @@ -52,6 +53,35 @@ export const ProfileLayoutSidebar = observer(() => { currentUserSettings?.workspace?.fallback_workspace_slug || ""; + const ref = useRef(null); + + useOutsideClickDetector(ref, () => { + if (sidebarCollapsed === false) { + if (window.innerWidth < 768) { + toggleSidebar(); + } + } + }); + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth <= 768) { + toggleSidebar(true); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [toggleSidebar]); + + const handleItemClick = () => { + if (window.innerWidth < 768) { + toggleSidebar(); + } + }; + const handleSignOut = async () => { setIsSigningOut(true); @@ -73,16 +103,18 @@ export const ProfileLayoutSidebar = observer(() => { return (
    -
    +
    @@ -101,14 +133,13 @@ export const ProfileLayoutSidebar = observer(() => { if (link.key === "change-password" && currentUser?.is_password_autoset) return null; return ( - +
    {} {!sidebarCollapsed && link.label} @@ -129,19 +160,17 @@ export const ProfileLayoutSidebar = observer(() => { {workspace?.logo && workspace.logo !== "" ? ( { )}
    {WORKSPACE_ACTION_LINKS.map((link) => ( - +
    {} {!sidebarCollapsed && link.label} @@ -180,9 +208,8 @@ export const ProfileLayoutSidebar = observer(() => {
    +
    ); }); diff --git a/web/pages/profile/index.tsx b/web/pages/profile/index.tsx index 294ef3574..655d6a4bd 100644 --- a/web/pages/profile/index.tsx +++ b/web/pages/profile/index.tsx @@ -23,6 +23,7 @@ import type { NextPageWithLayout } from "lib/types"; // constants import { USER_ROLES } from "constants/workspace"; import { TIME_ZONES } from "constants/timezones"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; const defaultValues: Partial = { avatar: "", @@ -56,7 +57,7 @@ const ProfileSettingsPage: NextPageWithLayout = observer(() => { // store hooks const { currentUser: myProfile, updateCurrentUser, currentUserLoader } = useUser(); // custom hooks - const {} = useUserAuth({ user: myProfile, isLoading: currentUserLoader }); + const { } = useUserAuth({ user: myProfile, isLoading: currentUserLoader }); useEffect(() => { reset({ ...defaultValues, ...myProfile }); @@ -136,304 +137,310 @@ const ProfileSettingsPage: NextPageWithLayout = observer(() => { return ( <> - ( - setIsImageUploadModalOpen(false)} - isRemoving={isRemoving} - handleDelete={() => handleDelete(myProfile?.avatar, true)} - onSuccess={(url) => { - onChange(url); - handleSubmit(onSubmit)(); - setIsImageUploadModalOpen(false); - }} - value={value && value.trim() !== "" ? value : null} - /> - )} - /> - setDeactivateAccountModal(false)} /> -
    -
    -
    -
    - {myProfile?.first_name +
    + +
    +
    + ( + setIsImageUploadModalOpen(false)} + isRemoving={isRemoving} + handleDelete={() => handleDelete(myProfile?.avatar, true)} + onSuccess={(url) => { + onChange(url); + handleSubmit(onSubmit)(); + setIsImageUploadModalOpen(false); + }} + value={value && value.trim() !== "" ? value : null} /> -
    -
    -
    - +
    +
    +
    + +
    + ( + onChange(imageUrl)} + control={control} + value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"} + /> )} - + />
    -
    -
    - ( - onChange(imageUrl)} - control={control} - value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"} - /> - )} - /> -
    -
    +
    +
    +
    + {`${watch("first_name")} ${watch("last_name")}`} +
    + {watch("email")} +
    -
    -
    -
    - {`${watch("first_name")} ${watch("last_name")}`} -
    - {watch("email")} -
    - - {/* + {/* Activity Overview */} -
    +
    -
    -
    -

    - First name* -

    - ( - +
    +

    + First name* +

    + ( + + )} /> - )} - /> - {errors.first_name && Please enter first name} -
    - -
    -

    Last name

    - - ( - - )} - /> -
    - -
    -

    - Email* -

    - ( - - )} - /> -
    - -
    -

    - Role* -

    - ( - - {USER_ROLES.map((item) => ( - - {item.label} - - ))} - - )} - /> - {errors.role && Please select a role} -
    - -
    -

    - Display name* -

    - { - if (value.trim().length < 1) return "Display name can't be empty."; - - if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; - - if (value.replace(/\s/g, "").length < 1) - return "Display name must be at least 1 characters long."; - - if (value.replace(/\s/g, "").length > 20) - return "Display name must be less than 20 characters long."; - - return true; - }, - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> - {errors.display_name && Please enter display name} -
    - -
    -

    - Timezone* -

    - - ( - t.value === value)?.label ?? value : "Select a timezone"} - options={timeZoneOptions} - onChange={onChange} - optionsClassName="w-full" - buttonClassName={errors.user_timezone ? "border-red-500" : "border-none"} - className="rounded-md border-[0.5px] !border-custom-border-200" - input - /> - )} - /> - {errors.role && Please select a time zone} -
    - -
    - -
    -
    -
    - - - {({ open }) => ( - <> - - Deactivate account - - - - -
    - - The danger zone of the profile page is a critical area that requires careful consideration and - attention. When deactivating an account, all of the data and resources within that account will be - permanently removed and cannot be recovered. - -
    - -
    + {errors.first_name && Please enter first name}
    -
    -
    - - )} -
    + +
    +

    Last name

    + + ( + + )} + /> +
    + +
    +

    + Email* +

    + ( + + )} + /> +
    + +
    +

    + Role* +

    + ( + + {USER_ROLES.map((item) => ( + + {item.label} + + ))} + + )} + /> + {errors.role && Please select a role} +
    + +
    +

    + Display name* +

    + { + if (value.trim().length < 1) return "Display name can't be empty."; + + if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; + + if (value.replace(/\s/g, "").length < 1) + return "Display name must be at least 1 characters long."; + + if (value.replace(/\s/g, "").length > 20) + return "Display name must be less than 20 characters long."; + + return true; + }, + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> + {errors.display_name && Please enter display name} +
    + +
    +

    + Timezone* +

    + + ( + t.value === value)?.label ?? value : "Select a timezone"} + options={timeZoneOptions} + onChange={onChange} + optionsClassName="w-full" + buttonClassName={errors.user_timezone ? "border-red-500" : "border-none"} + className="rounded-md border-[0.5px] !border-custom-border-200" + input + /> + )} + /> + {errors.role && Please select a time zone} +
    + +
    + +
    +
    +
    + + + {({ open }) => ( + <> + + Deactivate account + + + + +
    + + The danger zone of the profile page is a critical area that requires careful consideration and + attention. When deactivating an account, all of the data and resources within that account will be + permanently removed and cannot be recovered. + +
    + +
    +
    +
    +
    + + )} +
    +
    +
    ); diff --git a/web/pages/profile/preferences/theme.tsx b/web/pages/profile/preferences/theme.tsx index 51386bc29..0885ff6c8 100644 --- a/web/pages/profile/preferences/theme.tsx +++ b/web/pages/profile/preferences/theme.tsx @@ -48,7 +48,7 @@ const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => { return ( <> {currentUser ? ( -
    +

    Preferences

    diff --git a/web/store/application/theme.store.ts b/web/store/application/theme.store.ts index 1c6f792eb..7ecc0e770 100644 --- a/web/store/application/theme.store.ts +++ b/web/store/application/theme.store.ts @@ -7,15 +7,18 @@ export interface IThemeStore { // observables theme: string | null; sidebarCollapsed: boolean | undefined; + profileSidebarCollapsed: boolean | undefined; // actions toggleSidebar: (collapsed?: boolean) => void; setTheme: (theme: any) => void; + toggleProfileSidebar: (collapsed?: boolean) => void; } export class ThemeStore implements IThemeStore { // observables sidebarCollapsed: boolean | undefined = undefined; theme: string | null = null; + profileSidebarCollapsed: boolean | undefined = undefined; // root store rootStore; @@ -24,9 +27,11 @@ export class ThemeStore implements IThemeStore { // observable sidebarCollapsed: observable.ref, theme: observable.ref, + profileSidebarCollapsed: observable.ref, // action toggleSidebar: action, setTheme: action, + toggleProfileSidebar: action, // computed }); // root store @@ -46,6 +51,19 @@ export class ThemeStore implements IThemeStore { localStorage.setItem("app_sidebar_collapsed", this.sidebarCollapsed.toString()); }; + /** + * Toggle the profile sidebar collapsed state + * @param collapsed + */ + toggleProfileSidebar = (collapsed?: boolean) => { + if (collapsed === undefined) { + this.profileSidebarCollapsed = !this.profileSidebarCollapsed; + } else { + this.profileSidebarCollapsed = collapsed; + } + localStorage.setItem("profile_sidebar_collapsed", this.profileSidebarCollapsed.toString()); + }; + /** * Sets the user theme and applies it to the platform * @param _theme From e69fcd410c0848c47cc7f13d7ed1c142c4b23e6b Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Thu, 8 Feb 2024 17:53:36 +0530 Subject: [PATCH 21/30] chore: custom menu dropdown improvement (#3599) * conflict: merge conflict resolved * chore: breadcrumbs component improvement --- packages/ui/src/breadcrumbs/breadcrumbs.tsx | 2 +- packages/ui/src/dropdowns/custom-menu.tsx | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/breadcrumbs/breadcrumbs.tsx b/packages/ui/src/breadcrumbs/breadcrumbs.tsx index 0f09764ac..a2ae1d680 100644 --- a/packages/ui/src/breadcrumbs/breadcrumbs.tsx +++ b/packages/ui/src/breadcrumbs/breadcrumbs.tsx @@ -10,7 +10,7 @@ type BreadcrumbsProps = { const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
    {React.Children.map(children, (child, index) => ( -
    +
    {child} {index !== React.Children.count(children) - 1 && (
    diff --git a/web/components/dashboard/widgets/issue-panels/tabs-list.tsx b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx index 9ce00a03c..306c2fdeb 100644 --- a/web/components/dashboard/widgets/issue-panels/tabs-list.tsx +++ b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx @@ -16,42 +16,40 @@ export const TabsList: React.FC = observer((props) => { const { durationFilter, selectedTab } = props; const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; - const selectedTabIndex = tabsList.findIndex((tab) => tab.key === (selectedTab ?? "pending")); + const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); return (
    {tabsList.map((tab) => ( diff --git a/web/components/dashboard/widgets/issues-by-priority.tsx b/web/components/dashboard/widgets/issues-by-priority.tsx index 97884bccc..91e321b05 100644 --- a/web/components/dashboard/widgets/issues-by-priority.tsx +++ b/web/components/dashboard/widgets/issues-by-priority.tsx @@ -73,8 +73,10 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => const { dashboardId, workspaceSlug } = props; // store hooks const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); + // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + const selectedDuration = widgetDetails?.widget_filters.duration ?? "none"; const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -84,7 +86,7 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => filters, }); - const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none"); + const filterDates = getCustomDates(filters.duration ?? selectedDuration); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -92,7 +94,7 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => }; useEffect(() => { - const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none"); + const filterDates = getCustomDates(selectedDuration); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -139,10 +141,10 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => Assigned by priority handleUpdateFilters({ - target_date: val, + duration: val, }) } /> diff --git a/web/components/dashboard/widgets/issues-by-state-group.tsx b/web/components/dashboard/widgets/issues-by-state-group.tsx index 2f7f6ffae..a0eb6c70f 100644 --- a/web/components/dashboard/widgets/issues-by-state-group.tsx +++ b/web/components/dashboard/widgets/issues-by-state-group.tsx @@ -34,6 +34,7 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + const selectedDuration = widgetDetails?.widget_filters.duration ?? "none"; const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -43,7 +44,7 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) filters, }); - const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none"); + const filterDates = getCustomDates(filters.duration ?? selectedDuration); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -52,7 +53,7 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) // fetch widget stats useEffect(() => { - const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none"); + const filterDates = getCustomDates(selectedDuration); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -138,10 +139,10 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) Assigned by state handleUpdateFilters({ - target_date: val, + duration: val, }) } /> diff --git a/web/helpers/dashboard.helper.ts b/web/helpers/dashboard.helper.ts index 8003f15e3..90319a90b 100644 --- a/web/helpers/dashboard.helper.ts +++ b/web/helpers/dashboard.helper.ts @@ -4,6 +4,10 @@ import { renderFormattedPayloadDate } from "./date-time.helper"; // types import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types"; +/** + * @description returns date range based on the duration filter + * @param duration + */ export const getCustomDates = (duration: TDurationFilterOptions): string => { const today = new Date(); let firstDay, lastDay; @@ -30,6 +34,10 @@ export const getCustomDates = (duration: TDurationFilterOptions): string => { } }; +/** + * @description returns redirection filters for the issues list + * @param type + */ export const getRedirectionFilters = (type: TIssuesListTypes): string => { const today = renderFormattedPayloadDate(new Date()); @@ -44,3 +52,20 @@ export const getRedirectionFilters = (type: TIssuesListTypes): string => { return filterParams; }; + +/** + * @description returns the tab key based on the duration filter + * @param duration + * @param tab + */ +export const getTabKey = (duration: TDurationFilterOptions, tab: TIssuesListTypes | undefined): TIssuesListTypes => { + if (!tab) return "completed"; + + if (tab === "completed") return tab; + + if (duration === "none") return "pending"; + else { + if (["upcoming", "overdue"].includes(tab)) return tab; + else return "upcoming"; + } +}; From 3a14f19c993933415f79e98648877dbf995cd098 Mon Sep 17 00:00:00 2001 From: Ramesh Kumar Chandra <31303617+rameshkumarchandra@users.noreply.github.com> Date: Fri, 9 Feb 2024 15:52:25 +0530 Subject: [PATCH 24/30] style: responsive analytics (#3604) --- .../custom-analytics/custom-analytics.tsx | 28 +++++++---- .../analytics/custom-analytics/select-bar.tsx | 5 +- .../sidebar/projects-list.tsx | 4 +- .../sidebar/sidebar-header.tsx | 6 +-- .../custom-analytics/sidebar/sidebar.tsx | 46 +++++++++---------- .../analytics/project-modal/main-content.tsx | 7 ++- web/components/headers/user-profile.tsx | 2 +- .../headers/workspace-analytics.tsx | 35 ++++++++++++-- web/components/profile/sidebar.tsx | 16 +++---- web/pages/[workspaceSlug]/analytics.tsx | 15 ++++-- web/store/application/theme.store.ts | 18 ++++++++ 11 files changed, 118 insertions(+), 64 deletions(-) diff --git a/web/components/analytics/custom-analytics/custom-analytics.tsx b/web/components/analytics/custom-analytics/custom-analytics.tsx index a3c083b02..0c3ec8925 100644 --- a/web/components/analytics/custom-analytics/custom-analytics.tsx +++ b/web/components/analytics/custom-analytics/custom-analytics.tsx @@ -10,6 +10,8 @@ import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSi import { IAnalyticsParams } from "@plane/types"; // fetch-keys import { ANALYTICS } from "constants/fetch-keys"; +import { cn } from "helpers/common.helper"; +import { useApplication } from "hooks/store"; type Props = { additionalParams?: Partial; @@ -46,11 +48,13 @@ export const CustomAnalytics: React.FC = observer((props) => { workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null ); + const { theme: themeStore } = useApplication(); + const isProjectLevel = projectId ? true : false; return ( -
    -
    +
    +
    = observer((props) => {
    - + +
    + +
    ); }); diff --git a/web/components/analytics/custom-analytics/select-bar.tsx b/web/components/analytics/custom-analytics/select-bar.tsx index 19f83e40b..31acb8471 100644 --- a/web/components/analytics/custom-analytics/select-bar.tsx +++ b/web/components/analytics/custom-analytics/select-bar.tsx @@ -22,9 +22,8 @@ export const CustomAnalyticsSelectBar: React.FC = observer((props) => { return (
    {!isProjectLevel && (
    diff --git a/web/components/analytics/custom-analytics/sidebar/projects-list.tsx b/web/components/analytics/custom-analytics/sidebar/projects-list.tsx index d09e8def4..f7ba07b75 100644 --- a/web/components/analytics/custom-analytics/sidebar/projects-list.tsx +++ b/web/components/analytics/custom-analytics/sidebar/projects-list.tsx @@ -17,9 +17,9 @@ export const CustomAnalyticsSidebarProjectsList: React.FC = observer((pro const { getProjectById } = useProject(); return ( -
    +

    Selected Projects

    -
    +
    {projectIds.map((projectId) => { const project = getProjectById(projectId); diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx index 4a18011d1..ee677fe91 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx @@ -26,7 +26,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => { <> {projectId ? ( cycleDetails ? ( -
    +

    Analytics for {cycleDetails.name}

    @@ -52,7 +52,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
    ) : moduleDetails ? ( -
    +

    Analytics for {moduleDetails.name}

    @@ -78,7 +78,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
    ) : ( -
    +
    {projectDetails?.emoji ? (
    {renderEmoji(projectDetails.emoji)}
    diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx index 59013a3e3..c2e12dc3c 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { mutate } from "swr"; @@ -19,18 +19,18 @@ import { renderFormattedDate } from "helpers/date-time.helper"; import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types"; // fetch-keys import { ANALYTICS } from "constants/fetch-keys"; +import { cn } from "helpers/common.helper"; type Props = { analytics: IAnalyticsResponse | undefined; params: IAnalyticsParams; - fullScreen: boolean; isProjectLevel: boolean; }; const analyticsService = new AnalyticsService(); export const CustomAnalyticsSidebar: React.FC = observer((props) => { - const { analytics, params, fullScreen, isProjectLevel = false } = props; + const { analytics, params, isProjectLevel = false } = props; // router const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; @@ -138,18 +138,14 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds; + return ( -
    - {analytics ? analytics.total : "..."} Issues + {analytics ? analytics.total : "..."}
    Issues
    {isProjectLevel && (
    @@ -158,36 +154,36 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { (cycleId ? cycleDetails?.created_at : moduleId - ? moduleDetails?.created_at - : projectDetails?.created_at) ?? "" + ? moduleDetails?.created_at + : projectDetails?.created_at) ?? "" )}
    )}
    -
    - {fullScreen ? ( - <> - {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( - - )} - - - ) : null} + +
    + <> + {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( + + )} + +
    -
    + +
    diff --git a/web/components/analytics/project-modal/main-content.tsx b/web/components/analytics/project-modal/main-content.tsx index 09423e6dd..a04a43260 100644 --- a/web/components/analytics/project-modal/main-content.tsx +++ b/web/components/analytics/project-modal/main-content.tsx @@ -20,16 +20,15 @@ export const ProjectAnalyticsModalMainContent: React.FC = observer((props return ( - + {ANALYTICS_TABS.map((tab) => ( - `rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${ - selected ? "bg-custom-background-80" : "" + `rounded-0 w-full md:w-max md:rounded-3xl border-b md:border border-custom-border-200 focus:outline-none px-0 md:px-4 py-2 text-xs hover:bg-custom-background-80 ${selected ? "border-custom-primary-100 text-custom-primary-100 md:bg-custom-background-80 md:text-custom-text-200 md:border-custom-border-200" : "border-transparent" }` } - onClick={() => {}} + onClick={() => { }} > {tab.title} diff --git a/web/components/headers/user-profile.tsx b/web/components/headers/user-profile.tsx index f933d4dfa..30bc5b2a9 100644 --- a/web/components/headers/user-profile.tsx +++ b/web/components/headers/user-profile.tsx @@ -64,7 +64,7 @@ export const UserProfileHeader: FC = observer((props) => { ))} - + }
    ); -}; +}); diff --git a/web/components/profile/sidebar.tsx b/web/components/profile/sidebar.tsx index b356b5adb..107c1f528 100644 --- a/web/components/profile/sidebar.tsx +++ b/web/components/profile/sidebar.tsx @@ -30,7 +30,7 @@ export const ProfileSidebar = observer(() => { const { workspaceSlug, userId } = router.query; // store hooks const { currentUser } = useUser(); - const { theme: themStore } = useApplication(); + const { theme: themeStore } = useApplication(); const ref = useRef(null); const { data: userProjectsData } = useSWR( @@ -41,9 +41,9 @@ export const ProfileSidebar = observer(() => { ); useOutsideClickDetector(ref, () => { - if (themStore.profileSidebarCollapsed === false) { + if (themeStore.profileSidebarCollapsed === false) { if (window.innerWidth < 768) { - themStore.toggleProfileSidebar(); + themeStore.toggleProfileSidebar(); } } }); @@ -62,22 +62,22 @@ export const ProfileSidebar = observer(() => { useEffect(() => { const handleToggleProfileSidebar = () => { if (window && window.innerWidth < 768) { - themStore.toggleProfileSidebar(true); + themeStore.toggleProfileSidebar(true); } - if (window && themStore.profileSidebarCollapsed && window.innerWidth >= 768) { - themStore.toggleProfileSidebar(false); + if (window && themeStore.profileSidebarCollapsed && window.innerWidth >= 768) { + themeStore.toggleProfileSidebar(false); } }; window.addEventListener("resize", handleToggleProfileSidebar); handleToggleProfileSidebar(); return () => window.removeEventListener("resize", handleToggleProfileSidebar); - }, [themStore]); + }, [themeStore]); return (
    {userProjectsData ? ( <> diff --git a/web/pages/[workspaceSlug]/analytics.tsx b/web/pages/[workspaceSlug]/analytics.tsx index 71173c2c2..d4cb28e6f 100644 --- a/web/pages/[workspaceSlug]/analytics.tsx +++ b/web/pages/[workspaceSlug]/analytics.tsx @@ -15,8 +15,11 @@ import { ANALYTICS_TABS } from "constants/analytics"; import { EUserWorkspaceRoles } from "constants/workspace"; // type import { NextPageWithLayout } from "lib/types"; +import { useRouter } from "next/router"; const AnalyticsPage: NextPageWithLayout = observer(() => { + const router = useRouter() + const { analytics_tab } = router.query // theme const { resolvedTheme } = useTheme(); // store hooks @@ -38,17 +41,19 @@ const AnalyticsPage: NextPageWithLayout = observer(() => { <> {workspaceProjectIds && workspaceProjectIds.length > 0 ? (
    - - + + {ANALYTICS_TABS.map((tab) => ( - `rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${ - selected ? "bg-custom-background-80" : "" + `rounded-0 w-full md:w-max md:rounded-3xl border-b md:border border-custom-border-200 focus:outline-none px-0 md:px-4 py-2 text-xs hover:bg-custom-background-80 ${selected ? "border-custom-primary-100 text-custom-primary-100 md:bg-custom-background-80 md:text-custom-text-200 md:border-custom-border-200" : "border-transparent" }` } - onClick={() => {}} + onClick={() => { + router.query.analytics_tab = tab.key + router.push(router) + }} > {tab.title} diff --git a/web/store/application/theme.store.ts b/web/store/application/theme.store.ts index 7ecc0e770..f264c175d 100644 --- a/web/store/application/theme.store.ts +++ b/web/store/application/theme.store.ts @@ -8,10 +8,12 @@ export interface IThemeStore { theme: string | null; sidebarCollapsed: boolean | undefined; profileSidebarCollapsed: boolean | undefined; + workspaceAnalyticsSidebarCollapsed: boolean | undefined; // actions toggleSidebar: (collapsed?: boolean) => void; setTheme: (theme: any) => void; toggleProfileSidebar: (collapsed?: boolean) => void; + toggleWorkspaceAnalyticsSidebar: (collapsed?: boolean) => void; } export class ThemeStore implements IThemeStore { @@ -19,6 +21,7 @@ export class ThemeStore implements IThemeStore { sidebarCollapsed: boolean | undefined = undefined; theme: string | null = null; profileSidebarCollapsed: boolean | undefined = undefined; + workspaceAnalyticsSidebarCollapsed: boolean | undefined = undefined; // root store rootStore; @@ -28,10 +31,12 @@ export class ThemeStore implements IThemeStore { sidebarCollapsed: observable.ref, theme: observable.ref, profileSidebarCollapsed: observable.ref, + workspaceAnalyticsSidebarCollapsed: observable.ref, // action toggleSidebar: action, setTheme: action, toggleProfileSidebar: action, + toggleWorkspaceAnalyticsSidebar: action // computed }); // root store @@ -64,6 +69,19 @@ export class ThemeStore implements IThemeStore { localStorage.setItem("profile_sidebar_collapsed", this.profileSidebarCollapsed.toString()); }; + /** + * Toggle the profile sidebar collapsed state + * @param collapsed + */ + toggleWorkspaceAnalyticsSidebar = (collapsed?: boolean) => { + if (collapsed === undefined) { + this.workspaceAnalyticsSidebarCollapsed = !this.workspaceAnalyticsSidebarCollapsed; + } else { + this.workspaceAnalyticsSidebarCollapsed = collapsed; + } + localStorage.setItem("workspace_analytics_sidebar_collapsed", this.workspaceAnalyticsSidebarCollapsed.toString()); + }; + /** * Sets the user theme and applies it to the platform * @param _theme From e2affc3fa66e93d015c486deae05e2ef27f78d85 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Fri, 9 Feb 2024 15:53:15 +0530 Subject: [PATCH 25/30] chore: virtualization ish behaviour for issue layouts (#3538) * Virtualization like core changes with intersection observer * Virtualization like changes for spreadsheet * Virtualization like changes for list * Virtualization like changes for kanban * add logic to render all the issues at once * revert back the changes for list to follow the old pattern of grouping * fix column shadow in spreadsheet for rendering rows * fix constant draggable height while dragging and rendering blocks in kanban * fix height glitch while rendered rows adjust to default height * remove loading animation for issue layouts * reduce requestIdleCallback timer to 300ms * remove logic for index tarcking to force render as the same effect seems to be achieved by removing requestIdleCallback * Fix Kanban droppable height * fix spreadsheet sub issue loading * force change in reference to re render the render if visible component when the order of list changes * add comments and minor changes --- packages/types/src/issues.d.ts | 9 + web/components/core/render-if-visible-HOC.tsx | 80 ++++++ .../issue-layouts/kanban/base-kanban-root.tsx | 11 +- .../issues/issue-layouts/kanban/block.tsx | 38 ++- .../issue-layouts/kanban/blocks-list.tsx | 9 +- .../issues/issue-layouts/kanban/default.tsx | 19 +- .../issue-layouts/kanban/kanban-group.tsx | 7 + .../issues/issue-layouts/kanban/swimlanes.tsx | 9 + .../issue-layouts/list/base-list-root.tsx | 40 ++- .../issues/issue-layouts/list/block.tsx | 97 ++++--- .../issues/issue-layouts/list/blocks-list.tsx | 32 ++- .../issues/issue-layouts/list/default.tsx | 23 +- .../issue-layouts/spreadsheet/issue-row.tsx | 237 +++++++++++------- .../spreadsheet/spreadsheet-table.tsx | 42 ++++ .../spreadsheet/spreadsheet-view.tsx | 38 +-- web/components/issues/issue-layouts/utils.tsx | 4 +- web/constants/issue.ts | 9 +- 17 files changed, 467 insertions(+), 237 deletions(-) create mode 100644 web/components/core/render-if-visible-HOC.tsx diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index c54943f90..1f4a35dd4 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -221,3 +221,12 @@ export interface IGroupByColumn { export interface IIssueMap { [key: string]: TIssue; } + +export interface IIssueListRow { + id: string; + groupId: string; + type: "HEADER" | "NO_ISSUES" | "QUICK_ADD" | "ISSUE"; + name?: string; + icon?: ReactElement | undefined; + payload?: Partial; +} diff --git a/web/components/core/render-if-visible-HOC.tsx b/web/components/core/render-if-visible-HOC.tsx new file mode 100644 index 000000000..26ae15285 --- /dev/null +++ b/web/components/core/render-if-visible-HOC.tsx @@ -0,0 +1,80 @@ +import { cn } from "helpers/common.helper"; +import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react"; + +type Props = { + defaultHeight?: string; + verticalOffset?: number; + horizonatlOffset?: number; + root?: MutableRefObject; + children: ReactNode; + as?: keyof JSX.IntrinsicElements; + classNames?: string; + alwaysRender?: boolean; + placeholderChildren?: ReactNode; + pauseHeightUpdateWhileRendering?: boolean; + changingReference?: any; +}; + +const RenderIfVisible: React.FC = (props) => { + const { + defaultHeight = "300px", + root, + verticalOffset = 50, + horizonatlOffset = 0, + as = "div", + children, + classNames = "", + alwaysRender = false, //render the children even if it is not visble in root + placeholderChildren = null, //placeholder children + pauseHeightUpdateWhileRendering = false, //while this is true the height of the blocks are maintained + changingReference, //This is to force render when this reference is changed + } = props; + const [shouldVisible, setShouldVisible] = useState(alwaysRender); + const placeholderHeight = useRef(defaultHeight); + const intersectionRef = useRef(null); + + const isVisible = alwaysRender || shouldVisible; + + // Set visibility with intersection observer + useEffect(() => { + if (intersectionRef.current) { + const observer = new IntersectionObserver( + (entries) => { + if (typeof window !== undefined && window.requestIdleCallback) { + window.requestIdleCallback(() => setShouldVisible(entries[0].isIntersecting), { + timeout: 300, + }); + } else { + setShouldVisible(entries[0].isIntersecting); + } + }, + { + root: root?.current, + rootMargin: `${verticalOffset}% ${horizonatlOffset}% ${verticalOffset}% ${horizonatlOffset}%`, + } + ); + observer.observe(intersectionRef.current); + return () => { + if (intersectionRef.current) { + observer.unobserve(intersectionRef.current); + } + }; + } + }, [root?.current, intersectionRef, children, changingReference]); + + //Set height after render + useEffect(() => { + if (intersectionRef.current && isVisible) { + placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`; + } + }, [isVisible, intersectionRef, alwaysRender, pauseHeightUpdateWhileRendering]); + + const child = isVisible ? <>{children} : placeholderChildren; + const style = + isVisible && !pauseHeightUpdateWhileRendering ? {} : { height: placeholderHeight.current, width: "100%" }; + const className = isVisible ? classNames : cn(classNames, "bg-custom-background-80"); + + return React.createElement(as, { ref: intersectionRef, style, className }, child); +}; + +export default RenderIfVisible; diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 64b132267..36d5e0315 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useState } from "react"; +import { FC, useCallback, useRef, useState } from "react"; import { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; @@ -94,6 +94,8 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; + const scrollableContainerRef = useRef(null); + // states const [isDragStarted, setIsDragStarted] = useState(false); const [dragState, setDragState] = useState({}); @@ -245,7 +247,10 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas
    )} -
    +
    {/* drag and delete component */} @@ -289,6 +294,8 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas canEditProperties={canEditProperties} storeType={storeType} addIssuesToView={addIssuesToView} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} />
    diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 203ac4938..24cbe9908 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { MutableRefObject, memo } from "react"; import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; import { observer } from "mobx-react-lite"; // hooks @@ -13,6 +13,7 @@ import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; // helper import { cn } from "helpers/common.helper"; +import RenderIfVisible from "components/core/render-if-visible-HOC"; interface IssueBlockProps { peekIssueId?: string; @@ -25,6 +26,9 @@ interface IssueBlockProps { handleIssues: (issue: TIssue, action: EIssueActions) => void; quickActions: (issue: TIssue) => React.ReactNode; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; + issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization } interface IssueDetailsBlockProps { @@ -107,6 +111,9 @@ export const KanbanIssueBlock: React.FC = memo((props) => { handleIssues, quickActions, canEditProperties, + scrollableContainerRef, + isDragStarted, + issueIds, } = props; const issue = issuesMap[issueId]; @@ -129,24 +136,31 @@ export const KanbanIssueBlock: React.FC = memo((props) => { {...provided.dragHandleProps} ref={provided.innerRef} > - {issue.tempId !== undefined && ( -
    - )}
    - + + +
    )} diff --git a/web/components/issues/issue-layouts/kanban/blocks-list.tsx b/web/components/issues/issue-layouts/kanban/blocks-list.tsx index 15c797833..3746111e5 100644 --- a/web/components/issues/issue-layouts/kanban/blocks-list.tsx +++ b/web/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { MutableRefObject, memo } from "react"; //types import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; @@ -16,6 +16,8 @@ interface IssueBlocksListProps { handleIssues: (issue: TIssue, action: EIssueActions) => void; quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } const KanbanIssueBlocksListMemo: React.FC = (props) => { @@ -30,6 +32,8 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { handleIssues, quickActions, canEditProperties, + scrollableContainerRef, + isDragStarted, } = props; return ( @@ -56,6 +60,9 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { index={index} isDragDisabled={isDragDisabled} canEditProperties={canEditProperties} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} + issueIds={issueIds} //passing to force render for virtualization whenever parent rerenders /> ); })} diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index de6c1ddae..f11321944 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -20,6 +20,7 @@ import { import { EIssueActions } from "../types"; import { getGroupByColumns } from "../utils"; import { TCreateModalStoreTypes } from "constants/issue"; +import { MutableRefObject } from "react"; export interface IGroupByKanBan { issuesMap: IIssueMap; @@ -45,6 +46,8 @@ export interface IGroupByKanBan { storeType?: TCreateModalStoreTypes; addIssuesToView?: (issueIds: string[]) => Promise; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } const GroupByKanBan: React.FC = observer((props) => { @@ -67,6 +70,8 @@ const GroupByKanBan: React.FC = observer((props) => { storeType, addIssuesToView, canEditProperties, + scrollableContainerRef, + isDragStarted, } = props; const member = useMember(); @@ -92,11 +97,7 @@ const GroupByKanBan: React.FC = observer((props) => { const groupByVisibilityToggle = visibilityGroupBy(_list); return ( -
    +
    {sub_group_by === null && (
    = observer((props) => { disableIssueCreation={disableIssueCreation} canEditProperties={canEditProperties} groupByVisibilityToggle={groupByVisibilityToggle} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} /> )}
    @@ -168,6 +171,8 @@ export interface IKanBan { storeType?: TCreateModalStoreTypes; addIssuesToView?: (issueIds: string[]) => Promise; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } export const KanBan: React.FC = observer((props) => { @@ -189,6 +194,8 @@ export const KanBan: React.FC = observer((props) => { storeType, addIssuesToView, canEditProperties, + scrollableContainerRef, + isDragStarted, } = props; const issueKanBanView = useKanbanView(); @@ -213,6 +220,8 @@ export const KanBan: React.FC = observer((props) => { storeType={storeType} addIssuesToView={addIssuesToView} canEditProperties={canEditProperties} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} /> ); }); diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index 1a25c563e..7cbda05e1 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -1,3 +1,4 @@ +import { MutableRefObject } from "react"; import { Droppable } from "@hello-pangea/dnd"; // hooks import { useProjectState } from "hooks/store"; @@ -37,6 +38,8 @@ interface IKanbanGroup { disableIssueCreation?: boolean; canEditProperties: (projectId: string | undefined) => boolean; groupByVisibilityToggle: boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } export const KanbanGroup = (props: IKanbanGroup) => { @@ -57,6 +60,8 @@ export const KanbanGroup = (props: IKanbanGroup) => { disableIssueCreation, quickAddCallback, viewId, + scrollableContainerRef, + isDragStarted, } = props; // hooks const projectState = useProjectState(); @@ -127,6 +132,8 @@ export const KanbanGroup = (props: IKanbanGroup) => { handleIssues={handleIssues} quickActions={quickActions} canEditProperties={canEditProperties} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} /> {provided.placeholder} diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index 1b9f27828..5fdb58ef0 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -1,3 +1,4 @@ +import { MutableRefObject } from "react"; import { observer } from "mobx-react-lite"; // components import { KanBan } from "./default"; @@ -80,6 +81,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { viewId?: string ) => Promise; viewId?: string; + scrollableContainerRef?: MutableRefObject; } const SubGroupSwimlane: React.FC = observer((props) => { const { @@ -99,6 +101,8 @@ const SubGroupSwimlane: React.FC = observer((props) => { addIssuesToView, quickAddCallback, viewId, + scrollableContainerRef, + isDragStarted, } = props; const calculateIssueCount = (column_id: string) => { @@ -150,6 +154,8 @@ const SubGroupSwimlane: React.FC = observer((props) => { addIssuesToView={addIssuesToView} quickAddCallback={quickAddCallback} viewId={viewId} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} />
    )} @@ -183,6 +189,7 @@ export interface IKanBanSwimLanes { ) => Promise; viewId?: string; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; } export const KanBanSwimLanes: React.FC = observer((props) => { @@ -204,6 +211,7 @@ export const KanBanSwimLanes: React.FC = observer((props) => { addIssuesToView, quickAddCallback, viewId, + scrollableContainerRef, } = props; const member = useMember(); @@ -249,6 +257,7 @@ export const KanBanSwimLanes: React.FC = observer((props) => { canEditProperties={canEditProperties} quickAddCallback={quickAddCallback} viewId={viewId} + scrollableContainerRef={scrollableContainerRef} /> )}
    diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index 8f661a9e6..b1441cff7 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -122,26 +122,24 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { ); return ( - <> -
    - -
    - +
    + +
    ); }); diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 1ade285a9..ceec7b219 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -48,64 +48,59 @@ export const IssueBlock: React.FC = observer((props: IssueBlock const projectDetails = getProjectById(issue.project_id); return ( - <> -
    - {displayProperties && displayProperties?.key && ( -
    - {projectDetails?.identifier}-{issue.sequence_id} -
    - )} + "last:border-b-transparent": peekIssue?.issueId !== issue.id + })} + > + {displayProperties && displayProperties?.key && ( +
    + {projectDetails?.identifier}-{issue.sequence_id} +
    + )} - {issue?.tempId !== undefined && ( -
    - )} + {issue?.tempId !== undefined && ( +
    + )} - {issue?.is_draft ? ( + {issue?.is_draft ? ( + + {issue.name} + + ) : ( + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > {issue.name} - ) : ( - handleIssuePeekOverview(issue)} - className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" - > - - {issue.name} - - - )} + + )} -
    - {!issue?.tempId ? ( - <> - - {quickActions(issue)} - - ) : ( -
    - -
    - )} -
    +
    + {!issue?.tempId ? ( + <> + + {quickActions(issue)} + + ) : ( +
    + +
    + )}
    - +
    ); }); diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index 95ee6c7a8..d3c8d1406 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -1,9 +1,10 @@ -import { FC } from "react"; +import { FC, MutableRefObject } from "react"; // components import { IssueBlock } from "components/issues"; // types import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import { EIssueActions } from "../types"; +import RenderIfVisible from "components/core/render-if-visible-HOC"; interface Props { issueIds: TGroupedIssues | TUnGroupedIssues | any; @@ -12,27 +13,34 @@ interface Props { handleIssues: (issue: TIssue, action: EIssueActions) => Promise; quickActions: (issue: TIssue) => React.ReactNode; displayProperties: IIssueDisplayProperties | undefined; + containerRef: MutableRefObject; } export const IssueBlocksList: FC = (props) => { - const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties } = props; + const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties, containerRef } = props; return (
    {issueIds && issueIds.length > 0 ? ( issueIds.map((issueId: string) => { if (!issueId) return null; - return ( - + + + ); }) ) : ( diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index dd6c8da22..373897fda 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -1,5 +1,7 @@ +import { useRef } from "react"; // components import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues"; +import { HeaderGroupByCard } from "./headers/group-by-card"; // hooks import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; // types @@ -10,12 +12,12 @@ import { IIssueDisplayProperties, TIssueMap, TUnGroupedIssues, + IGroupByColumn, } from "@plane/types"; import { EIssueActions } from "../types"; // constants -import { HeaderGroupByCard } from "./headers/group-by-card"; -import { getGroupByColumns } from "../utils"; import { TCreateModalStoreTypes } from "constants/issue"; +import { getGroupByColumns } from "../utils"; export interface IGroupByList { issueIds: TGroupedIssues | TUnGroupedIssues | any; @@ -64,9 +66,11 @@ const GroupByList: React.FC = (props) => { const label = useLabel(); const projectState = useProjectState(); - const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true); + const containerRef = useRef(null); - if (!list) return null; + const groups = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true); + + if (!groups) return null; const prePopulateQuickAddData = (groupByKey: string | null, value: any) => { const defaultState = projectState.projectStates?.find((state) => state.default); @@ -104,11 +108,11 @@ const GroupByList: React.FC = (props) => { const isGroupByCreatedBy = group_by === "created_by"; return ( -
    - {list && - list.length > 0 && - list.map( - (_list: any) => +
    + {groups && + groups.length > 0 && + groups.map( + (_list: IGroupByColumn) => validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
    @@ -131,6 +135,7 @@ const GroupByList: React.FC = (props) => { quickActions={quickActions} displayProperties={displayProperties} canEditProperties={canEditProperties} + containerRef={containerRef} /> )} diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 2a97045fe..840ea39f9 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { Dispatch, MutableRefObject, SetStateAction, useRef, useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // icons @@ -7,6 +7,7 @@ import { ChevronRight, MoreHorizontal } from "lucide-react"; import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; // components import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import RenderIfVisible from "components/core/render-if-visible-HOC"; import { IssueColumn } from "./issue-column"; // ui import { ControlLink, Tooltip } from "@plane/ui"; @@ -32,6 +33,9 @@ interface Props { portalElement: React.MutableRefObject; nestingLevel: number; issueId: string; + isScrolled: MutableRefObject; + containerRef: MutableRefObject; + issueIds: string[]; } export const SpreadsheetIssueRow = observer((props: Props) => { @@ -44,8 +48,96 @@ export const SpreadsheetIssueRow = observer((props: Props) => { handleIssues, quickActions, canEditProperties, + isScrolled, + containerRef, + issueIds, } = props; + const [isExpanded, setExpanded] = useState(false); + const { subIssues: subIssuesStore } = useIssueDetail(); + + const subIssues = subIssuesStore.subIssuesByIssueId(issueId); + + return ( + <> + {/* first column/ issue name and key column */} + } + changingReference={issueIds} + > + + + + {isExpanded && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssueId: string) => ( + + ))} + + ); +}); + +interface IssueRowDetailsProps { + displayProperties: IIssueDisplayProperties; + isEstimateEnabled: boolean; + quickActions: ( + issue: TIssue, + customActionButton?: React.ReactElement, + portalElement?: HTMLDivElement | null + ) => React.ReactNode; + canEditProperties: (projectId: string | undefined) => boolean; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; + portalElement: React.MutableRefObject; + nestingLevel: number; + issueId: string; + isScrolled: MutableRefObject; + isExpanded: boolean; + setExpanded: Dispatch>; +} + +const IssueRowDetails = observer((props: IssueRowDetailsProps) => { + const { + displayProperties, + issueId, + isEstimateEnabled, + nestingLevel, + portalElement, + handleIssues, + quickActions, + canEditProperties, + isScrolled, + isExpanded, + setExpanded, + } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -54,8 +146,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => { const { peekIssue, setPeekIssue } = useIssueDetail(); // states const [isMenuActive, setIsMenuActive] = useState(false); - const [isExpanded, setExpanded] = useState(false); - const menuActionRef = useRef(null); const handleIssuePeekOverview = (issue: TIssue) => { @@ -66,7 +156,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => { const { subIssues: subIssuesStore, issue } = useIssueDetail(); const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); const paddingLeft = `${nestingLevel * 54}px`; @@ -91,81 +180,77 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
    ); - if (!issueDetail) return null; const disableUserActions = !canEditProperties(issueDetail.project_id); return ( <> -
    - {/* first column/ issue name and key column */} - - {/* Rest of the columns */} - {SPREADSHEET_PROPERTY_LIST.map((property) => ( + + + + )} + + + handleIssuePeekOverview(issueDetail)} + className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > +
    + +
    + {issueDetail.name} +
    +
    +
    +
    + + {/* Rest of the columns */} + {SPREADSHEET_PROPERTY_LIST.map((property) => ( { isEstimateEnabled={isEstimateEnabled} /> ))} -
    - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( - - ))} ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx index e63b01dfb..5d45157cc 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -1,4 +1,5 @@ import { observer } from "mobx-react-lite"; +import { MutableRefObject, useEffect, useRef } from "react"; //types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types"; import { EIssueActions } from "../types"; @@ -21,6 +22,7 @@ type Props = { handleIssues: (issue: TIssue, action: EIssueActions) => Promise; canEditProperties: (projectId: string | undefined) => boolean; portalElement: React.MutableRefObject; + containerRef: MutableRefObject; }; export const SpreadsheetTable = observer((props: Props) => { @@ -34,8 +36,45 @@ export const SpreadsheetTable = observer((props: Props) => { quickActions, handleIssues, canEditProperties, + containerRef, } = props; + // states + const isScrolled = useRef(false); + + const handleScroll = () => { + if (!containerRef.current) return; + const scrollLeft = containerRef.current.scrollLeft; + + const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns + const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers + + //The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly + if (scrollLeft > 0 !== isScrolled.current) { + const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child"); + + for (let i = 0; i < firtColumns.length; i++) { + const shadow = i === 0 ? headerShadow : columnShadow; + if (scrollLeft > 0) { + (firtColumns[i] as HTMLElement).style.boxShadow = shadow; + } else { + (firtColumns[i] as HTMLElement).style.boxShadow = "none"; + } + } + isScrolled.current = scrollLeft > 0; + } + }; + + useEffect(() => { + const currentContainerRef = containerRef.current; + + if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll); + + return () => { + if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll); + }; + }, []); + const handleKeyBoardNavigation = useTableKeyboardNavigation(); return ( @@ -58,6 +97,9 @@ export const SpreadsheetTable = observer((props: Props) => { isEstimateEnabled={isEstimateEnabled} handleIssues={handleIssues} portalElement={portalElement} + containerRef={containerRef} + isScrolled={isScrolled} + issueIds={issueIds} /> ))} diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index e99b17850..1ac815ced 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import React, { useRef } from "react"; import { observer } from "mobx-react-lite"; // components import { Spinner } from "@plane/ui"; @@ -48,8 +48,6 @@ export const SpreadsheetView: React.FC = observer((props) => { enableQuickCreateIssue, disableIssueCreation, } = props; - // states - const isScrolled = useRef(false); // refs const containerRef = useRef(null); const portalRef = useRef(null); @@ -58,39 +56,6 @@ export const SpreadsheetView: React.FC = observer((props) => { const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null; - const handleScroll = () => { - if (!containerRef.current) return; - const scrollLeft = containerRef.current.scrollLeft; - - const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns - const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers - - //The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly - if (scrollLeft > 0 !== isScrolled.current) { - const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child"); - - for (let i = 0; i < firtColumns.length; i++) { - const shadow = i === 0 ? headerShadow : columnShadow; - if (scrollLeft > 0) { - (firtColumns[i] as HTMLElement).style.boxShadow = shadow; - } else { - (firtColumns[i] as HTMLElement).style.boxShadow = "none"; - } - } - isScrolled.current = scrollLeft > 0; - } - }; - - useEffect(() => { - const currentContainerRef = containerRef.current; - - if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll); - - return () => { - if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll); - }; - }, []); - if (!issueIds || issueIds.length === 0) return (
    @@ -112,6 +77,7 @@ export const SpreadsheetView: React.FC = observer((props) => { quickActions={quickActions} handleIssues={handleIssues} canEditProperties={canEditProperties} + containerRef={containerRef} />
    diff --git a/web/components/issues/issue-layouts/utils.tsx b/web/components/issues/issue-layouts/utils.tsx index 83ec363b9..0c3367dc1 100644 --- a/web/components/issues/issue-layouts/utils.tsx +++ b/web/components/issues/issue-layouts/utils.tsx @@ -1,10 +1,10 @@ import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui"; -import { ISSUE_PRIORITIES } from "constants/issue"; +import { EIssueListRow, ISSUE_PRIORITIES } from "constants/issue"; import { renderEmoji } from "helpers/emoji.helper"; import { IMemberRootStore } from "store/member"; import { IProjectStore } from "store/project/project.store"; import { IStateStore } from "store/state.store"; -import { GroupByColumnTypes, IGroupByColumn } from "@plane/types"; +import { GroupByColumnTypes, IGroupByColumn, IIssueListRow, TGroupedIssues, TUnGroupedIssues } from "@plane/types"; import { STATE_GROUPS } from "constants/state"; import { ILabelStore } from "store/label.store"; diff --git a/web/constants/issue.ts b/web/constants/issue.ts index 57dff280e..5b6ce8187 100644 --- a/web/constants/issue.ts +++ b/web/constants/issue.ts @@ -412,6 +412,13 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, }; +export enum EIssueListRow { + HEADER = "HEADER", + ISSUE = "ISSUE", + NO_ISSUES = "NO_ISSUES", + QUICK_ADD = "QUICK_ADD", +} + export const getValueFromObject = (object: Object, key: string): string | number | boolean | null => { const keys = key ? key.split(".") : []; @@ -442,4 +449,4 @@ export const groupReactionEmojis = (reactions: any) => { } return _groupedEmojis; -}; +}; \ No newline at end of file From 27037a2177c1a79da5f37eb8d2ae694512ea7de6 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Fri, 9 Feb 2024 15:53:54 +0530 Subject: [PATCH 26/30] feat: completed cycle snapshot (#3600) * fix: transfer cycle old distribtion captured * chore: active cycle snapshot * chore: migration file changed * chore: distribution payload changed * chore: labels and assignee structure change * chore: migration changes * chore: cycle snapshot progress payload updated * chore: cycle snapshot progress type added * chore: snapshot progress stats updated in cycle sidebar * chore: empty string validation --------- Co-authored-by: Anmol Singh Bhatia --- apiserver/plane/app/views/cycle.py | 224 +++++++++++++++++- .../0060_cycle_progress_snapshot.py | 18 ++ apiserver/plane/db/models/cycle.py | 1 + packages/types/src/cycles.d.ts | 18 ++ packages/ui/src/dropdowns/custom-menu.tsx | 4 - web/components/cycles/sidebar.tsx | 152 ++++++++---- 6 files changed, 370 insertions(+), 47 deletions(-) create mode 100644 apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 32f593e1e..63d8d28ae 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -20,6 +20,7 @@ from django.core import serializers from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework.response import Response @@ -312,6 +313,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "labels": label_distribution, "completion_chart": {}, } + if data[0]["start_date"] and data[0]["end_date"]: data[0]["distribution"][ "completion_chart" @@ -840,10 +842,230 @@ class TransferCycleIssueEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - new_cycle = Cycle.objects.get( + new_cycle = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, pk=new_cycle_id + ).first() + + old_cycle = ( + Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) ) + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + cycle_id=cycle_id, + ) + + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None, + "avatar": item["avatar"], + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + + label_distribution_data = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": str(item["label_id"]) if item["label_id"] else None, + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in label_distribution + ] + + current_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ).first() + + current_cycle.progress_snapshot = { + "total_issues": old_cycle.first().total_issues, + "completed_issues": old_cycle.first().completed_issues, + "cancelled_issues": old_cycle.first().cancelled_issues, + "started_issues": old_cycle.first().started_issues, + "unstarted_issues": old_cycle.first().unstarted_issues, + "backlog_issues": old_cycle.first().backlog_issues, + "total_estimates": old_cycle.first().total_estimates, + "completed_estimates": old_cycle.first().completed_estimates, + "started_estimates": old_cycle.first().started_estimates, + "distribution":{ + "labels": label_distribution_data, + "assignees": assignee_distribution_data, + "completion_chart": completion_chart, + }, + } + current_cycle.save(update_fields=["progress_snapshot"]) + if ( new_cycle.end_date is not None and new_cycle.end_date < timezone.now().date() diff --git a/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py b/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py new file mode 100644 index 000000000..074e20a16 --- /dev/null +++ b/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2024-02-08 09:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0059_auto_20240208_0957'), + ] + + operations = [ + migrations.AddField( + model_name='cycle', + name='progress_snapshot', + field=models.JSONField(default=dict), + ), + ] diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index 5251c68ec..d802dbc1e 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -68,6 +68,7 @@ class Cycle(ProjectBaseModel): sort_order = models.FloatField(default=65535) external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) + progress_snapshot = models.JSONField(default=dict) class Meta: verbose_name = "Cycle" diff --git a/packages/types/src/cycles.d.ts b/packages/types/src/cycles.d.ts index 12cbab4c6..5d715385a 100644 --- a/packages/types/src/cycles.d.ts +++ b/packages/types/src/cycles.d.ts @@ -31,6 +31,7 @@ export interface ICycle { issue: string; name: string; owned_by: string; + progress_snapshot: TProgressSnapshot; project: string; project_detail: IProjectLite; status: TCycleGroups; @@ -49,6 +50,23 @@ export interface ICycle { workspace_detail: IWorkspaceLite; } +export type TProgressSnapshot = { + backlog_issues: number; + cancelled_issues: number; + completed_estimates: number | null; + completed_issues: number; + distribution?: { + assignees: TAssigneesDistribution[]; + completion_chart: TCompletionChartDistribution; + labels: TLabelsDistribution[]; + }; + started_estimates: number | null; + started_issues: number; + total_estimates: number | null; + total_issues: number; + unstarted_issues: number; +}; + export type TAssigneesDistribution = { assignee_id: string | null; avatar: string | null; diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 5dd2923a8..37aba932a 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -54,10 +54,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { setIsOpen(false); }; - const handleOnChange = () => { - if (closeOnSelect) closeDropdown(); - }; - const selectActiveItem = () => { const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector( `[data-headlessui-state="active"] button` diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index 299c71008..6966779b5 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { useForm } from "react-hook-form"; import { Disclosure, Popover, Transition } from "@headlessui/react"; +import isEmpty from "lodash/isEmpty"; // services import { CycleService } from "services/cycle.service"; // hooks @@ -293,7 +294,11 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`); const progressPercentage = cycleDetails - ? Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100) + ? isCompleted + ? Math.round( + (cycleDetails.progress_snapshot.completed_issues / cycleDetails.progress_snapshot.total_issues) * 100 + ) + : Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100) : null; if (!cycleDetails) @@ -317,7 +322,14 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const issueCount = - cycleDetails.total_issues === 0 ? "0 Issue" : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + isCompleted && !isEmpty(cycleDetails.progress_snapshot) + ? cycleDetails.progress_snapshot.total_issues === 0 + ? "0 Issue" + : `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}` + : cycleDetails.total_issues === 0 + ? "0 Issue" + : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; @@ -568,49 +580,105 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
    - {cycleDetails.distribution?.completion_chart && - cycleDetails.start_date && - cycleDetails.end_date ? ( -
    -
    -
    -
    - - Ideal + {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? ( + <> + {cycleDetails.progress_snapshot.distribution?.completion_chart && + cycleDetails.start_date && + cycleDetails.end_date && ( +
    +
    +
    +
    + + Ideal +
    +
    + + Current +
    +
    +
    +
    + +
    -
    - - Current -
    -
    -
    -
    - -
    -
    + )} + ) : ( - "" + <> + {cycleDetails.distribution?.completion_chart && + cycleDetails.start_date && + cycleDetails.end_date && ( +
    +
    +
    +
    + + Ideal +
    +
    + + Current +
    +
    +
    +
    + +
    +
    + )} + )} - {cycleDetails.total_issues > 0 && cycleDetails.distribution && ( -
    - -
    + {/* stats */} + {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? ( + <> + {cycleDetails.progress_snapshot.total_issues > 0 && + cycleDetails.progress_snapshot.distribution && ( +
    + +
    + )} + + ) : ( + <> + {cycleDetails.total_issues > 0 && cycleDetails.distribution && ( +
    + +
    + )} + )}
    From 8d730e66804978430451b1fd15b180facc02be03 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Fri, 9 Feb 2024 16:14:08 +0530 Subject: [PATCH 27/30] fix: spreadsheet date validation and sorting (#3607) * fix validation for start and end date in spreadsheet layout * revamp logic for sorting in all fields --- .../spreadsheet/columns/due-date-column.tsx | 1 + .../spreadsheet/columns/start-date-column.tsx | 1 + web/store/issue/helpers/issue-helper.store.ts | 165 ++++++++++++++---- web/store/issue/root.store.ts | 39 +++-- web/store/member/workspace-member.store.ts | 8 + 5 files changed, 160 insertions(+), 54 deletions(-) diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx index c5674cee9..98262b504 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx @@ -21,6 +21,7 @@ export const SpreadsheetDueDateColumn: React.FC = observer((props: Props)
    { const targetDate = data ? renderFormattedPayloadDate(data) : null; onChange( diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx index fcbd817b6..82c00fc12 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx @@ -21,6 +21,7 @@ export const SpreadsheetStartDateColumn: React.FC = observer((props: Prop
    { const startDate = data ? renderFormattedPayloadDate(data) : null; onChange( diff --git a/web/store/issue/helpers/issue-helper.store.ts b/web/store/issue/helpers/issue-helper.store.ts index 5fdf0df82..ff5dba9dd 100644 --- a/web/store/issue/helpers/issue-helper.store.ts +++ b/web/store/issue/helpers/issue-helper.store.ts @@ -1,7 +1,7 @@ -import sortBy from "lodash/sortBy"; +import orderBy from "lodash/orderBy"; import get from "lodash/get"; import indexOf from "lodash/indexOf"; -import reverse from "lodash/reverse"; +import isEmpty from "lodash/isEmpty"; import values from "lodash/values"; // types import { TIssue, TIssueMap, TIssueGroupByOptions, TIssueOrderByOptions } from "@plane/types"; @@ -144,98 +144,189 @@ export class IssueHelperStore implements TIssueHelperStore { issueDisplayFiltersDefaultData = (groupBy: string | null): string[] => { switch (groupBy) { case "state": - return this.rootStore?.states || []; + return Object.keys(this.rootStore?.stateMap || {}); case "state_detail.group": return Object.keys(STATE_GROUPS); case "priority": return ISSUE_PRIORITIES.map((i) => i.key); case "labels": - return this.rootStore?.labels || []; + return Object.keys(this.rootStore?.labelMap || {}); case "created_by": - return this.rootStore?.members || []; + return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {}); case "assignees": - return this.rootStore?.members || []; + return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {}); case "project": - return this.rootStore?.projects || []; + return Object.keys(this.rootStore?.projectMap || {}); default: return []; } }; + /** + * This Method is used to get data of the issue based on the ids of the data for states, labels adn assignees + * @param dataType what type of data is being sent + * @param dataIds id/ids of the data that is to be populated + * @param order ascending or descending for arrays of data + * @returns string | string[] of sortable fields to be used for sorting + */ + populateIssueDataForSorting( + dataType: "state_id" | "label_ids" | "assignee_ids", + dataIds: string | string[] | null | undefined, + order?: "asc" | "desc" + ) { + if (!dataIds) return; + + const dataValues: string[] = []; + const isDataIdsArray = Array.isArray(dataIds); + const dataIdsArray = isDataIdsArray ? dataIds : [dataIds]; + + switch (dataType) { + case "state_id": + const stateMap = this.rootStore?.stateMap; + if (!stateMap) break; + for (const dataId of dataIdsArray) { + const state = stateMap[dataId]; + if (state && state.name) dataValues.push(state.name.toLocaleLowerCase()); + } + break; + case "label_ids": + const labelMap = this.rootStore?.labelMap; + if (!labelMap) break; + for (const dataId of dataIdsArray) { + const label = labelMap[dataId]; + if (label && label.name) dataValues.push(label.name.toLocaleLowerCase()); + } + break; + case "assignee_ids": + const memberMap = this.rootStore?.memberMap; + if (!memberMap) break; + for (const dataId of dataIdsArray) { + const member = memberMap[dataId]; + if (memberMap && member.first_name) dataValues.push(member.first_name.toLocaleLowerCase()); + } + break; + } + + return isDataIdsArray ? (order ? orderBy(dataValues, undefined, [order]) : dataValues) : dataValues[0]; + } + + /** + * This Method is mainly used to filter out empty values in the begining + * @param key key of the value that is to be checked if empty + * @param object any object in which the key's value is to be checked + * @returns 1 if emoty, 0 if not empty + */ + getSortOrderToFilterEmptyValues(key: string, object: any) { + const value = object?.[key]; + + if (typeof value !== "number" && isEmpty(value)) return 1; + + return 0; + } + issuesSortWithOrderBy = (issueObject: TIssueMap, key: Partial): TIssue[] => { let array = values(issueObject); - array = reverse(sortBy(array, "created_at")); + array = orderBy(array, "created_at"); + switch (key) { case "sort_order": - return sortBy(array, "sort_order"); - + return orderBy(array, "sort_order"); case "state__name": - return reverse(sortBy(array, "state")); + return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"])); case "-state__name": - return sortBy(array, "state"); - + return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"]), ["desc"]); // dates case "created_at": - return sortBy(array, "created_at"); + return orderBy(array, "created_at"); case "-created_at": - return reverse(sortBy(array, "created_at")); - + return orderBy(array, "created_at", ["desc"]); case "updated_at": - return sortBy(array, "updated_at"); + return orderBy(array, "updated_at"); case "-updated_at": - return reverse(sortBy(array, "updated_at")); - + return orderBy(array, "updated_at", ["desc"]); case "start_date": - return sortBy(array, "start_date"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"]); //preferring sorting based on empty values to always keep the empty values below case "-start_date": - return reverse(sortBy(array, "start_date")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); case "target_date": - return sortBy(array, "target_date"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"]); //preferring sorting based on empty values to always keep the empty values below case "-target_date": - return reverse(sortBy(array, "target_date")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); // custom case "priority": { const sortArray = ISSUE_PRIORITIES.map((i) => i.key); - return reverse(sortBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority))); + return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority), ["desc"]); } case "-priority": { const sortArray = ISSUE_PRIORITIES.map((i) => i.key); - return sortBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority)); + return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority)); } // number case "attachment_count": - return sortBy(array, "attachment_count"); + return orderBy(array, "attachment_count"); case "-attachment_count": - return reverse(sortBy(array, "attachment_count")); + return orderBy(array, "attachment_count", ["desc"]); case "estimate_point": - return sortBy(array, "estimate_point"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"]); //preferring sorting based on empty values to always keep the empty values below case "-estimate_point": - return reverse(sortBy(array, "estimate_point")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); case "link_count": - return sortBy(array, "link_count"); + return orderBy(array, "link_count"); case "-link_count": - return reverse(sortBy(array, "link_count")); + return orderBy(array, "link_count", ["desc"]); case "sub_issues_count": - return sortBy(array, "sub_issues_count"); + return orderBy(array, "sub_issues_count"); case "-sub_issues_count": - return reverse(sortBy(array, "sub_issues_count")); + return orderBy(array, "sub_issues_count", ["desc"]); // Array case "labels__name": - return reverse(sortBy(array, "labels")); + return orderBy(array, [ + this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "asc"), + ]); case "-labels__name": - return sortBy(array, "labels"); + return orderBy( + array, + [ + this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "desc"), + ], + ["asc", "desc"] + ); case "assignees__first_name": - return reverse(sortBy(array, "assignees")); + return orderBy(array, [ + this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "asc"), + ]); case "-assignees__first_name": - return sortBy(array, "assignees"); + return orderBy( + array, + [ + this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "desc"), + ], + ["asc", "desc"] + ); default: return array; diff --git a/web/store/issue/root.store.ts b/web/store/issue/root.store.ts index b2425757c..ee2e6d84d 100644 --- a/web/store/issue/root.store.ts +++ b/web/store/issue/root.store.ts @@ -4,7 +4,7 @@ import isEmpty from "lodash/isEmpty"; import { RootStore } from "../root.store"; import { IStateStore, StateStore } from "../state.store"; // issues data store -import { IState } from "@plane/types"; +import { IIssueLabel, IProject, IState, IUserLite } from "@plane/types"; import { IIssueStore, IssueStore } from "./issue.store"; import { IIssueDetail, IssueDetail } from "./issue-details/root.store"; import { IWorkspaceIssuesFilter, WorkspaceIssuesFilter, IWorkspaceIssues, WorkspaceIssues } from "./workspace"; @@ -22,6 +22,7 @@ import { IArchivedIssuesFilter, ArchivedIssuesFilter, IArchivedIssues, ArchivedI import { IDraftIssuesFilter, DraftIssuesFilter, IDraftIssues, DraftIssues } from "./draft"; import { IIssueKanBanViewStore, IssueKanBanViewStore } from "./issue_kanban_view.store"; import { ICalendarStore, CalendarStore } from "./issue_calendar_view.store"; +import { IWorkspaceMembership } from "store/member/workspace-member.store"; export interface IIssueRootStore { currentUserId: string | undefined; @@ -32,11 +33,12 @@ export interface IIssueRootStore { viewId: string | undefined; globalViewId: string | undefined; // all issues view id userId: string | undefined; // user profile detail Id - states: string[] | undefined; + stateMap: Record | undefined; stateDetails: IState[] | undefined; - labels: string[] | undefined; - members: string[] | undefined; - projects: string[] | undefined; + labelMap: Record | undefined; + workSpaceMemberRolesMap: Record | undefined; + memberMap: Record | undefined; + projectMap: Record | undefined; rootStore: RootStore; @@ -83,11 +85,12 @@ export class IssueRootStore implements IIssueRootStore { viewId: string | undefined = undefined; globalViewId: string | undefined = undefined; userId: string | undefined = undefined; - states: string[] | undefined = undefined; + stateMap: Record | undefined = undefined; stateDetails: IState[] | undefined = undefined; - labels: string[] | undefined = undefined; - members: string[] | undefined = undefined; - projects: string[] | undefined = undefined; + labelMap: Record | undefined = undefined; + workSpaceMemberRolesMap: Record | undefined = undefined; + memberMap: Record | undefined = undefined; + projectMap: Record | undefined = undefined; rootStore: RootStore; @@ -133,11 +136,12 @@ export class IssueRootStore implements IIssueRootStore { viewId: observable.ref, userId: observable.ref, globalViewId: observable.ref, - states: observable, + stateMap: observable, stateDetails: observable, - labels: observable, - members: observable, - projects: observable, + labelMap: observable, + memberMap: observable, + workSpaceMemberRolesMap: observable, + projectMap: observable, }); this.rootStore = rootStore; @@ -151,13 +155,14 @@ export class IssueRootStore implements IIssueRootStore { if (rootStore.app.router.viewId) this.viewId = rootStore.app.router.viewId; if (rootStore.app.router.globalViewId) this.globalViewId = rootStore.app.router.globalViewId; if (rootStore.app.router.userId) this.userId = rootStore.app.router.userId; - if (!isEmpty(rootStore?.state?.stateMap)) this.states = Object.keys(rootStore?.state?.stateMap); + if (!isEmpty(rootStore?.state?.stateMap)) this.stateMap = rootStore?.state?.stateMap; if (!isEmpty(rootStore?.state?.projectStates)) this.stateDetails = rootStore?.state?.projectStates; - if (!isEmpty(rootStore?.label?.labelMap)) this.labels = Object.keys(rootStore?.label?.labelMap); + if (!isEmpty(rootStore?.label?.labelMap)) this.labelMap = rootStore?.label?.labelMap; if (!isEmpty(rootStore?.memberRoot?.workspace?.workspaceMemberMap)) - this.members = Object.keys(rootStore?.memberRoot?.workspace?.workspaceMemberMap); + this.workSpaceMemberRolesMap = rootStore?.memberRoot?.workspace?.memberMap || undefined; + if (!isEmpty(rootStore?.memberRoot?.memberMap)) this.memberMap = rootStore?.memberRoot?.memberMap || undefined; if (!isEmpty(rootStore?.projectRoot?.project?.projectMap)) - this.projects = Object.keys(rootStore?.projectRoot?.project?.projectMap); + this.projectMap = rootStore?.projectRoot?.project?.projectMap; }); this.issues = new IssueStore(); diff --git a/web/store/member/workspace-member.store.ts b/web/store/member/workspace-member.store.ts index ff65d0eb9..1dae25bd4 100644 --- a/web/store/member/workspace-member.store.ts +++ b/web/store/member/workspace-member.store.ts @@ -26,6 +26,7 @@ export interface IWorkspaceMemberStore { // computed workspaceMemberIds: string[] | null; workspaceMemberInvitationIds: string[] | null; + memberMap: Record | null; // computed actions getSearchedWorkspaceMemberIds: (searchQuery: string) => string[] | null; getSearchedWorkspaceInvitationIds: (searchQuery: string) => string[] | null; @@ -68,6 +69,7 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { // computed workspaceMemberIds: computed, workspaceMemberInvitationIds: computed, + memberMap: computed, // actions fetchWorkspaceMembers: action, updateMember: action, @@ -100,6 +102,12 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { return memberIds; } + get memberMap() { + const workspaceSlug = this.routerStore.workspaceSlug; + if (!workspaceSlug) return null; + return this.workspaceMemberMap?.[workspaceSlug] ?? {}; + } + get workspaceMemberInvitationIds() { const workspaceSlug = this.routerStore.workspaceSlug; if (!workspaceSlug) return null; From be5d1eb9f96c066d9c949cd26d5f13a391505165 Mon Sep 17 00:00:00 2001 From: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com> Date: Fri, 9 Feb 2024 16:17:39 +0530 Subject: [PATCH 28/30] fix: notification popover responsiveness (#3602) * fix: notification popover responsiveness * fix: build errors * fix: typo --- .../notifications/notification-card.tsx | 289 ++++++++++++------ .../notifications/notification-header.tsx | 20 +- .../notifications/notification-popover.tsx | 273 +++++++++-------- .../select-snooze-till-modal.tsx | 13 +- 4 files changed, 360 insertions(+), 235 deletions(-) diff --git a/web/components/notifications/notification-card.tsx b/web/components/notifications/notification-card.tsx index 7a372c5d8..e709bbca3 100644 --- a/web/components/notifications/notification-card.tsx +++ b/web/components/notifications/notification-card.tsx @@ -1,7 +1,7 @@ -import React from "react"; +import React, { useEffect, useRef } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; -import { ArchiveRestore, Clock, MessageSquare, User2 } from "lucide-react"; +import { ArchiveRestore, Clock, MessageSquare, MoreVertical, User2 } from "lucide-react"; import Link from "next/link"; // hooks import useToast from "hooks/use-toast"; @@ -14,6 +14,7 @@ import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from import { calculateTimeAgo, renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper"; // type import type { IUserNotification } from "@plane/types"; +import { Menu } from "@headlessui/react"; type NotificationCardProps = { notification: IUserNotification; @@ -40,8 +41,73 @@ export const NotificationCard: React.FC = (props) => { const router = useRouter(); const { workspaceSlug } = router.query; - + // states + const [showSnoozeOptions, setshowSnoozeOptions] = React.useState(false); + // toast alert const { setToastAlert } = useToast(); + // refs + const snoozeRef = useRef(null); + + const moreOptions = [ + { + id: 1, + name: notification.read_at ? "Mark as unread" : "Mark as read", + icon: , + onClick: () => { + markNotificationReadStatusToggle(notification.id).then(() => { + setToastAlert({ + title: notification.read_at ? "Notification marked as read" : "Notification marked as unread", + type: "success", + }); + }); + }, + }, + { + id: 2, + name: notification.archived_at ? "Unarchive" : "Archive", + icon: notification.archived_at ? ( + + ) : ( + + ), + onClick: () => { + markNotificationArchivedStatus(notification.id).then(() => { + setToastAlert({ + title: notification.archived_at ? "Notification un-archived" : "Notification archived", + type: "success", + }); + }); + }, + }, + ]; + + const snoozeOptionOnClick = (date: Date | null) => { + if (!date) { + setSelectedNotificationForSnooze(notification.id); + return; + } + markSnoozeNotification(notification.id, date).then(() => { + setToastAlert({ + title: `Notification snoozed till ${renderFormattedDate(date)}`, + type: "success", + }); + }); + }; + + // close snooze options on outside click + useEffect(() => { + const handleClickOutside = (event: any) => { + if (snoozeRef.current && !snoozeRef.current?.contains(event.target)) { + setshowSnoozeOptions(false); + } + }; + document.addEventListener("mousedown", handleClickOutside, true); + document.addEventListener("touchend", handleClickOutside, true); + return () => { + document.removeEventListener("mousedown", handleClickOutside, true); + document.removeEventListener("touchend", handleClickOutside, true); + }; + }, []); if (isSnoozedTabOpen && new Date(notification.snoozed_till!) < new Date()) return null; @@ -87,57 +153,136 @@ export const NotificationCard: React.FC = (props) => { )}
    - {!notification.message ? ( -
    - - {notification.triggered_by_details.is_bot - ? notification.triggered_by_details.first_name - : notification.triggered_by_details.display_name}{" "} - - {notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "} - {notification.data.issue_activity.field === "comment" - ? "commented" - : notification.data.issue_activity.field === "None" - ? null - : replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "} - {notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None" - ? "to" - : ""} - - {" "} - {notification.data.issue_activity.field !== "None" ? ( - notification.data.issue_activity.field !== "comment" ? ( - notification.data.issue_activity.field === "target_date" ? ( - renderFormattedDate(notification.data.issue_activity.new_value) - ) : notification.data.issue_activity.field === "attachment" ? ( - "the issue" - ) : notification.data.issue_activity.field === "description" ? ( - stripAndTruncateHTML(notification.data.issue_activity.new_value, 55) +
    + {!notification.message ? ( +
    + + {notification.triggered_by_details.is_bot + ? notification.triggered_by_details.first_name + : notification.triggered_by_details.display_name}{" "} + + {notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "} + {notification.data.issue_activity.field === "comment" + ? "commented" + : notification.data.issue_activity.field === "None" + ? null + : replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "} + {notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None" + ? "to" + : ""} + + {" "} + {notification.data.issue_activity.field !== "None" ? ( + notification.data.issue_activity.field !== "comment" ? ( + notification.data.issue_activity.field === "target_date" ? ( + renderFormattedDate(notification.data.issue_activity.new_value) + ) : notification.data.issue_activity.field === "attachment" ? ( + "the issue" + ) : notification.data.issue_activity.field === "description" ? ( + stripAndTruncateHTML(notification.data.issue_activity.new_value, 55) + ) : ( + notification.data.issue_activity.new_value + ) ) : ( - notification.data.issue_activity.new_value + + {`"`} + {notification.data.issue_activity.new_value.length > 55 + ? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..." + : notification.data.issue_activity.issue_comment} + {`"`} + ) ) : ( - - {`"`} - {notification.data.issue_activity.new_value.length > 55 - ? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..." - : notification.data.issue_activity.issue_comment} - {`"`} - - ) - ) : ( - "the issue and assigned it to you." + "the issue and assigned it to you." + )} + +
    + ) : ( +
    + {notification.message} +
    + )} +
    + + {({ open }) => ( + <> + + + + {open && ( + +
    + {moreOptions.map((item) => ( + + {({ close }) => ( + + )} + + ))} + +
    { + e.stopPropagation(); + e.preventDefault(); + setshowSnoozeOptions(true); + }} + className="flex gap-x-2 items-center p-1.5" + > + + Snooze +
    +
    +
    +
    + )} + )} - +
    + {showSnoozeOptions && ( +
    + {snoozeOptions.map((item) => ( +

    { + e.stopPropagation(); + e.preventDefault(); + setshowSnoozeOptions(false); + snoozeOptionOnClick(item.value); + }} + > + {item.label} +

    + ))} +
    + )}
    - ) : ( -
    - {notification.message} -
    - )} +
    -

    +

    {truncateText( `${notification.data.issue.identifier}-${notification.data.issue.sequence_id} ${notification.data.issue.name}`, 50 @@ -152,43 +297,12 @@ export const NotificationCard: React.FC = (props) => {

    ) : ( -

    {calculateTimeAgo(notification.created_at)}

    +

    {calculateTimeAgo(notification.created_at)}

    )}
    -
    - {[ - { - id: 1, - name: notification.read_at ? "Mark as unread" : "Mark as read", - icon: , - onClick: () => { - markNotificationReadStatusToggle(notification.id).then(() => { - setToastAlert({ - title: notification.read_at ? "Notification marked as read" : "Notification marked as unread", - type: "success", - }); - }); - }, - }, - { - id: 2, - name: notification.archived_at ? "Unarchive" : "Archive", - icon: notification.archived_at ? ( - - ) : ( - - ), - onClick: () => { - markNotificationArchivedStatus(notification.id).then(() => { - setToastAlert({ - title: notification.archived_at ? "Notification un-archived" : "Notification archived", - type: "success", - }); - }); - }, - }, - ].map((item) => ( +
    + {moreOptions.map((item) => (
    - - - +
    + + + +
    diff --git a/web/components/notifications/notification-popover.tsx b/web/components/notifications/notification-popover.tsx index 4b55ea4cb..47fdae6ef 100644 --- a/web/components/notifications/notification-popover.tsx +++ b/web/components/notifications/notification-popover.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite"; // hooks import { useApplication } from "hooks/store"; import useUserNotification from "hooks/use-user-notifications"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { EmptyState } from "components/common"; import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "components/notifications"; @@ -15,8 +16,12 @@ import emptyNotification from "public/empty-state/notification.svg"; import { getNumberCount } from "helpers/string.helper"; export const NotificationPopover = observer(() => { + // states + const [isActive, setIsActive] = React.useState(false); // store hooks const { theme: themeStore } = useApplication(); + // refs + const notificationPopoverRef = React.useRef(null); const { notifications, @@ -44,8 +49,11 @@ export const NotificationPopover = observer(() => { setFetchNotifications, markAllNotificationsAsRead, } = useUserNotification(); - const isSidebarCollapsed = themeStore.sidebarCollapsed; + useOutsideClickDetector(notificationPopoverRef, () => { + // if snooze modal is open, then don't close the popover + if (selectedNotificationForSnooze === null) setIsActive(false); + }); return ( <> @@ -54,141 +62,142 @@ export const NotificationPopover = observer(() => { onClose={() => setSelectedNotificationForSnooze(null)} onSubmit={markSnoozeNotification} notification={notifications?.find((notification) => notification.id === selectedNotificationForSnooze) || null} - onSuccess={() => { - setSelectedNotificationForSnooze(null); - }} + onSuccess={() => setSelectedNotificationForSnooze(null)} /> - - {({ open: isActive, close: closePopover }) => { - if (isActive) setFetchNotifications(true); + + <> + + + + + + setIsActive(false)} + isRefreshing={isRefreshing} + snoozed={snoozed} + archived={archived} + readNotification={readNotification} + selectedTab={selectedTab} + setSnoozed={setSnoozed} + setArchived={setArchived} + setReadNotification={setReadNotification} + setSelectedTab={setSelectedTab} + markAllNotificationsAsRead={markAllNotificationsAsRead} + /> - return ( - <> - - - - {isSidebarCollapsed ? null : Notifications} - {totalNotificationCount && totalNotificationCount > 0 ? ( - isSidebarCollapsed ? ( - - ) : ( - - {getNumberCount(totalNotificationCount)} - - ) - ) : null} - - - - - - - {notifications ? ( - notifications.length > 0 ? ( -
    -
    - {notifications.map((notification) => ( - - ))} -
    - {isLoadingMore && ( -
    -
    - - Loading... -
    -

    Loading notifications

    -
    - )} - {hasMore && !isLoadingMore && ( - - )} -
    - ) : ( -
    - 0 ? ( +
    +
    + {notifications.map((notification) => ( + setIsActive(false)} + notification={notification} + markNotificationArchivedStatus={markNotificationArchivedStatus} + markNotificationReadStatus={markNotificationAsRead} + markNotificationReadStatusToggle={markNotificationReadStatus} + setSelectedNotificationForSnooze={setSelectedNotificationForSnooze} + markSnoozeNotification={markSnoozeNotification} /> + ))} +
    + {isLoadingMore && ( +
    +
    + + Loading... +
    +

    Loading notifications

    - ) - ) : ( - - - - - - - - )} - - - - ); - }} + )} + {hasMore && !isLoadingMore && ( + + )} +
    + ) : ( +
    + +
    + ) + ) : ( + + + + + + + + )} + + + ); diff --git a/web/components/notifications/select-snooze-till-modal.tsx b/web/components/notifications/select-snooze-till-modal.tsx index ab3497bb8..2ad4b0ef2 100644 --- a/web/components/notifications/select-snooze-till-modal.tsx +++ b/web/components/notifications/select-snooze-till-modal.tsx @@ -109,7 +109,12 @@ export const SnoozeNotificationModal: FC = (props) => { }; const handleClose = () => { - onClose(); + // This is a workaround to fix the issue of the Notification popover modal close on closing this modal + const closeTimeout = setTimeout(() => { + onClose(); + clearTimeout(closeTimeout); + }, 50); + const timeout = setTimeout(() => { reset({ ...defaultValues }); clearTimeout(timeout); @@ -142,7 +147,7 @@ export const SnoozeNotificationModal: FC = (props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    @@ -156,8 +161,8 @@ export const SnoozeNotificationModal: FC = (props) => {
    -
    -
    +
    +
    Pick a date
    Date: Fri, 9 Feb 2024 16:22:08 +0530 Subject: [PATCH 29/30] chore: added sign-up/in, onboarding, dashboard, all-issues related events (#3595) * chore: added event constants * chore: added workspace events * chore: added workspace group for events * chore: member invitation event added * chore: added project pages related events. * fix: member integer role to string * chore: added sign-up & sign-in events * chore: added global-view related events * chore: added notification related events * chore: project, cycle property change added * chore: cycle favourite, and change-properties added * chore: module davorite, and sidebar property changes added * fix: build errors * chore: all events defined in constants --- apiserver/plane/app/views/auth_extended.py | 12 +- apiserver/plane/app/views/authentication.py | 8 +- apiserver/plane/app/views/oauth.py | 4 +- .../sign-in-forms/optional-set-password.tsx | 27 +++- .../account/sign-in-forms/password.tsx | 16 +- web/components/account/sign-in-forms/root.tsx | 11 +- .../account/sign-in-forms/unique-code.tsx | 18 ++- .../sign-up-forms/optional-set-password.tsx | 27 +++- web/components/account/sign-up-forms/root.tsx | 11 +- .../account/sign-up-forms/unique-code.tsx | 19 ++- web/components/cycles/cycles-board-card.tsx | 47 ++++-- web/components/cycles/cycles-list-item.tsx | 59 ++++--- web/components/cycles/delete-modal.tsx | 6 +- web/components/cycles/form.tsx | 6 +- web/components/cycles/modal.tsx | 23 ++- web/components/cycles/sidebar.tsx | 73 ++++++--- web/components/headers/pages.tsx | 13 +- .../headers/workspace-dashboard.tsx | 21 ++- web/components/inbox/inbox-issue-actions.tsx | 17 +- .../inbox/modals/create-issue-modal.tsx | 17 +- web/components/issues/attachment/root.tsx | 8 +- web/components/issues/issue-detail/root.tsx | 25 +-- .../calendar/quick-add-issue-form.tsx | 6 +- .../roots/global-view-root.tsx | 12 +- .../gantt/quick-add-issue-form.tsx | 6 +- .../issue-layouts/kanban/base-kanban-root.tsx | 3 +- .../kanban/quick-add-issue-form.tsx | 6 +- .../list/quick-add-issue-form.tsx | 6 +- .../properties/all-properties.tsx | 16 +- .../spreadsheet/quick-add-issue-form.tsx | 6 +- web/components/issues/issue-modal/modal.tsx | 30 +--- web/components/issues/peek-overview/root.tsx | 25 +-- .../modules/delete-module-modal.tsx | 6 +- web/components/modules/form.tsx | 6 +- web/components/modules/modal.tsx | 18 ++- web/components/modules/module-card-item.tsx | 47 ++++-- web/components/modules/module-list-item.tsx | 43 +++-- web/components/modules/sidebar.tsx | 33 +++- .../notifications/notification-card.tsx | 29 +++- .../notifications/notification-header.tsx | 21 ++- .../notifications/notification-popover.tsx | 11 +- web/components/onboarding/invitations.tsx | 23 ++- web/components/onboarding/invite-members.tsx | 28 +++- web/components/onboarding/tour/root.tsx | 19 ++- web/components/onboarding/user-details.tsx | 20 ++- web/components/onboarding/workspace.tsx | 35 +++- .../page-views/workspace-dashboard.tsx | 6 +- .../pages/create-update-page-modal.tsx | 34 +++- web/components/pages/delete-page-modal.tsx | 19 ++- .../project/create-project-modal.tsx | 17 +- .../project/delete-project-modal.tsx | 16 +- web/components/project/form.tsx | 26 ++- .../project/leave-project-modal.tsx | 8 +- web/components/project/member-list-item.tsx | 9 +- .../project/send-project-invitation-modal.tsx | 43 +++-- .../project/settings/features-list.tsx | 10 +- .../states/create-update-state-inline.tsx | 42 +++-- web/components/states/delete-state-modal.tsx | 20 ++- .../workspace/create-workspace-form.tsx | 26 +-- .../workspace/delete-workspace-modal.tsx | 23 ++- .../workspace/settings/members-list-item.tsx | 12 +- .../workspace/settings/workspace-details.tsx | 21 ++- web/components/workspace/sidebar-dropdown.tsx | 1 - web/components/workspace/sidebar-menu.tsx | 25 +-- .../workspace/views/delete-view-modal.tsx | 21 ++- web/components/workspace/views/header.tsx | 11 +- web/components/workspace/views/modal.tsx | 38 ++++- .../workspace/views/view-list-item.tsx | 4 +- web/constants/event-tracker.ts | 139 ++++++++++++++-- web/lib/app-provider.tsx | 4 +- web/lib/posthog-provider.tsx | 23 ++- web/package.json | 2 +- .../projects/[projectId]/pages/index.tsx | 8 +- .../[workspaceSlug]/settings/members.tsx | 36 +++-- web/pages/accounts/forgot-password.tsx | 16 +- web/pages/accounts/reset-password.tsx | 21 ++- web/pages/invitations/index.tsx | 21 ++- web/pages/onboarding/index.tsx | 4 +- web/store/event-tracker.store.ts | 152 +++++++++++++----- web/store/user/index.ts | 2 + 80 files changed, 1276 insertions(+), 507 deletions(-) diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py index 501f47657..29cb43e38 100644 --- a/apiserver/plane/app/views/auth_extended.py +++ b/apiserver/plane/app/views/auth_extended.py @@ -401,8 +401,8 @@ class EmailCheckEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", + event_name="Sign up", + medium="Magic link", first_time=True, ) key, token, current_attempt = generate_magic_token(email=email) @@ -438,8 +438,8 @@ class EmailCheckEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", + event_name="Sign in", + medium="Magic link", first_time=False, ) @@ -468,8 +468,8 @@ class EmailCheckEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", + event_name="Sign in", + medium="Email", first_time=False, ) diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py index a41200d61..c2b3e0b7e 100644 --- a/apiserver/plane/app/views/authentication.py +++ b/apiserver/plane/app/views/authentication.py @@ -274,8 +274,8 @@ class SignInEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", + event_name="Sign in", + medium="Email", first_time=False, ) @@ -349,8 +349,8 @@ class MagicSignInEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", + event_name="Sign in", + medium="Magic link", first_time=False, ) diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py index de90e4337..8152fb0ee 100644 --- a/apiserver/plane/app/views/oauth.py +++ b/apiserver/plane/app/views/oauth.py @@ -296,7 +296,7 @@ class OauthEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", + event_name="Sign in", medium=medium.upper(), first_time=False, ) @@ -427,7 +427,7 @@ class OauthEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", + event_name="Sign up", medium=medium.upper(), first_time=True, ) diff --git a/web/components/account/sign-in-forms/optional-set-password.tsx b/web/components/account/sign-in-forms/optional-set-password.tsx index d7a595298..1ea5ca792 100644 --- a/web/components/account/sign-in-forms/optional-set-password.tsx +++ b/web/components/account/sign-in-forms/optional-set-password.tsx @@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form"; import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // icons import { Eye, EyeOff } from "lucide-react"; +import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED } from "constants/event-tracker"; type Props = { email: string; @@ -34,6 +36,8 @@ export const SignInOptionalSetPasswordForm: React.FC = (props) => { // states const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); const [showPassword, setShowPassword] = useState(false); + // store hooks + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); // form info @@ -63,21 +67,34 @@ export const SignInOptionalSetPasswordForm: React.FC = (props) => { title: "Success!", message: "Password created successfully.", }); + captureEvent(PASSWORD_CREATE_SELECTED, { + state: "SUCCESS", + first_time: false, + }); await handleSignInRedirection(); }) - .catch((err) => + .catch((err) => { + captureEvent(PASSWORD_CREATE_SELECTED, { + state: "FAILED", + first_time: false, + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; const handleGoToWorkspace = async () => { setIsGoingToWorkspace(true); - - await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); + await handleSignInRedirection().finally(() => { + captureEvent(PASSWORD_CREATE_SKIPPED, { + state: "SUCCESS", + first_time: false, + }); + setIsGoingToWorkspace(false); + }); }; return ( diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx index fe20d5b10..98719df63 100644 --- a/web/components/account/sign-in-forms/password.tsx +++ b/web/components/account/sign-in-forms/password.tsx @@ -7,7 +7,7 @@ import { Eye, EyeOff, XCircle } from "lucide-react"; import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; -import { useApplication } from "hooks/store"; +import { useApplication, useEventTracker } from "hooks/store"; // components import { ESignInSteps, ForgotPasswordPopover } from "components/account"; // ui @@ -16,6 +16,8 @@ import { Button, Input } from "@plane/ui"; import { checkEmailValidity } from "helpers/string.helper"; // types import { IPasswordSignInData } from "@plane/types"; +// constants +import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker"; type Props = { email: string; @@ -46,6 +48,7 @@ export const SignInPasswordForm: React.FC = observer((props) => { const { config: { envConfig }, } = useApplication(); + const { captureEvent } = useEventTracker(); // derived values const isSmtpConfigured = envConfig?.is_smtp_configured; // form info @@ -72,7 +75,13 @@ export const SignInPasswordForm: React.FC = observer((props) => { await authService .passwordSignIn(payload) - .then(async () => await onSubmit()) + .then(async () => { + captureEvent(SIGN_IN_WITH_PASSWORD, { + state: "SUCCESS", + first_time: false, + }); + await onSubmit(); + }) .catch((err) => setToastAlert({ type: "error", @@ -182,9 +191,10 @@ export const SignInPasswordForm: React.FC = observer((props) => {
    )} /> -
    +
    {isSmtpConfigured ? ( captureEvent(FORGOT_PASSWORD)} href={`/accounts/forgot-password?email=${email}`} className="text-xs font-medium text-custom-primary-100" > diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx index c92cd4bd4..62f63caea 100644 --- a/web/components/account/sign-in-forms/root.tsx +++ b/web/components/account/sign-in-forms/root.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import Link from "next/link"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication } from "hooks/store"; +import { useApplication, useEventTracker } from "hooks/store"; import useSignInRedirection from "hooks/use-sign-in-redirection"; // components import { LatestFeatureBlock } from "components/common"; @@ -13,6 +13,8 @@ import { OAuthOptions, SignInOptionalSetPasswordForm, } from "components/account"; +// constants +import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker"; export enum ESignInSteps { EMAIL = "EMAIL", @@ -32,6 +34,7 @@ export const SignInRoot = observer(() => { const { config: { envConfig }, } = useApplication(); + const { captureEvent } = useEventTracker(); // derived values const isSmtpConfigured = envConfig?.is_smtp_configured; @@ -110,7 +113,11 @@ export const SignInRoot = observer(() => {

    Don{"'"}t have an account?{" "} - + captureEvent(NAVIGATE_TO_SIGNUP, {})} + className="text-custom-primary-100 font-medium underline" + > Sign up

    diff --git a/web/components/account/sign-in-forms/unique-code.tsx b/web/components/account/sign-in-forms/unique-code.tsx index 6e0ae3745..55dbe86e2 100644 --- a/web/components/account/sign-in-forms/unique-code.tsx +++ b/web/components/account/sign-in-forms/unique-code.tsx @@ -7,12 +7,15 @@ import { UserService } from "services/user.service"; // hooks import useToast from "hooks/use-toast"; import useTimer from "hooks/use-timer"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types import { IEmailCheckData, IMagicSignInData } from "@plane/types"; +// constants +import { CODE_VERIFIED } from "constants/event-tracker"; type Props = { email: string; @@ -41,6 +44,8 @@ export const SignInUniqueCodeForm: React.FC = (props) => { const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); // toast alert const { setToastAlert } = useToast(); + // store hooks + const { captureEvent } = useEventTracker(); // timer const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30); // form info @@ -69,17 +74,22 @@ export const SignInUniqueCodeForm: React.FC = (props) => { await authService .magicSignIn(payload) .then(async () => { + captureEvent(CODE_VERIFIED, { + state: "SUCCESS", + }); const currentUser = await userService.currentUser(); - await onSubmit(currentUser.is_password_autoset); }) - .catch((err) => + .catch((err) => { + captureEvent(CODE_VERIFIED, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { diff --git a/web/components/account/sign-up-forms/optional-set-password.tsx b/web/components/account/sign-up-forms/optional-set-password.tsx index db14f0ccb..b49adabbb 100644 --- a/web/components/account/sign-up-forms/optional-set-password.tsx +++ b/web/components/account/sign-up-forms/optional-set-password.tsx @@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form"; import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // constants import { ESignUpSteps } from "components/account"; +import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker"; // icons import { Eye, EyeOff } from "lucide-react"; @@ -37,6 +39,8 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => { // states const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); const [showPassword, setShowPassword] = useState(false); + // store hooks + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); // form info @@ -66,21 +70,34 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => { title: "Success!", message: "Password created successfully.", }); + captureEvent(SETUP_PASSWORD, { + state: "SUCCESS", + first_time: true, + }); await handleSignInRedirection(); }) - .catch((err) => + .catch((err) => { + captureEvent(SETUP_PASSWORD, { + state: "FAILED", + first_time: true, + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; const handleGoToWorkspace = async () => { setIsGoingToWorkspace(true); - - await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); + await handleSignInRedirection().finally(() => { + captureEvent(PASSWORD_CREATE_SKIPPED, { + state: "SUCCESS", + first_time: true, + }); + setIsGoingToWorkspace(false); + }); }; return ( diff --git a/web/components/account/sign-up-forms/root.tsx b/web/components/account/sign-up-forms/root.tsx index da9d7d79a..8eeb5e99f 100644 --- a/web/components/account/sign-up-forms/root.tsx +++ b/web/components/account/sign-up-forms/root.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication } from "hooks/store"; +import { useApplication, useEventTracker } from "hooks/store"; import useSignInRedirection from "hooks/use-sign-in-redirection"; // components import { @@ -12,6 +12,8 @@ import { SignUpUniqueCodeForm, } from "components/account"; import Link from "next/link"; +// constants +import { NAVIGATE_TO_SIGNIN } from "constants/event-tracker"; export enum ESignUpSteps { EMAIL = "EMAIL", @@ -32,6 +34,7 @@ export const SignUpRoot = observer(() => { const { config: { envConfig }, } = useApplication(); + const { captureEvent } = useEventTracker(); // step 1 submit handler- email verification const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE); @@ -86,7 +89,11 @@ export const SignUpRoot = observer(() => {

    Already using Plane?{" "} - + captureEvent(NAVIGATE_TO_SIGNIN, {})} + className="text-custom-primary-100 font-medium underline" + > Sign in

    diff --git a/web/components/account/sign-up-forms/unique-code.tsx b/web/components/account/sign-up-forms/unique-code.tsx index 7764b627e..1b54ef9eb 100644 --- a/web/components/account/sign-up-forms/unique-code.tsx +++ b/web/components/account/sign-up-forms/unique-code.tsx @@ -8,12 +8,15 @@ import { UserService } from "services/user.service"; // hooks import useToast from "hooks/use-toast"; import useTimer from "hooks/use-timer"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types import { IEmailCheckData, IMagicSignInData } from "@plane/types"; +// constants +import { CODE_VERIFIED } from "constants/event-tracker"; type Props = { email: string; @@ -39,6 +42,8 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { const { email, handleEmailClear, onSubmit } = props; // states const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + // store hooks + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); // timer @@ -69,17 +74,22 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { await authService .magicSignIn(payload) .then(async () => { + captureEvent(CODE_VERIFIED, { + state: "SUCCESS", + }); const currentUser = await userService.currentUser(); - await onSubmit(currentUser.is_password_autoset); }) - .catch((err) => + .catch((err) => { + captureEvent(CODE_VERIFIED, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { @@ -96,7 +106,6 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { title: "Success!", message: "A new unique code has been sent to your email.", }); - reset({ email: formData.email, token: "", diff --git a/web/components/cycles/cycles-board-card.tsx b/web/components/cycles/cycles-board-card.tsx index bad7df0e5..375c15301 100644 --- a/web/components/cycles/cycles-board-card.tsx +++ b/web/components/cycles/cycles-board-card.tsx @@ -16,6 +16,7 @@ import { copyTextToClipboard } from "helpers/string.helper"; // constants import { CYCLE_STATUS } from "constants/cycle"; import { EUserWorkspaceRoles } from "constants/workspace"; +import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; //.types import { TCycleGroups } from "@plane/types"; @@ -33,7 +34,7 @@ export const CyclesBoardCard: FC = (props) => { // router const router = useRouter(); // store - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -90,39 +91,55 @@ export const CyclesBoardCard: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_FAVORITED, { + cycle_id: cycleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleEditCycle = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Cycles page board layout"); + setTrackElement("Cycles page grid layout"); setUpdateModal(true); }; const handleDeleteCycle = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Cycles page board layout"); + setTrackElement("Cycles page grid layout"); setDeleteModal(true); }; diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index 725480241..98392cd0e 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -18,6 +18,7 @@ import { CYCLE_STATUS } from "constants/cycle"; import { EUserWorkspaceRoles } from "constants/workspace"; // types import { TCycleGroups } from "@plane/types"; +import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; type TCyclesListItem = { cycleId: string; @@ -37,7 +38,7 @@ export const CyclesListItem: FC = (props) => { // router const router = useRouter(); // store hooks - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -63,26 +64,42 @@ export const CyclesListItem: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_FAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleEditCycle = (e: MouseEvent) => { @@ -159,9 +176,9 @@ export const CyclesListItem: FC = (props) => { projectId={projectId} /> -
    -
    -
    +
    +
    +
    {isCompleted ? ( @@ -181,20 +198,20 @@ export const CyclesListItem: FC = (props) => {
    - + {cycleDetails.name}
    -
    {currentCycle && (
    = (props) => {
    )}
    -
    +
    {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
    -
    +
    {cycleDetails.assignees.length > 0 ? ( diff --git a/web/components/cycles/delete-modal.tsx b/web/components/cycles/delete-modal.tsx index 32e067833..5dc0306ab 100644 --- a/web/components/cycles/delete-modal.tsx +++ b/web/components/cycles/delete-modal.tsx @@ -10,6 +10,8 @@ import useToast from "hooks/use-toast"; import { Button } from "@plane/ui"; // types import { ICycle } from "@plane/types"; +// constants +import { CYCLE_DELETED } from "constants/event-tracker"; interface ICycleDelete { cycle: ICycle; @@ -45,13 +47,13 @@ export const CycleDeleteModal: React.FC = observer((props) => { message: "Cycle deleted successfully.", }); captureCycleEvent({ - eventName: "Cycle deleted", + eventName: CYCLE_DELETED, payload: { ...cycle, state: "SUCCESS" }, }); }) .catch(() => { captureCycleEvent({ - eventName: "Cycle deleted", + eventName: CYCLE_DELETED, payload: { ...cycle, state: "FAILED" }, }); }); diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index 865cc68a1..dfe2a878e 100644 --- a/web/components/cycles/form.tsx +++ b/web/components/cycles/form.tsx @@ -10,7 +10,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { ICycle } from "@plane/types"; type Props = { - handleFormSubmit: (values: Partial) => Promise; + handleFormSubmit: (values: Partial, dirtyFields: any) => Promise; handleClose: () => void; status: boolean; projectId: string; @@ -29,7 +29,7 @@ export const CycleForm: React.FC = (props) => { const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props; // form data const { - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, dirtyFields }, handleSubmit, control, watch, @@ -61,7 +61,7 @@ export const CycleForm: React.FC = (props) => { maxDate?.setDate(maxDate.getDate() - 1); return ( - + handleFormSubmit(formData,dirtyFields))}>
    {!status && ( diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index 7e17e55f1..e8f19d6a1 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -10,6 +10,8 @@ import useLocalStorage from "hooks/use-local-storage"; import { CycleForm } from "components/cycles"; // types import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types"; +// constants +import { CYCLE_CREATED, CYCLE_UPDATED } from "constants/event-tracker"; type CycleModalProps = { isOpen: boolean; @@ -47,7 +49,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { message: "Cycle created successfully.", }); captureCycleEvent({ - eventName: "Cycle created", + eventName: CYCLE_CREATED, payload: { ...res, state: "SUCCESS" }, }); }) @@ -58,18 +60,23 @@ export const CycleCreateUpdateModal: React.FC = (props) => { message: err.detail ?? "Error in creating cycle. Please try again.", }); captureCycleEvent({ - eventName: "Cycle created", + eventName: CYCLE_CREATED, payload: { ...payload, state: "FAILED" }, }); }); }; - const handleUpdateCycle = async (cycleId: string, payload: Partial) => { + const handleUpdateCycle = async (cycleId: string, payload: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; const selectedProjectId = payload.project ?? projectId.toString(); await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload) - .then(() => { + .then((res) => { + const changed_properties = Object.keys(dirtyFields); + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { ...res, changed_properties: changed_properties, state: "SUCCESS" }, + }); setToastAlert({ type: "success", title: "Success!", @@ -77,6 +84,10 @@ export const CycleCreateUpdateModal: React.FC = (props) => { }); }) .catch((err) => { + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { ...payload, state: "FAILED" }, + }); setToastAlert({ type: "error", title: "Error!", @@ -95,7 +106,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { return status; }; - const handleFormSubmit = async (formData: Partial) => { + const handleFormSubmit = async (formData: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; const payload: Partial = { @@ -119,7 +130,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { } if (isDateValid) { - if (data) await handleUpdateCycle(data.id, payload); + if (data) await handleUpdateCycle(data.id, payload, dirtyFields); else { await handleCreateCycle(payload).then(() => { setCycleTab("all"); diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index 6966779b5..27182247b 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -39,6 +39,7 @@ import { import { ICycle } from "@plane/types"; // constants import { EUserWorkspaceRoles } from "constants/workspace"; +import { CYCLE_UPDATED } from "constants/event-tracker"; // fetch-keys import { CYCLE_STATUS } from "constants/cycle"; @@ -67,7 +68,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId, peekCycle } = router.query; // store hooks - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureCycleEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -83,10 +84,32 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { defaultValues, }); - const submitChanges = (data: Partial) => { + const submitChanges = (data: Partial, changedProperty: string) => { if (!workspaceSlug || !projectId || !cycleId) return; - updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data); + updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data) + .then((res) => { + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { + ...res, + changed_properties: [changedProperty], + element: "Right side-peek", + state: "SUCCESS", + }, + }); + }) + + .catch((_) => { + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { + ...data, + element: "Right side-peek", + state: "FAILED", + }, + }); + }); }; const handleCopyText = () => { @@ -146,10 +169,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValidForExistingCycle) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "start_date" + ); setToastAlert({ type: "success", title: "Success!", @@ -174,10 +200,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValid) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "start_date" + ); setToastAlert({ type: "success", title: "Success!", @@ -219,10 +248,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValidForExistingCycle) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "end_date" + ); setToastAlert({ type: "success", title: "Success!", @@ -246,10 +278,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValid) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "end_date" + ); setToastAlert({ type: "success", title: "Success!", diff --git a/web/components/headers/pages.tsx b/web/components/headers/pages.tsx index 28116b323..1984971d6 100644 --- a/web/components/headers/pages.tsx +++ b/web/components/headers/pages.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { FileText, Plus } from "lucide-react"; // hooks -import { useApplication, useProject, useUser } from "hooks/store"; +import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; // ui import { Breadcrumbs, Button } from "@plane/ui"; // helpers @@ -25,6 +25,7 @@ export const PagesHeader = observer(() => { membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails } = useProject(); + const { setTrackElement } = useEventTracker(); const canUserCreatePage = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); @@ -64,7 +65,15 @@ export const PagesHeader = observer(() => {
    {canUserCreatePage && (
    -
    diff --git a/web/components/headers/workspace-dashboard.tsx b/web/components/headers/workspace-dashboard.tsx index d8306ab40..6b85577f6 100644 --- a/web/components/headers/workspace-dashboard.tsx +++ b/web/components/headers/workspace-dashboard.tsx @@ -4,13 +4,18 @@ import { useTheme } from "next-themes"; // images import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; +// hooks +import { useEventTracker } from "hooks/store"; // components import { BreadcrumbLink } from "components/common"; import { Breadcrumbs } from "@plane/ui"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +// constants +import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "constants/event-tracker"; export const WorkspaceDashboardHeader = () => { // hooks + const { captureEvent } = useEventTracker(); const { resolvedTheme } = useTheme(); return ( @@ -31,16 +36,26 @@ export const WorkspaceDashboardHeader = () => {
    diff --git a/web/components/inbox/inbox-issue-actions.tsx b/web/components/inbox/inbox-issue-actions.tsx index 82253af88..998ad268c 100644 --- a/web/components/inbox/inbox-issue-actions.tsx +++ b/web/components/inbox/inbox-issue-actions.tsx @@ -20,6 +20,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle // types import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types"; import { EUserProjectRoles } from "constants/project"; +import { ISSUE_DELETED } from "constants/event-tracker"; type TInboxIssueActionsHeader = { workspaceSlug: string; @@ -86,17 +87,12 @@ export const InboxIssueActionsHeader: FC = observer((p throw new Error("Missing required parameters"); await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: inboxIssueId, state: "SUCCESS", element: "Inbox page", - }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, + } }); router.push({ pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, @@ -108,17 +104,12 @@ export const InboxIssueActionsHeader: FC = observer((p message: "Something went wrong while deleting inbox issue. Please try again.", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: inboxIssueId, state: "FAILED", element: "Inbox page", }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); } }, diff --git a/web/components/inbox/modals/create-issue-modal.tsx b/web/components/inbox/modals/create-issue-modal.tsx index 066f172ca..84c4bef1e 100644 --- a/web/components/inbox/modals/create-issue-modal.tsx +++ b/web/components/inbox/modals/create-issue-modal.tsx @@ -18,6 +18,8 @@ import { GptAssistantPopover } from "components/core"; import { Button, Input, ToggleSwitch } from "@plane/ui"; // types import { TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -65,7 +67,6 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { config: { envConfig }, } = useApplication(); const { captureIssueEvent } = useEventTracker(); - const { currentWorkspace } = useWorkspace(); const { control, @@ -94,34 +95,24 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { handleClose(); } else reset(defaultValues); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...formData, state: "SUCCESS", element: "Inbox page", }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, path: router.pathname, }); }) .catch((error) => { console.error(error); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...formData, state: "FAILED", element: "Inbox page", }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, path: router.pathname, }); }); diff --git a/web/components/issues/attachment/root.tsx b/web/components/issues/attachment/root.tsx index 11d74af0e..ffa17d337 100644 --- a/web/components/issues/attachment/root.tsx +++ b/web/components/issues/attachment/root.tsx @@ -38,7 +38,7 @@ export const IssueAttachmentRoot: FC = (props) => { title: "Attachment uploaded", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment added", payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "attachment", @@ -47,7 +47,7 @@ export const IssueAttachmentRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment added", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, }); setToastAlert({ @@ -67,7 +67,7 @@ export const IssueAttachmentRoot: FC = (props) => { title: "Attachment removed", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment deleted", payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "attachment", @@ -76,7 +76,7 @@ export const IssueAttachmentRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment deleted", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "attachment", diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 2e0303a8e..92badf4b2 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -16,6 +16,7 @@ import { TIssue } from "@plane/types"; // constants import { EUserProjectRoles } from "constants/project"; import { EIssuesStoreType } from "constants/issue"; +import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker"; export type TIssueOperations = { fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise; @@ -102,7 +103,7 @@ export const IssueDetailRoot: FC = (props) => { }); } captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: Object.keys(data).join(","), @@ -112,7 +113,7 @@ export const IssueDetailRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, updates: { changed_property: Object.keys(data).join(","), @@ -138,7 +139,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Issue deleted successfully", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, path: router.asPath, }); @@ -149,7 +150,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Issue delete failed", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, path: router.asPath, }); @@ -164,7 +165,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Issue added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -174,7 +175,7 @@ export const IssueDetailRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -198,7 +199,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Cycle removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -208,7 +209,7 @@ export const IssueDetailRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -232,7 +233,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Module added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "module_id", @@ -242,7 +243,7 @@ export const IssueDetailRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "module_id", @@ -266,7 +267,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Module removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "module_id", @@ -276,7 +277,7 @@ export const IssueDetailRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "module_id", diff --git a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx index 1f62c248c..6db9323fa 100644 --- a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx @@ -13,6 +13,8 @@ import { createIssuePayload } from "helpers/issue.helper"; import { PlusIcon } from "lucide-react"; // types import { TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; type Props = { formKey: keyof TIssue; @@ -129,7 +131,7 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { viewId ).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Calendar quick add" }, path: router.asPath, }); @@ -142,7 +144,7 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { } catch (err: any) { console.error(err); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Calendar quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx index 0dae3c8bd..c03e86504 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import isEqual from "lodash/isEqual"; // hooks -import { useGlobalView, useIssues, useLabel, useUser } from "hooks/store"; +import { useEventTracker, useGlobalView, useIssues, useLabel, useUser } from "hooks/store"; //ui import { Button } from "@plane/ui"; // components @@ -11,6 +11,8 @@ import { AppliedFiltersList } from "components/issues"; import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types"; import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace"; +// constants +import { GLOBAL_VIEW_UPDATED } from "constants/event-tracker"; type Props = { globalViewId: string; @@ -27,6 +29,7 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => { } = useIssues(EIssuesStoreType.GLOBAL); const { workspaceLabels } = useLabel(); const { globalViewMap, updateGlobalView } = useGlobalView(); + const { captureEvent } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -91,6 +94,13 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => { filters: { ...(appliedFilters ?? {}), }, + }).then((res) => { + captureEvent(GLOBAL_VIEW_UPDATED, { + view_id: res.id, + applied_filters: res.filters, + state: "SUCCESS", + element: "Spreadsheet view", + }); }); }; diff --git a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx index e89f60688..bfecb993b 100644 --- a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx @@ -13,6 +13,8 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { createIssuePayload } from "helpers/issue.helper"; // types import { IProject, TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; interface IInputProps { formKey: string; @@ -111,7 +113,7 @@ export const GanttQuickAddIssueForm: React.FC = observe quickAddCallback && (await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Gantt quick add" }, path: router.asPath, }); @@ -123,7 +125,7 @@ export const GanttQuickAddIssueForm: React.FC = observe }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Gantt quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 36d5e0315..83f72d8ea 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -25,6 +25,7 @@ import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile"; import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue"; +import { ISSUE_DELETED } from "constants/event-tracker"; export interface IBaseKanBanLayout { issues: IProjectIssues | ICycleIssues | IDraftIssues | IModuleIssues | IProjectViewIssues | IProfileIssues; @@ -212,7 +213,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas setDeleteIssueModal(false); setDragState({}); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: dragState.draggedIssueId!, state: "FAILED", element: "Kanban layout drag & drop" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx index 8880ca278..513163431 100644 --- a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx @@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { createIssuePayload } from "helpers/issue.helper"; // types import { TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; const Inputs = (props: any) => { const { register, setFocus, projectDetail } = props; @@ -106,7 +108,7 @@ export const KanBanQuickAddIssueForm: React.FC = obser viewId ).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Kanban quick add" }, path: router.asPath, }); @@ -118,7 +120,7 @@ export const KanBanQuickAddIssueForm: React.FC = obser }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Kanban quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx index dd63f09aa..8d1ce6d9c 100644 --- a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx @@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { TIssue, IProject } from "@plane/types"; // types import { createIssuePayload } from "helpers/issue.helper"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; interface IInputProps { formKey: string; @@ -103,7 +105,7 @@ export const ListQuickAddIssueForm: FC = observer((props quickAddCallback && (await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "List quick add" }, path: router.asPath, }); @@ -115,7 +117,7 @@ export const ListQuickAddIssueForm: FC = observer((props }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "List quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index e0a0dbd5c..4d851545e 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -18,6 +18,8 @@ import { import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // types import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; +// constants +import { ISSUE_UPDATED } from "constants/event-tracker"; export interface IIssueProperties { issue: TIssue; @@ -40,7 +42,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleState = (stateId: string) => { handleIssues({ ...issue, state_id: stateId }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -54,7 +56,7 @@ export const IssueProperties: React.FC = observer((props) => { const handlePriority = (value: TIssuePriorities) => { handleIssues({ ...issue, priority: value }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -68,7 +70,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleLabel = (ids: string[]) => { handleIssues({ ...issue, label_ids: ids }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -82,7 +84,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleAssignee = (ids: string[]) => { handleIssues({ ...issue, assignee_ids: ids }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -96,7 +98,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleStartDate = (date: Date | null) => { handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -110,7 +112,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleTargetDate = (date: Date | null) => { handleIssues({ ...issue, target_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -124,7 +126,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleEstimate = (value: number | null) => { handleIssues({ ...issue, estimate_point: value }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { diff --git a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx index b0acd7237..3cba3c6cd 100644 --- a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx @@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { createIssuePayload } from "helpers/issue.helper"; // types import { TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; type Props = { formKey: keyof TIssue; @@ -162,7 +164,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => (await quickAddCallback(currentWorkspace.slug, currentProjectDetails.id, { ...payload } as TIssue, viewId).then( (res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Spreadsheet quick add" }, path: router.asPath, }); @@ -175,7 +177,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Spreadsheet quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx index 02a087314..97d977ace 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -13,6 +13,8 @@ import { IssueFormRoot } from "./form"; import type { TIssue } from "@plane/types"; // constants import { EIssuesStoreType, TCreateModalStoreTypes } from "constants/issue"; +import { ISSUE_CREATED, ISSUE_UPDATED } from "constants/event-tracker"; + export interface IssuesModalProps { data?: Partial; isOpen: boolean; @@ -157,14 +159,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue created successfully.", }); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...response, state: "SUCCESS" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); !createMore && handleClose(); return response; @@ -175,14 +172,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue could not be created. Please try again.", }); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); } }; @@ -198,14 +190,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue updated successfully.", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); handleClose(); return response; @@ -216,14 +203,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue could not be created. Please try again.", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...payload, state: "FAILED" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); } }; diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index f14018ed4..b491ebe36 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -11,6 +11,7 @@ import { TIssue } from "@plane/types"; // constants import { EUserProjectRoles } from "constants/project"; import { EIssuesStoreType } from "constants/issue"; +import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker"; interface IIssuePeekOverview { is_archived?: boolean; @@ -103,7 +104,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue updated successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: Object.keys(data).join(","), @@ -113,7 +114,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue peek-overview" }, path: router.asPath, }); @@ -135,7 +136,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue deleted successfully", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" }, path: router.asPath, }); @@ -146,7 +147,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue delete failed", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, path: router.asPath, }); @@ -161,7 +162,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -171,7 +172,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -195,7 +196,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Cycle removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -210,7 +211,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Cycle remove from issue failed", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -229,7 +230,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Module added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "module_id", @@ -239,7 +240,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "module_id", @@ -263,7 +264,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Module removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "module_id", @@ -273,7 +274,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "module_id", diff --git a/web/components/modules/delete-module-modal.tsx b/web/components/modules/delete-module-modal.tsx index 2727b4e3b..636a828ae 100644 --- a/web/components/modules/delete-module-modal.tsx +++ b/web/components/modules/delete-module-modal.tsx @@ -11,6 +11,8 @@ import { Button } from "@plane/ui"; import { AlertTriangle } from "lucide-react"; // types import type { IModule } from "@plane/types"; +// constants +import { MODULE_DELETED } from "constants/event-tracker"; type Props = { data: IModule; @@ -51,7 +53,7 @@ export const DeleteModuleModal: React.FC = observer((props) => { message: "Module deleted successfully.", }); captureModuleEvent({ - eventName: "Module deleted", + eventName: MODULE_DELETED, payload: { ...data, state: "SUCCESS" }, }); }) @@ -62,7 +64,7 @@ export const DeleteModuleModal: React.FC = observer((props) => { message: "Module could not be deleted. Please try again.", }); captureModuleEvent({ - eventName: "Module deleted", + eventName: MODULE_DELETED, payload: { ...data, state: "FAILED" }, }); }) diff --git a/web/components/modules/form.tsx b/web/components/modules/form.tsx index be0792caa..8fa63e826 100644 --- a/web/components/modules/form.tsx +++ b/web/components/modules/form.tsx @@ -11,7 +11,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { IModule } from "@plane/types"; type Props = { - handleFormSubmit: (values: Partial) => Promise; + handleFormSubmit: (values: Partial, dirtyFields: any) => Promise; handleClose: () => void; status: boolean; projectId: string; @@ -36,7 +36,7 @@ export const ModuleForm: React.FC = ({ data, }) => { const { - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, dirtyFields }, handleSubmit, watch, control, @@ -53,7 +53,7 @@ export const ModuleForm: React.FC = ({ }); const handleCreateUpdateModule = async (formData: Partial) => { - await handleFormSubmit(formData); + await handleFormSubmit(formData, dirtyFields); reset({ ...defaultValues, diff --git a/web/components/modules/modal.tsx b/web/components/modules/modal.tsx index 0852434c3..7990386df 100644 --- a/web/components/modules/modal.tsx +++ b/web/components/modules/modal.tsx @@ -9,6 +9,8 @@ import useToast from "hooks/use-toast"; import { ModuleForm } from "components/modules"; // types import type { IModule } from "@plane/types"; +// constants +import { MODULE_CREATED, MODULE_UPDATED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -59,7 +61,7 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: "Module created successfully.", }); captureModuleEvent({ - eventName: "Module created", + eventName: MODULE_CREATED, payload: { ...res, state: "SUCCESS" }, }); }) @@ -70,13 +72,13 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: err.detail ?? "Module could not be created. Please try again.", }); captureModuleEvent({ - eventName: "Module created", + eventName: MODULE_CREATED, payload: { ...data, state: "FAILED" }, }); }); }; - const handleUpdateModule = async (payload: Partial) => { + const handleUpdateModule = async (payload: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId || !data) return; const selectedProjectId = payload.project ?? projectId.toString(); @@ -90,8 +92,8 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: "Module updated successfully.", }); captureModuleEvent({ - eventName: "Module updated", - payload: { ...res, state: "SUCCESS" }, + eventName: MODULE_UPDATED, + payload: { ...res, changed_properties: Object.keys(dirtyFields), state: "SUCCESS" }, }); }) .catch((err) => { @@ -101,20 +103,20 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: err.detail ?? "Module could not be updated. Please try again.", }); captureModuleEvent({ - eventName: "Module updated", + eventName: MODULE_UPDATED, payload: { ...data, state: "FAILED" }, }); }); }; - const handleFormSubmit = async (formData: Partial) => { + const handleFormSubmit = async (formData: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; const payload: Partial = { ...formData, }; if (!data) await handleCreateModule(payload); - else await handleUpdateModule(payload); + else await handleUpdateModule(payload, dirtyFields); }; useEffect(() => { diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index 3d83be010..219942550 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -16,6 +16,7 @@ import { renderFormattedDate } from "helpers/date-time.helper"; // constants import { MODULE_STATUS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; +import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "constants/event-tracker"; type Props = { moduleId: string; @@ -36,7 +37,7 @@ export const ModuleCardItem: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -46,13 +47,21 @@ export const ModuleCardItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the module to favorites. Please try again.", + addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_FAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the module to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: React.MouseEvent) => { @@ -60,13 +69,21 @@ export const ModuleCardItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't remove the module from favorites. Please try again.", + removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_UNFAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the module from favorites. Please try again.", + }); }); - }); }; const handleCopyText = (e: React.MouseEvent) => { @@ -84,14 +101,14 @@ export const ModuleCardItem: React.FC = observer((props) => { const handleEditModule = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Modules page board layout"); + setTrackElement("Modules page grid layout"); setEditModal(true); }; const handleDeleteModule = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Modules page board layout"); + setTrackElement("Modules page grid layout"); setDeleteModal(true); }; diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index 7232c8815..23e3e5ed4 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -16,6 +16,7 @@ import { renderFormattedDate } from "helpers/date-time.helper"; // constants import { MODULE_STATUS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; +import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "constants/event-tracker"; type Props = { moduleId: string; @@ -36,7 +37,7 @@ export const ModuleListItem: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -46,13 +47,21 @@ export const ModuleListItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the module to favorites. Please try again.", + addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_FAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the module to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: React.MouseEvent) => { @@ -60,13 +69,21 @@ export const ModuleListItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't remove the module from favorites. Please try again.", + removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_UNFAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the module from favorites. Please try again.", + }); }); - }); }; const handleCopyText = (e: React.MouseEvent) => { diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index 947885f9a..6b7ac1b3a 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -34,6 +34,7 @@ import { ILinkDetails, IModule, ModuleLink } from "@plane/types"; // constant import { MODULE_STATUS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; +import { MODULE_LINK_CREATED, MODULE_LINK_DELETED, MODULE_LINK_UPDATED, MODULE_UPDATED } from "constants/event-tracker"; const defaultValues: Partial = { lead: "", @@ -66,7 +67,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule(); - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker(); const moduleDetails = getModuleById(moduleId); const { setToastAlert } = useToast(); @@ -77,7 +78,19 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const submitChanges = (data: Partial) => { if (!workspaceSlug || !projectId || !moduleId) return; - updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data); + updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data) + .then((res) => { + captureModuleEvent({ + eventName: MODULE_UPDATED, + payload: { ...res, changed_properties: Object.keys(data)[0], element: "Right side-peek", state: "SUCCESS" }, + }); + }) + .catch((_) => { + captureModuleEvent({ + eventName: MODULE_UPDATED, + payload: { ...data, state: "FAILED" }, + }); + }); }; const handleCreateLink = async (formData: ModuleLink) => { @@ -87,6 +100,10 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload) .then(() => { + captureEvent(MODULE_LINK_CREATED, { + module_id: moduleId, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Module link created", @@ -109,6 +126,10 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload) .then(() => { + captureEvent(MODULE_LINK_UPDATED, { + module_id: moduleId, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Module link updated", @@ -129,6 +150,10 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { deleteModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId) .then(() => { + captureEvent(MODULE_LINK_DELETED, { + module_id: moduleId, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Module link deleted", @@ -187,8 +212,8 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { if (watch("start_date") && watch("target_date") && watch("start_date") !== "" && watch("start_date") !== "") { submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), target_date: renderFormattedPayloadDate(`${watch("target_date")}`), + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), }); setToastAlert({ type: "success", @@ -294,7 +319,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { ( + render={({ field: { value, onChange } }) => ( void; @@ -28,6 +32,7 @@ type NotificationCardProps = { export const NotificationCard: React.FC = (props) => { const { + selectedTab, notification, isSnoozedTabOpen, closePopover, @@ -37,6 +42,8 @@ export const NotificationCard: React.FC = (props) => { setSelectedNotificationForSnooze, markSnoozeNotification, } = props; + // store hooks + const { captureEvent } = useEventTracker(); const router = useRouter(); const { workspaceSlug } = router.query; @@ -49,6 +56,10 @@ export const NotificationCard: React.FC = (props) => { { markNotificationReadStatus(notification.id); + captureEvent(ISSUE_OPENED, { + issue_id: notification.data.issue.id, + element: "notification", + }); closePopover(); }} href={`/${workspaceSlug}/projects/${notification.project}/${ @@ -164,6 +175,11 @@ export const NotificationCard: React.FC = (props) => { icon: , onClick: () => { markNotificationReadStatusToggle(notification.id).then(() => { + captureEvent(NOTIFICATIONS_READ, { + issue_id: notification.data.issue.id, + tab: selectedTab, + state: "SUCCESS", + }); setToastAlert({ title: notification.read_at ? "Notification marked as read" : "Notification marked as unread", type: "success", @@ -181,6 +197,11 @@ export const NotificationCard: React.FC = (props) => { ), onClick: () => { markNotificationArchivedStatus(notification.id).then(() => { + captureEvent(NOTIFICATION_ARCHIVED, { + issue_id: notification.data.issue.id, + tab: selectedTab, + state: "SUCCESS", + }); setToastAlert({ title: notification.archived_at ? "Notification un-archived" : "Notification archived", type: "success", @@ -195,7 +216,6 @@ export const NotificationCard: React.FC = (props) => { onClick={(e) => { e.stopPropagation(); e.preventDefault(); - item.onClick(); }} key={item.id} @@ -228,6 +248,11 @@ export const NotificationCard: React.FC = (props) => { } markSnoozeNotification(notification.id, item.value).then(() => { + captureEvent(NOTIFICATION_SNOOZED, { + issue_id: notification.data.issue.id, + tab: selectedTab, + state: "SUCCESS", + }); setToastAlert({ title: `Notification snoozed till ${renderFormattedDate(item.value)}`, type: "success", diff --git a/web/components/notifications/notification-header.tsx b/web/components/notifications/notification-header.tsx index 39bf0e8fb..cf3a9fd36 100644 --- a/web/components/notifications/notification-header.tsx +++ b/web/components/notifications/notification-header.tsx @@ -2,10 +2,19 @@ import React from "react"; import { ArrowLeft, CheckCheck, Clock, ListFilter, MoreVertical, RefreshCw, X } from "lucide-react"; // ui import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui"; +// hooks +import { useEventTracker } from "hooks/store"; // helpers import { getNumberCount } from "helpers/string.helper"; // type import type { NotificationType, NotificationCount } from "@plane/types"; +// constants +import { + ARCHIVED_NOTIFICATIONS, + NOTIFICATIONS_READ, + SNOOZED_NOTIFICATIONS, + UNREAD_NOTIFICATIONS, +} from "constants/event-tracker"; type NotificationHeaderProps = { notificationCount?: NotificationCount | null; @@ -39,6 +48,8 @@ export const NotificationHeader: React.FC = (props) => setSelectedTab, markAllNotificationsAsRead, } = props; + // store hooks + const { captureEvent } = useEventTracker(); const notificationTabs: Array<{ label: string; @@ -84,6 +95,7 @@ export const NotificationHeader: React.FC = (props) => setSnoozed(false); setArchived(false); setReadNotification((prev) => !prev); + captureEvent(UNREAD_NOTIFICATIONS); }} > @@ -97,7 +109,12 @@ export const NotificationHeader: React.FC = (props) => } closeOnSelect > - + { + markAllNotificationsAsRead(); + captureEvent(NOTIFICATIONS_READ); + }} + >
    Mark all as read @@ -108,6 +125,7 @@ export const NotificationHeader: React.FC = (props) => setArchived(false); setReadNotification(false); setSnoozed((prev) => !prev); + captureEvent(SNOOZED_NOTIFICATIONS); }} >
    @@ -120,6 +138,7 @@ export const NotificationHeader: React.FC = (props) => setSnoozed(false); setReadNotification(false); setArchived((prev) => !prev); + captureEvent(ARCHIVED_NOTIFICATIONS); }} >
    diff --git a/web/components/notifications/notification-popover.tsx b/web/components/notifications/notification-popover.tsx index 4b55ea4cb..2b0689c34 100644 --- a/web/components/notifications/notification-popover.tsx +++ b/web/components/notifications/notification-popover.tsx @@ -3,7 +3,7 @@ import { Popover, Transition } from "@headlessui/react"; import { Bell } from "lucide-react"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication } from "hooks/store"; +import { useApplication, useEventTracker } from "hooks/store"; import useUserNotification from "hooks/use-user-notifications"; // components import { EmptyState } from "components/common"; @@ -13,10 +13,13 @@ import { Loader, Tooltip } from "@plane/ui"; import emptyNotification from "public/empty-state/notification.svg"; // helpers import { getNumberCount } from "helpers/string.helper"; +// constants +import { SIDEBAR_CLICKED } from "constants/event-tracker"; export const NotificationPopover = observer(() => { // store hooks const { theme: themeStore } = useApplication(); + const { captureEvent } = useEventTracker(); const { notifications, @@ -66,6 +69,11 @@ export const NotificationPopover = observer(() => { <> + captureEvent(SIDEBAR_CLICKED, { + destination: "notifications", + }) + } className={`group relative flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${ isActive ? "bg-custom-primary-100/10 text-custom-primary-100" @@ -120,6 +128,7 @@ export const NotificationPopover = observer(() => { key={notification.id} isSnoozedTabOpen={snoozed} closePopover={closePopover} + selectedTab={selectedTab} notification={notification} markNotificationArchivedStatus={markNotificationArchivedStatus} markNotificationReadStatus={markNotificationAsRead} diff --git a/web/components/onboarding/invitations.tsx b/web/components/onboarding/invitations.tsx index 3315ff035..c176ed580 100644 --- a/web/components/onboarding/invitations.tsx +++ b/web/components/onboarding/invitations.tsx @@ -11,11 +11,13 @@ import { WorkspaceService } from "services/workspace.service"; // constants import { USER_WORKSPACES, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys"; import { ROLE } from "constants/workspace"; +import { MEMBER_ACCEPTED } from "constants/event-tracker"; // types import { IWorkspaceMemberInvitation } from "@plane/types"; // icons import { CheckCircle2, Search } from "lucide-react"; import {} from "hooks/store/use-event-tracker"; +import { getUserRole } from "helpers/user.helper"; type Props = { handleNextStep: () => void; @@ -58,11 +60,19 @@ export const Invitations: React.FC = (props) => { if (invitationsRespond.length <= 0) return; setIsJoiningWorkspaces(true); + const invitation = invitations?.find((invitation) => invitation.id === invitationsRespond[0]); await workspaceService .joinWorkspaces({ invitations: invitationsRespond }) - .then(async (res) => { - captureEvent("Member accepted", { ...res, state: "SUCCESS", accepted_from: "App" }); + .then(async () => { + captureEvent(MEMBER_ACCEPTED, { + member_id: invitation?.id, + role: getUserRole(invitation?.role!), + project_id: undefined, + accepted_from: "App", + state: "SUCCESS", + element: "Workspace invitations page", + }); await fetchWorkspaces(); await mutate(USER_WORKSPACES); await updateLastWorkspace(); @@ -71,7 +81,14 @@ export const Invitations: React.FC = (props) => { }) .catch((error) => { console.error(error); - captureEvent("Member accepted", { state: "FAILED", accepted_from: "App" }); + captureEvent(MEMBER_ACCEPTED, { + member_id: invitation?.id, + role: getUserRole(invitation?.role!), + project_id: undefined, + accepted_from: "App", + state: "FAILED", + element: "Workspace invitations page", + }); }) .finally(() => setIsJoiningWorkspaces(false)); }; diff --git a/web/components/onboarding/invite-members.tsx b/web/components/onboarding/invite-members.tsx index dc6e2db96..561a428d6 100644 --- a/web/components/onboarding/invite-members.tsx +++ b/web/components/onboarding/invite-members.tsx @@ -18,6 +18,7 @@ import { Check, ChevronDown, Plus, XCircle } from "lucide-react"; import { WorkspaceService } from "services/workspace.service"; // hooks import useToast from "hooks/use-toast"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // components @@ -28,6 +29,9 @@ import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types"; // constants import { EUserWorkspaceRoles, ROLE } from "constants/workspace"; +import { MEMBER_INVITED } from "constants/event-tracker"; +// helpers +import { getUserRole } from "helpers/user.helper"; // assets import user1 from "public/users/user-1.png"; import user2 from "public/users/user-2.png"; @@ -267,6 +271,8 @@ export const InviteMembers: React.FC = (props) => { const { setToastAlert } = useToast(); const { resolvedTheme } = useTheme(); + // store hooks + const { captureEvent } = useEventTracker(); const { control, @@ -305,6 +311,17 @@ export const InviteMembers: React.FC = (props) => { })), }) .then(async () => { + captureEvent(MEMBER_INVITED, { + emails: [ + ...payload.emails.map((email) => ({ + email: email.email, + role: getUserRole(email.role), + })), + ], + project_id: undefined, + state: "SUCCESS", + element: "Onboarding", + }); setToastAlert({ type: "success", title: "Success!", @@ -313,13 +330,18 @@ export const InviteMembers: React.FC = (props) => { await nextStep(); }) - .catch((err) => + .catch((err) => { + captureEvent(MEMBER_INVITED, { + project_id: undefined, + state: "FAILED", + element: "Onboarding", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error, - }) - ); + }); + }); }; const appendField = () => { diff --git a/web/components/onboarding/tour/root.tsx b/web/components/onboarding/tour/root.tsx index 6e1de15dd..c09a2a94c 100644 --- a/web/components/onboarding/tour/root.tsx +++ b/web/components/onboarding/tour/root.tsx @@ -15,6 +15,8 @@ import CyclesTour from "public/onboarding/cycles.webp"; import ModulesTour from "public/onboarding/modules.webp"; import ViewsTour from "public/onboarding/views.webp"; import PagesTour from "public/onboarding/pages.webp"; +// constants +import { PRODUCT_TOUR_SKIPPED, PRODUCT_TOUR_STARTED } from "constants/event-tracker"; type Props = { onComplete: () => void; @@ -79,7 +81,7 @@ export const TourRoot: React.FC = observer((props) => { const [step, setStep] = useState("welcome"); // store hooks const { commandPalette: commandPaletteStore } = useApplication(); - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); const { currentUser } = useUser(); const currentStepIndex = TOUR_STEPS.findIndex((tourStep) => tourStep.key === step); @@ -103,13 +105,22 @@ export const TourRoot: React.FC = observer((props) => {

    - @@ -156,8 +167,8 @@ export const TourRoot: React.FC = observer((props) => { )} diff --git a/web/pages/accounts/forgot-password.tsx b/web/pages/accounts/forgot-password.tsx index 8d3c4cd28..07fa86045 100644 --- a/web/pages/accounts/forgot-password.tsx +++ b/web/pages/accounts/forgot-password.tsx @@ -7,6 +7,7 @@ import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; import useTimer from "hooks/use-timer"; +import { useEventTracker } from "hooks/store"; // layouts import DefaultLayout from "layouts/default-layout"; // components @@ -19,6 +20,7 @@ import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; import { checkEmailValidity } from "helpers/string.helper"; // type import { NextPageWithLayout } from "lib/types"; +import { FORGOT_PASS_LINK } from "constants/event-tracker"; type TForgotPasswordFormValues = { email: string; @@ -35,6 +37,8 @@ const ForgotPasswordPage: NextPageWithLayout = () => { // router const router = useRouter(); const { email } = router.query; + // store hooks + const { captureEvent } = useEventTracker(); // toast const { setToastAlert } = useToast(); // timer @@ -57,6 +61,9 @@ const ForgotPasswordPage: NextPageWithLayout = () => { email: formData.email, }) .then(() => { + captureEvent(FORGOT_PASS_LINK, { + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Email sent", @@ -65,13 +72,16 @@ const ForgotPasswordPage: NextPageWithLayout = () => { }); setResendCodeTimer(30); }) - .catch((err) => + .catch((err) => { + captureEvent(FORGOT_PASS_LINK, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; return ( diff --git a/web/pages/accounts/reset-password.tsx b/web/pages/accounts/reset-password.tsx index 9854ec5bb..c4258f39e 100644 --- a/web/pages/accounts/reset-password.tsx +++ b/web/pages/accounts/reset-password.tsx @@ -7,6 +7,7 @@ import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; import useSignInRedirection from "hooks/use-sign-in-redirection"; +import { useEventTracker } from "hooks/store"; // layouts import DefaultLayout from "layouts/default-layout"; // components @@ -21,6 +22,8 @@ import { checkEmailValidity } from "helpers/string.helper"; import { NextPageWithLayout } from "lib/types"; // icons import { Eye, EyeOff } from "lucide-react"; +// constants +import { NEW_PASS_CREATED } from "constants/event-tracker"; type TResetPasswordFormValues = { email: string; @@ -41,6 +44,8 @@ const ResetPasswordPage: NextPageWithLayout = () => { const { uidb64, token, email } = router.query; // states const [showPassword, setShowPassword] = useState(false); + // store hooks + const { captureEvent } = useEventTracker(); // toast const { setToastAlert } = useToast(); // sign in redirection hook @@ -66,14 +71,22 @@ const ResetPasswordPage: NextPageWithLayout = () => { await authService .resetPassword(uidb64.toString(), token.toString(), payload) - .then(() => handleRedirection()) - .catch((err) => + .then(() => { + captureEvent(NEW_PASS_CREATED, { + state: "SUCCESS", + }); + handleRedirection(); + }) + .catch((err) => { + captureEvent(NEW_PASS_CREATED, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; return ( diff --git a/web/pages/invitations/index.tsx b/web/pages/invitations/index.tsx index 1d8c3e774..26ced2010 100644 --- a/web/pages/invitations/index.tsx +++ b/web/pages/invitations/index.tsx @@ -23,11 +23,13 @@ import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-l import emptyInvitation from "public/empty-state/invitation.svg"; // helpers import { truncateText } from "helpers/string.helper"; +import { getUserRole } from "helpers/user.helper"; // types import { NextPageWithLayout } from "lib/types"; import type { IWorkspaceMemberInvitation } from "@plane/types"; // constants import { ROLE } from "constants/workspace"; +import { MEMBER_ACCEPTED } from "constants/event-tracker"; // components import { EmptyState } from "components/common"; @@ -40,7 +42,7 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { const [invitationsRespond, setInvitationsRespond] = useState([]); const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); // store hooks - const { captureEvent } = useEventTracker(); + const { captureEvent, joinWorkspaceMetricGroup } = useEventTracker(); const { currentUser, currentUserSettings } = useUser(); // router const router = useRouter(); @@ -81,11 +83,16 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { .then((res) => { mutate("USER_WORKSPACES"); const firstInviteId = invitationsRespond[0]; + const invitation = invitations?.find((i) => i.id === firstInviteId); const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace; - captureEvent("Member accepted", { - ...res, - state: "SUCCESS", + joinWorkspaceMetricGroup(redirectWorkspace?.id); + captureEvent(MEMBER_ACCEPTED, { + member_id: invitation?.id, + role: getUserRole(invitation?.role!), + project_id: undefined, accepted_from: "App", + state: "SUCCESS", + element: "Workspace invitations page", }); userService .updateUser({ last_workspace_id: redirectWorkspace?.id }) @@ -103,6 +110,12 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { }); }) .catch(() => { + captureEvent(MEMBER_ACCEPTED, { + project_id: undefined, + accepted_from: "App", + state: "FAILED", + element: "Workspace invitations page", + }); setToastAlert({ type: "error", title: "Error!", diff --git a/web/pages/onboarding/index.tsx b/web/pages/onboarding/index.tsx index 5a5911fca..99886156d 100644 --- a/web/pages/onboarding/index.tsx +++ b/web/pages/onboarding/index.tsx @@ -24,6 +24,8 @@ import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; // types import { IUser, TOnboardingSteps } from "@plane/types"; import { NextPageWithLayout } from "lib/types"; +// constants +import { USER_ONBOARDING_COMPLETED } from "constants/event-tracker"; // services const workspaceService = new WorkspaceService(); @@ -79,7 +81,7 @@ const OnboardingPage: NextPageWithLayout = observer(() => { await updateUserOnBoard() .then(() => { - captureEvent("User onboarding completed", { + captureEvent(USER_ONBOARDING_COMPLETED, { user_role: user.role, email: user.email, user_id: user.id, diff --git a/web/store/event-tracker.store.ts b/web/store/event-tracker.store.ts index 89e279c40..744ad44fb 100644 --- a/web/store/event-tracker.store.ts +++ b/web/store/event-tracker.store.ts @@ -3,39 +3,49 @@ import posthog from "posthog-js"; // stores import { RootStore } from "./root.store"; import { - EventGroupProps, + GROUP_WORKSPACE, + WORKSPACE_CREATED, EventProps, IssueEventProps, getCycleEventPayload, getIssueEventPayload, getModuleEventPayload, getProjectEventPayload, + getProjectStateEventPayload, + getWorkspaceEventPayload, + getPageEventPayload, } from "constants/event-tracker"; export interface IEventTrackerStore { // properties - trackElement: string; + trackElement: string | undefined; // computed - getRequiredPayload: any; + getRequiredProperties: any; // actions + resetSession: () => void; setTrackElement: (element: string) => void; - captureEvent: (eventName: string, payload: object | [] | null, group?: EventGroupProps) => void; + captureEvent: (eventName: string, payload?: any) => void; + joinWorkspaceMetricGroup: (workspaceId?: string) => void; + captureWorkspaceEvent: (props: EventProps) => void; captureProjectEvent: (props: EventProps) => void; captureCycleEvent: (props: EventProps) => void; captureModuleEvent: (props: EventProps) => void; + capturePageEvent: (props: EventProps) => void; captureIssueEvent: (props: IssueEventProps) => void; + captureProjectStateEvent: (props: EventProps) => void; } export class EventTrackerStore implements IEventTrackerStore { - trackElement: string = ""; + trackElement: string | undefined = undefined; rootStore; constructor(_rootStore: RootStore) { makeObservable(this, { // properties trackElement: observable, // computed - getRequiredPayload: computed, + getRequiredProperties: computed, // actions + resetSession: action, setTrackElement: action, captureEvent: action, captureProjectEvent: action, @@ -48,12 +58,12 @@ export class EventTrackerStore implements IEventTrackerStore { /** * @description: Returns the necessary property for the event tracking */ - get getRequiredPayload() { + get getRequiredProperties() { const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; const currentProjectDetails = this.rootStore.projectRoot.project.currentProjectDetails; return { - workspace_id: currentWorkspaceDetails?.id ?? "", - project_id: currentProjectDetails?.id ?? "", + workspace_id: currentWorkspaceDetails?.id, + project_id: currentProjectDetails?.id, }; } @@ -61,42 +71,74 @@ export class EventTrackerStore implements IEventTrackerStore { * @description: Set the trigger point of event. * @param {string} element */ - setTrackElement = (element: string) => { + setTrackElement = (element?: string) => { this.trackElement = element; }; - postHogGroup = (group: EventGroupProps) => { - if (group && group!.isGrouping === true) { - posthog?.group(group!.groupType!, group!.groupId!, { - date: new Date(), - workspace_id: group!.groupId, - }); - } + /** + * @description: Reset the session. + */ + resetSession = () => { + posthog?.reset(); }; - captureEvent = (eventName: string, payload: object | [] | null) => { - posthog?.capture(eventName, { - ...payload, - element: this.trackElement ?? "", + /** + * @description: Creates the workspace metric group. + * @param {string} userEmail + * @param {string} workspaceId + */ + joinWorkspaceMetricGroup = (workspaceId?: string) => { + if (!workspaceId) return; + posthog?.group(GROUP_WORKSPACE, workspaceId, { + date: new Date().toDateString(), + workspace_id: workspaceId, }); }; + /** + * @description: Captures the event. + * @param {string} eventName + * @param {any} payload + */ + captureEvent = (eventName: string, payload?: any) => { + posthog?.capture(eventName, { + ...this.getRequiredProperties, + ...payload, + element: payload?.element ?? this.trackElement, + }); + this.setTrackElement(undefined); + }; + + /** + * @description: Captures the workspace crud related events. + * @param {EventProps} props + */ + captureWorkspaceEvent = (props: EventProps) => { + const { eventName, payload } = props; + if (eventName === WORKSPACE_CREATED && payload.state == "SUCCESS") { + this.joinWorkspaceMetricGroup(payload.id); + } + const eventPayload: any = getWorkspaceEventPayload({ + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); + }; + /** * @description: Captures the project related events. * @param {EventProps} props */ captureProjectEvent = (props: EventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = getProjectEventPayload({ - ...this.getRequiredPayload, + ...this.getRequiredProperties, ...payload, element: payload.element ?? this.trackElement, }); posthog?.capture(eventName, eventPayload); - this.setTrackElement(""); + this.setTrackElement(undefined); }; /** @@ -104,17 +146,14 @@ export class EventTrackerStore implements IEventTrackerStore { * @param {EventProps} props */ captureCycleEvent = (props: EventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = getCycleEventPayload({ - ...this.getRequiredPayload, + ...this.getRequiredProperties, ...payload, element: payload.element ?? this.trackElement, }); posthog?.capture(eventName, eventPayload); - this.setTrackElement(""); + this.setTrackElement(undefined); }; /** @@ -122,17 +161,29 @@ export class EventTrackerStore implements IEventTrackerStore { * @param {EventProps} props */ captureModuleEvent = (props: EventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = getModuleEventPayload({ - ...this.getRequiredPayload, + ...this.getRequiredProperties, ...payload, element: payload.element ?? this.trackElement, }); posthog?.capture(eventName, eventPayload); - this.setTrackElement(""); + this.setTrackElement(undefined); + }; + + /** + * @description: Captures the project pages related events. + * @param {EventProps} props + */ + capturePageEvent = (props: EventProps) => { + const { eventName, payload } = props; + const eventPayload: any = getPageEventPayload({ + ...this.getRequiredProperties, + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); }; /** @@ -140,16 +191,29 @@ export class EventTrackerStore implements IEventTrackerStore { * @param {IssueEventProps} props */ captureIssueEvent = (props: IssueEventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = { ...getIssueEventPayload(props), - ...this.getRequiredPayload, + ...this.getRequiredProperties, state_group: this.rootStore.state.getStateById(payload.state_id)?.group ?? "", element: payload.element ?? this.trackElement, }; posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); + }; + + /** + * @description: Captures the issue related events. + * @param {IssueEventProps} props + */ + captureProjectStateEvent = (props: EventProps) => { + const { eventName, payload } = props; + const eventPayload: any = getProjectStateEventPayload({ + ...this.getRequiredProperties, + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); }; } diff --git a/web/store/user/index.ts b/web/store/user/index.ts index b07764a05..15f9e5772 100644 --- a/web/store/user/index.ts +++ b/web/store/user/index.ts @@ -250,6 +250,7 @@ export class UserRootStore implements IUserRootStore { this.isUserLoggedIn = false; }); this.membership = new UserMembershipStore(this.rootStore); + this.rootStore.eventTracker.resetSession(); this.rootStore.resetOnSignout(); }); @@ -264,6 +265,7 @@ export class UserRootStore implements IUserRootStore { this.isUserLoggedIn = false; }); this.membership = new UserMembershipStore(this.rootStore); + this.rootStore.eventTracker.resetSession(); this.rootStore.resetOnSignout(); }); } From 1927fdd437ce1f0bdfe67ffa441bc2bc07fcacfe Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 9 Feb 2024 16:37:39 +0530 Subject: [PATCH 30/30] feat: checkbox component (#3603) * feat: custom checkbox component. * improvement: checkbox component implementation in email notification settings. * improvement: add loader in email notification settings page. --- packages/ui/src/form-fields/checkbox.tsx | 67 +++++++++++++++++++ packages/ui/src/form-fields/index.ts | 1 + .../preferences/email-notification-form.tsx | 39 +++-------- web/pages/profile/preferences/email.tsx | 14 +++- 4 files changed, 91 insertions(+), 30 deletions(-) create mode 100644 packages/ui/src/form-fields/checkbox.tsx diff --git a/packages/ui/src/form-fields/checkbox.tsx b/packages/ui/src/form-fields/checkbox.tsx new file mode 100644 index 000000000..09b90b03b --- /dev/null +++ b/packages/ui/src/form-fields/checkbox.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; + +export interface CheckboxProps extends React.InputHTMLAttributes { + intermediate?: boolean; + className?: string; +} + +const Checkbox = React.forwardRef((props, ref) => { + const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props; + + return ( +
    + + + + + + + +
    + ); +}); +Checkbox.displayName = "form-checkbox-field"; + +export { Checkbox }; diff --git a/packages/ui/src/form-fields/index.ts b/packages/ui/src/form-fields/index.ts index 9cac73428..f19adcdc5 100644 --- a/packages/ui/src/form-fields/index.ts +++ b/packages/ui/src/form-fields/index.ts @@ -1,3 +1,4 @@ export * from "./input"; export * from "./textarea"; export * from "./input-color-picker"; +export * from "./checkbox"; diff --git a/web/components/profile/preferences/email-notification-form.tsx b/web/components/profile/preferences/email-notification-form.tsx index 898044c60..e041b28d8 100644 --- a/web/components/profile/preferences/email-notification-form.tsx +++ b/web/components/profile/preferences/email-notification-form.tsx @@ -1,7 +1,7 @@ -import { FC } from "react"; +import React, { FC } from "react"; import { Controller, useForm } from "react-hook-form"; // ui -import { Button } from "@plane/ui"; +import { Button, Checkbox } from "@plane/ui"; // hooks import useToast from "hooks/use-toast"; // services @@ -23,6 +23,7 @@ export const EmailNotificationForm: FC = (props) => // form data const { handleSubmit, + watch, control, setValue, formState: { isSubmitting, isDirty, dirtyFields }, @@ -78,12 +79,7 @@ export const EmailNotificationForm: FC = (props) => control={control} name="property_change" render={({ field: { value, onChange } }) => ( - onChange(!value)} - className="w-3.5 h-3.5 mx-2 cursor-pointer !border-custom-border-100" - /> + onChange(!value)} className="mx-2" /> )} />
    @@ -100,14 +96,14 @@ export const EmailNotificationForm: FC = (props) => control={control} name="state_change" render={({ field: { value, onChange } }) => ( - { setValue("issue_completed", !value); onChange(!value); }} - className="w-3.5 h-3.5 mx-2 cursor-pointer" + className="mx-2" /> )} /> @@ -123,12 +119,7 @@ export const EmailNotificationForm: FC = (props) => control={control} name="issue_completed" render={({ field: { value, onChange } }) => ( - onChange(!value)} - className="w-3.5 h-3.5 mx-2 cursor-pointer" - /> + onChange(!value)} className="mx-2" /> )} />
    @@ -145,12 +136,7 @@ export const EmailNotificationForm: FC = (props) => control={control} name="comment" render={({ field: { value, onChange } }) => ( - onChange(!value)} - className="w-3.5 h-3.5 mx-2 cursor-pointer" - /> + onChange(!value)} className="mx-2" /> )} />
    @@ -167,12 +153,7 @@ export const EmailNotificationForm: FC = (props) => control={control} name="mention" render={({ field: { value, onChange } }) => ( - onChange(!value)} - className="w-3.5 h-3.5 mx-2 cursor-pointer" - /> + onChange(!value)} className="mx-2" /> )} />
    diff --git a/web/pages/profile/preferences/email.tsx b/web/pages/profile/preferences/email.tsx index 714d8b555..7db6df113 100644 --- a/web/pages/profile/preferences/email.tsx +++ b/web/pages/profile/preferences/email.tsx @@ -2,6 +2,8 @@ import { ReactElement } from "react"; import useSWR from "swr"; // layouts import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences"; +// ui +import { Loader } from "@plane/ui"; // components import { EmailNotificationForm } from "components/profile/preferences"; // services @@ -14,10 +16,20 @@ const userService = new UserService(); const ProfilePreferencesThemePage: NextPageWithLayout = () => { // fetching user email notification settings - const { data } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => + const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => userService.currentUserEmailNotificationSettings() ); + if (isLoading) { + return ( + + + + + + ); + } + if (!data) { return null; }
    - -
    -
    - - {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id} - + +
    +
    + + {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id} + - {canEditProperties(issueDetail.project_id) && ( - - )} -
    - - {issueDetail.sub_issues_count > 0 && ( -
    - + {canEditProperties(issueDetail.project_id) && ( + )}
    - - handleIssuePeekOverview(issueDetail)} - className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" - > -
    - -
    0 && ( +
    +
    - -
    - -