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 1/3] 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 2/3] 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 */} - - -
-
- - {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 && ( +
+
- -
- - - {/* 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 3/3] 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 && ( +
+ +
+ )} + )}