From 5ce163cb7959b2d3c6a4c1e9f45cdf15e35a5e03 Mon Sep 17 00:00:00 2001 From: Paradoxe Ngwasi Date: Mon, 26 Aug 2024 15:03:20 +0000 Subject: [PATCH 1/3] Add user profile detail component and scroll pagination --- .../components/UserProfileDetail.tsx | 77 +++++ .../app/[locale]/profile/[memberId]/page.tsx | 268 +++++++----------- apps/web/app/hooks/features/usePagination.ts | 60 +++- apps/web/lib/features/user-profile-tasks.tsx | 54 ++-- 4 files changed, 269 insertions(+), 190 deletions(-) create mode 100644 apps/web/app/[locale]/profile/[memberId]/components/UserProfileDetail.tsx diff --git a/apps/web/app/[locale]/profile/[memberId]/components/UserProfileDetail.tsx b/apps/web/app/[locale]/profile/[memberId]/components/UserProfileDetail.tsx new file mode 100644 index 000000000..527c004c4 --- /dev/null +++ b/apps/web/app/[locale]/profile/[memberId]/components/UserProfileDetail.tsx @@ -0,0 +1,77 @@ +import { useTimer } from '@app/hooks'; +import { ITimerStatusEnum, OT_Member } from '@app/interfaces'; +import { isValidUrl } from '@app/utils'; +import { getTimerStatusValue, TimerStatus } from 'lib/features'; +import { cn } from 'lib/utils'; +import { useMemo } from 'react'; +import stc from 'string-to-color'; +import { Avatar, Text } from 'lib/components'; +import { imgTitle } from '@app/helpers'; +import { TableActionPopover } from 'lib/settings/table-action-popover'; + +export function UserProfileDetail({ member }: { member?: OT_Member }) { + const user = useMemo(() => member?.employee.user, [member?.employee.user]); + + const userName = `${user?.firstName || ''} ${user?.lastName || ''}`; + const imgUrl = user?.image?.thumbUrl || user?.image?.fullUrl || user?.imageUrl; + const imageUrl = useMemo(() => imgUrl, [imgUrl]); + const size = 100; + const { timerStatus } = useTimer(); + // const isManager = activeTeamManagers.find((member) => member.employee.user?.id === member?.employee.user?.id); + const timerStatusValue: ITimerStatusEnum = useMemo(() => { + return getTimerStatusValue(timerStatus, member, false); + }, [timerStatus, member]); + return ( +
+
+ {imageUrl && isValidUrl(imageUrl) ? ( + + + + ) : ( + <> + {imgTitle(userName).charAt(0)} + + + + )} +
+
+
+ + {user?.firstName} {user?.lastName} + +
+ +
+
+ {user?.email} +
+
+ ); +} diff --git a/apps/web/app/[locale]/profile/[memberId]/page.tsx b/apps/web/app/[locale]/profile/[memberId]/page.tsx index cfe785764..3901d6dc4 100644 --- a/apps/web/app/[locale]/profile/[memberId]/page.tsx +++ b/apps/web/app/[locale]/profile/[memberId]/page.tsx @@ -1,20 +1,15 @@ 'use client'; /* eslint-disable no-mixed-spaces-and-tabs */ -import { imgTitle } from '@app/helpers'; -import { useAuthenticateUser, useDailyPlan, useOrganizationTeams, useTimer, useUserProfilePage } from '@app/hooks'; -import { ITimerStatusEnum, OT_Member } from '@app/interfaces'; -import { clsxm, isValidUrl } from '@app/utils'; -import clsx from 'clsx'; +import { useAuthenticateUser, useDailyPlan, useOrganizationTeams, useUserProfilePage } from '@app/hooks'; import { withAuthentication } from 'lib/app/authenticator'; -import { Avatar, Breadcrumb, Button, Container, Text, VerticalSeparator } from 'lib/components'; +import { Breadcrumb, Button, Container, Text, VerticalSeparator } from 'lib/components'; import { ArrowLeftIcon } from 'assets/svg'; -import { TaskFilter, Timer, TimerStatus, UserProfileTask, getTimerStatusValue, useTaskFilter } from 'lib/features'; +import { TaskFilter, Timer, UserProfileTask, useTaskFilter } from 'lib/features'; import { MainHeader, MainLayout } from 'lib/layout'; import Link from 'next/link'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; -import stc from 'string-to-color'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { fullWidthState } from '@app/stores/fullWidth'; @@ -23,7 +18,8 @@ import { AppsTab } from 'lib/features/activity/apps'; import { VisitedSitesTab } from 'lib/features/activity/visited-sites'; import { activityTypeState } from '@app/stores/activity-type'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@components/ui/resizable'; -import { TableActionPopover } from 'lib/settings/table-action-popover'; +import { UserProfileDetail } from './components/UserProfileDetail'; +import { cn } from 'lib/utils'; // import { ActivityCalendar } from 'lib/features/activity/calendar'; export type FilterTab = 'Tasks' | 'Screenshots' | 'Apps' | 'Visited Sites'; @@ -90,179 +86,105 @@ const Profile = React.memo(function ProfilePage({ params }: { params: { memberId getEmployeeDayPlans(profile.member?.employeeId ?? ''); }, [getEmployeeDayPlans, profile.member?.employeeId]); - return ( - <> - {Array.isArray(members) && members.length && !profile.member ? ( - -
-
- - {t('common.MEMBER')} {t('common.NOT_FOUND')}! - - - - {t('pages.profile.MEMBER_NOT_FOUND_MSG_1')} - + if (Array.isArray(members) && members.length && !profile.member) { + return ( + +
+
+ + {t('common.MEMBER')} {t('common.NOT_FOUND')}! + + + + {t('pages.profile.MEMBER_NOT_FOUND_MSG_1')} + + + +
+
+
+ ); + } - + return ( + + + setHeaderSize(size)} + > + + {/* Breadcrumb */} +
+ + + + +
-
- - ) : ( - - - setHeaderSize(size)} - > - - {/* Breadcrumb */} -
- - - - -
+ {/* User Profile Detail */} +
+ - {/* User Profile Detail */} -
- - {profileIsAuthUser && isTrackingEnabled && ( - + {profileIsAuthUser && isTrackingEnabled && ( + - {/* TaskFilter */} - - - {/*
+ /> + )} +
+ {/* TaskFilter */} + + + {/*
*/} - - - - {hook.tab == 'worked' && canSeeActivity && ( - -
- {Object.keys(activityScreens).map((filter, i) => ( -
- {i !== 0 && } -
changeActivityFilter(filter as FilterTab)} - > - {filter} -
-
- ))} + + + + {hook.tab == 'worked' && canSeeActivity && ( + +
+ {Object.keys(activityScreens).map((filter, i) => ( +
+ {i !== 0 && } +
changeActivityFilter(filter as FilterTab)} + > + {filter} +
- - )} - - - {hook.tab !== 'worked' || activityFilter == 'Tasks' ? ( - - ) : ( - activityScreens[activityFilter] ?? null - )} - - - - - )} - + ))} +
+
+ )} + + + {hook.tab !== 'worked' || activityFilter == 'Tasks' ? ( + + ) : ( + activityScreens[activityFilter] ?? null + )} + +
+ + ); }); -function UserProfileDetail({ member }: { member?: OT_Member }) { - const user = useMemo(() => member?.employee.user, [member?.employee.user]); - - const userName = `${user?.firstName || ''} ${user?.lastName || ''}`; - const imgUrl = user?.image?.thumbUrl || user?.image?.fullUrl || user?.imageUrl; - const imageUrl = useMemo(() => imgUrl, [imgUrl]); - const size = 100; - const { timerStatus } = useTimer(); - // const isManager = activeTeamManagers.find((member) => member.employee.user?.id === member?.employee.user?.id); - const timerStatusValue: ITimerStatusEnum = useMemo(() => { - return getTimerStatusValue(timerStatus, member, false); - }, [timerStatus, member]); - return ( -
- -
- - {imageUrl && isValidUrl(imageUrl) ? ( - - - - - - ) : ( - <> - - {imgTitle(userName).charAt(0)} - - - - )} -
-
-
- - {user?.firstName} {user?.lastName} - -
- -
-
- {user?.email} -
-
- ); -} - export default withAuthentication(Profile, { displayName: 'ProfilePage' }); diff --git a/apps/web/app/hooks/features/usePagination.ts b/apps/web/app/hooks/features/usePagination.ts index 2d7e686f7..3b4630b7b 100644 --- a/apps/web/app/hooks/features/usePagination.ts +++ b/apps/web/app/hooks/features/usePagination.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; export function usePagination(items: T[], defaultItemsPerPage = 10) { const [itemOffset, setItemOffset] = useState(0); @@ -24,3 +24,61 @@ export function usePagination(items: T[], defaultItemsPerPage = 10) { currentItems }; } + +export function useScrollPagination({ + enabled, + defaultItemsPerPage = 10, + items, + scrollableElement +}: { + items: T[]; + enabled?: boolean; + defaultItemsPerPage?: number; + scrollableElement?: HTMLElement | null; +}) { + const [slicedItems, setSlicedItems] = useState(items.slice(0, defaultItemsPerPage)); + const [page, setPage] = useState(1); + + const $scrollableElement = useRef(null); + + $scrollableElement.current = scrollableElement || $scrollableElement.current; + + useEffect(() => { + if (enabled) { + setPage(1); + setSlicedItems(items.slice(0, defaultItemsPerPage)); + } + }, [enabled, items]); + + useEffect(() => { + const container = $scrollableElement.current; + if (!container || !enabled) return; + + const handleScroll = () => { + if ( + container.scrollTop + container.clientHeight >= + container.scrollHeight - 100 // Adjust this value for how close to the bottom you want to trigger loading + ) { + setPage((prevPage) => prevPage + 1); + } + }; + + container.addEventListener('scroll', handleScroll); + + return () => { + container.removeEventListener('scroll', handleScroll); + }; + }, [$scrollableElement.current, enabled]); + + useEffect(() => { + const newItems = items.slice(0, defaultItemsPerPage * page); + if (items.length > newItems.length) { + setSlicedItems((prevItems) => (prevItems.length === newItems.length ? prevItems : newItems)); + } + }, [page, items, defaultItemsPerPage]); + + return { + slicedItems: enabled ? slicedItems : items, + scrollableElement: $scrollableElement + }; +} diff --git a/apps/web/lib/features/user-profile-tasks.tsx b/apps/web/lib/features/user-profile-tasks.tsx index fcd17d383..12c913b02 100644 --- a/apps/web/lib/features/user-profile-tasks.tsx +++ b/apps/web/lib/features/user-profile-tasks.tsx @@ -4,20 +4,25 @@ import { UserProfilePlans } from 'lib/features'; import { TaskCard } from './task/task-card'; import { I_TaskFilter } from './task/task-filters'; import { useTranslations } from 'next-intl'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ScreenCalendar } from './activity/screen-calendar'; +import { cn } from 'lib/utils'; +import { useScrollPagination } from '@app/hooks/features/usePagination'; + type Props = { tabFiltered: I_TaskFilter; profile: I_UserProfilePage; + paginateTasks?: boolean; }; /** - * It renders a list of tasks, with the first task being the active task, and the rest being the last - * 24 hours of tasks + * It displays a list of tasks, the first task being the active task and the rest being the last 24 hours of tasks * @param - `profile` - The user profile page data. * @returns A component that displays a user's profile page. */ -export function UserProfileTask({ profile, tabFiltered }: Props) { +export function UserProfileTask({ profile, paginateTasks, tabFiltered }: Props) { + const [scrollableContainer, setScrollableContainer] = useState(null); + const t = useTranslations(); // Get current timer seconds const { time, timerStatus } = useLiveTimerStatus(); @@ -31,10 +36,22 @@ export function UserProfileTask({ profile, tabFiltered }: Props) { () => tasks.filter((t) => (profile.member?.running == true ? t.id !== profile.activeUserTeamTask?.id : t)), [profile.activeUserTeamTask?.id, profile.member?.running, tasks] ); - // const data = otherTasks.length < 10 ? otherTasks : data; - // const { total, onPageChange, itemsPerPage, itemOffset, endOffset, setItemsPerPage, currentItems } = - // usePagination(otherTasks); + const { slicedItems } = useScrollPagination({ + enabled: !!paginateTasks, + items: otherTasks, + scrollableElement: scrollableContainer, + defaultItemsPerPage: 20 + }); + + useEffect(() => { + // Use the native element query since the ResizablePanel + // does not forward any HTML reference + const scrollable = document.querySelector('div.custom-scrollbar'); + if (scrollable) { + setScrollableContainer(scrollable); + } + }, []); return (
@@ -66,10 +83,12 @@ export function UserProfileTask({ profile, tabFiltered }: Props) { isAuthUser={profile.isAuthUser} activeAuthTask={true} profile={profile} - taskBadgeClassName={` ${profile.activeUserTeamTask?.issueType === 'Bug' - ? '!px-[0.3312rem] py-[0.2875rem]' - : '!px-[0.375rem] py-[0.375rem]' - } rounded-sm`} + taskBadgeClassName={cn( + profile.activeUserTeamTask?.issueType === 'Bug' + ? '!px-[0.3312rem] py-[0.2875rem]' + : '!px-[0.375rem] py-[0.375rem]', + 'rounded-sm' + )} taskTitleClassName="mt-[0.0625rem]" /> )} @@ -87,19 +106,22 @@ export function UserProfileTask({ profile, tabFiltered }: Props) { {tabFiltered.tab !== 'dailyplan' && (
    - {otherTasks.map((task) => { + {slicedItems.map((task) => { return (
  • From 1221710c86c8b0b443a144dd813cc978a2dbaa3c Mon Sep 17 00:00:00 2001 From: Paradoxe Ngwasi Date: Mon, 26 Aug 2024 16:11:41 +0000 Subject: [PATCH 2/3] Improve task rendering performance with LazyRender --- .../components/pages/task/ChildIssueCard.tsx | 2 +- apps/web/lib/components/lazy-render.tsx | 52 ++++++++++++ apps/web/lib/features/task/task-input.tsx | 80 ++++++++++--------- 3 files changed, 97 insertions(+), 37 deletions(-) create mode 100644 apps/web/lib/components/lazy-render.tsx diff --git a/apps/web/components/pages/task/ChildIssueCard.tsx b/apps/web/components/pages/task/ChildIssueCard.tsx index 3b5999936..7f2614e2d 100644 --- a/apps/web/components/pages/task/ChildIssueCard.tsx +++ b/apps/web/components/pages/task/ChildIssueCard.tsx @@ -115,7 +115,7 @@ function CreateChildTask({ modal, task }: { modal: IHookModal; task: ITeamTask } return ( -
    +
    {loading && (
    diff --git a/apps/web/lib/components/lazy-render.tsx b/apps/web/lib/components/lazy-render.tsx new file mode 100644 index 000000000..d9c6d489f --- /dev/null +++ b/apps/web/lib/components/lazy-render.tsx @@ -0,0 +1,52 @@ +import React, { ReactNode, useEffect, useState } from 'react'; + +type Props = { + items: T[]; + children?: (item: T, index: number) => ReactNode; + itemsPerPage?: number; +}; + +/** + * Lazy Render based on + * Queues the render function to be called during a browser's idle periods. + * @param param0 + * @returns + */ +export function LazyRender({ items, children, itemsPerPage = 20 }: Props) { + const [slicedItems, setSlicedItems] = useState([]); + const [page, setPage] = useState(1); + + useEffect(() => { + if (!('requestIdleCallback' in window)) { + setSlicedItems(items); + return; + } + + let cancelableIdlCallback = requestIdleCallback(function callback(deadline) { + console.log('Called Lazy Render'); + if (deadline.timeRemaining() < 1) { + cancelableIdlCallback = requestIdleCallback(callback); + return; + } + + const newItems = items.slice(0, itemsPerPage * page); + + if (items.length > newItems.length) { + setSlicedItems((prevItems) => (prevItems.length === newItems.length ? prevItems : newItems)); + + // Increment the page to trigger the next render + setPage((p) => p + 1); + } + }); + + return () => { + window.cancelIdleCallback(cancelableIdlCallback); + }; + }, [page, items]); + + return slicedItems.map((item, i) => { + const key = 'id' in item ? (item.id as any) : i; + + return {children ? children(item, i) : undefined}; + }); +} diff --git a/apps/web/lib/features/task/task-input.tsx b/apps/web/lib/features/task/task-input.tsx index 0a320fb9a..fb68e6990 100644 --- a/apps/web/lib/features/task/task-input.tsx +++ b/apps/web/lib/features/task/task-input.tsx @@ -37,6 +37,7 @@ import { ActiveTaskPropertiesDropdown, ActiveTaskSizesDropdown, ActiveTaskStatus import { useTranslations } from 'next-intl'; import { useInfinityScrolling } from '@app/hooks/useInfinityFetch'; import { ObserverComponent } from '@components/shared/Observer'; +import { LazyRender } from 'lib/components/lazy-render'; type Props = { task?: Nullable; @@ -613,43 +614,50 @@ function TaskCard({ {/* Task list */} -
      - {forParentChildRelationship && - data?.map((task, i) => { - const last = (datas.filteredTasks?.length || 0) - 1 === i; - const active = datas.inputTask === task; - - return ( -
    • - - - {!last && } -
    • - ); - })} - {!forParentChildRelationship && - datas.filteredTasks?.map((task, i) => { - const last = (datas.filteredTasks?.length || 0) - 1 === i; - const active = datas.inputTask === task; - - return ( -
    • - +
        + {forParentChildRelationship && ( + + {(task, i) => { + const last = (datas.filteredTasks?.length || 0) - 1 === i; + const active = datas.inputTask === task; + + return ( +
      • + + + {!last && } +
      • + ); + }} +
        + )} - {!last && } - - ); - })} + {!forParentChildRelationship && ( + + {(task, i) => { + const last = (datas.filteredTasks?.length || 0) - 1 === i; + const active = datas.inputTask === task; + + return ( +
      • + + + {!last && } +
      • + ); + }} +
        + )} {(forParentChildRelationship && updatedTaskList && updatedTaskList.length === 0) || (!forParentChildRelationship && datas.filteredTasks && datas.filteredTasks.length === 0 && ( From a9a3716ac8e064940c5c2a3093fdcc0e1656ba10 Mon Sep 17 00:00:00 2001 From: Paradoxe Ngwasi Date: Mon, 26 Aug 2024 16:21:34 +0000 Subject: [PATCH 3/3] Fix rendering issue with LazyRender component --- apps/web/lib/components/lazy-render.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/lib/components/lazy-render.tsx b/apps/web/lib/components/lazy-render.tsx index d9c6d489f..a698459e6 100644 --- a/apps/web/lib/components/lazy-render.tsx +++ b/apps/web/lib/components/lazy-render.tsx @@ -44,9 +44,13 @@ export function LazyRender({ items, children, itemsPerPage = 2 }; }, [page, items]); - return slicedItems.map((item, i) => { - const key = 'id' in item ? (item.id as any) : i; - - return {children ? children(item, i) : undefined}; - }); + return ( + <> + {slicedItems.map((item, i) => { + const key = 'id' in item ? (item.id as any) : i; + + return {children ? children(item, i) : undefined}; + })} + + ); }