From 08487fb7d4cedfea6859183005cffc15fd818433 Mon Sep 17 00:00:00 2001 From: rahulramesha Date: Thu, 1 Feb 2024 18:49:31 +0530 Subject: [PATCH] Virtualization like core changes with intersection observer --- packages/types/src/issues.d.ts | 9 +++ web/components/core/render-if-visible-HOC.tsx | 74 +++++++++++++++++++ web/components/issues/issue-layouts/utils.tsx | 48 +++++++++++- web/constants/issue.ts | 9 ++- 4 files changed, 137 insertions(+), 3 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..d7c974956 --- /dev/null +++ b/web/components/core/render-if-visible-HOC.tsx @@ -0,0 +1,74 @@ +import { cn } from "helpers/common.helper"; +import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react"; + +type Props = { + defaultHeight?: number; + verticalOffset?: number; + horizonatlOffset?: number; + root?: MutableRefObject; + children: ReactNode; + as?: keyof JSX.IntrinsicElements; + classNames?: string; + alwaysRender?: boolean; +}; + +const RenderIfVisible: React.FC = (props) => { + const { + defaultHeight = 300, + root, + verticalOffset = 50, + horizonatlOffset = 0, + as = "div", + children, + classNames = "", + alwaysRender = false, + ...others + } = 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: 600, + }); + } 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]); + + // Set height after render + useEffect(() => { + if (intersectionRef.current && isVisible) { + placeholderHeight.current = intersectionRef.current.offsetHeight; + } + }, [isVisible, intersectionRef, alwaysRender]); + + const child = isVisible ? <>{children} : null; + const style = isVisible ? {} : { height: placeholderHeight.current, width: "100%" }; + const className = isVisible ? classNames : cn(classNames, "animate-pulse"); + + return React.createElement(as, { ref: intersectionRef, style, className }, child); +}; + +export default RenderIfVisible; diff --git a/web/components/issues/issue-layouts/utils.tsx b/web/components/issues/issue-layouts/utils.tsx index 83ec363b9..c5283433a 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"; @@ -155,3 +155,47 @@ const getCreatedByColumns = (member: IMemberRootStore) => { }; }); }; + +export function getIssueFlatList( + groups: IGroupByColumn[], + issueIds: TGroupedIssues | TUnGroupedIssues, + showEmptyGroup: boolean +): IIssueListRow[] | undefined { + let list: IIssueListRow[] = []; + + if (Array.isArray(issueIds)) { + return wrapIssuesWithHeaderAndFooter(groups[0], issueIds, showEmptyGroup); + } + + for (const group of groups) { + const groupList = wrapIssuesWithHeaderAndFooter(group, issueIds[group.id], showEmptyGroup); + + if (!groupList) continue; + + list = list.concat(groupList); + } + + return list; +} + +function wrapIssuesWithHeaderAndFooter( + group: IGroupByColumn, + issueIds: string[], + showEmptyGroup: boolean +): IIssueListRow[] | undefined { + const header: IIssueListRow = { ...group, groupId: group.id, type: EIssueListRow.HEADER }; + const quickAdd: IIssueListRow = { ...group, groupId: group.id, type: EIssueListRow.QUICK_ADD }; + if (issueIds && issueIds.length > 0) { + const list: IIssueListRow[] = [header]; + + for (const issueId of issueIds) { + list.push({ id: issueId, groupId: group.id, type: EIssueListRow.ISSUE }); + } + + list.push(quickAdd); + + return list; + } + + if (showEmptyGroup) return [header, { id: group.id, groupId: group.id, type: EIssueListRow.NO_ISSUES }, quickAdd]; +} \ No newline at end of file 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