Skip to content

Commit eebcaaf

Browse files
committed
feat(OUT-2104-re): virtualization to fix the freezing issues when tasks state are changed.
- when there are lots of tasks in the app and tasks state is changed, the app froze for 4-5 seconds. - applied virtualization for listing tasks on board and list. - optimized filter by keyword functionality. - cleanups remaining for list view.
1 parent 7f54d69 commit eebcaaf

4 files changed

Lines changed: 231 additions & 90 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@reduxjs/toolkit": "^2.2.3",
5858
"@sentry/nextjs": "^8",
5959
"@supabase/supabase-js": "^2.47.5",
60+
"@tanstack/react-virtual": "^3.13.12",
6061
"@trigger.dev/sdk": "3.3.17",
6162
"@types/date-fns": "^2.6.0",
6263
"@types/deep-equal": "^1.0.4",

src/app/ui/TaskBoard.tsx

Lines changed: 189 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import { NoFilteredTasksState } from '@/components/layouts/EmptyState/NoFiltered
1515
import { FilterBar } from '@/components/layouts/FilterBar'
1616
import { Header } from '@/components/layouts/Header'
1717
import { CustomLink } from '@/hoc/CustomLink'
18-
import CustomScrollBar from '@/hoc/CustomScrollBar'
1918
import { DragDropHandler } from '@/hoc/DragDropHandler'
2019
import { useFilter } from '@/hooks/useFilter'
2120
import { selectTaskBoard, updateWorkflowStateIdByTaskId } from '@/redux/features/taskBoardSlice'
@@ -30,9 +29,11 @@ import { sortTaskByDescendingOrder } from '@/utils/sortTask'
3029
import { prioritizeStartedStates } from '@/utils/workflowStates'
3130
import { UserRole } from '@api/core/types/user'
3231
import { Box, Stack } from '@mui/material'
33-
import { useCallback, useEffect, useState } from 'react'
32+
import { useCallback, useEffect, useRef, useState } from 'react'
3433
import { useSelector } from 'react-redux'
3534
import { z } from 'zod'
35+
import { useVirtualizer } from '@tanstack/react-virtual'
36+
import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto'
3637

3738
interface TaskBoardProps {
3839
mode: UserRole
@@ -172,35 +173,11 @@ export const TaskBoard = ({ mode, workspace }: TaskBoardProps) => {
172173
showAddBtn={mode === UserRole.IU || !!previewMode}
173174
showHeader={showHeader}
174175
>
175-
<CustomScrollBar>
176-
<Stack direction="column" rowGap="6px" sx={{ overflowX: 'auto' }}>
177-
{sortTaskByDescendingOrder<TaskResponse>(filterTaskWithWorkflowStateId(list.id)).map((task, index) => {
178-
return (
179-
<CustomLink
180-
key={task.id}
181-
href={{ pathname: getCardHref(task, mode), query: { token } }}
182-
style={{ width: 'fit-content' }}
183-
>
184-
<DragDropHandler
185-
key={task.id}
186-
accept={'taskCard'}
187-
index={index}
188-
task={task}
189-
draggable // Make TaskCard draggable
190-
>
191-
<Box key={task.id}>
192-
<TaskCard
193-
task={task}
194-
key={task.id}
195-
href={{ pathname: getCardHref(task, mode), query: { token } }}
196-
/>
197-
</Box>
198-
</DragDropHandler>
199-
</CustomLink>
200-
)
201-
})}
202-
</Stack>
203-
</CustomScrollBar>
176+
<TasksRowVirtualizer
177+
rows={sortTaskByDescendingOrder<TaskResponse>(filterTaskWithWorkflowStateId(list.id))}
178+
mode={mode}
179+
token={token ?? null}
180+
/>
204181
</TaskColumn>
205182
</DragDropHandler>
206183
))}
@@ -216,42 +193,33 @@ export const TaskBoard = ({ mode, workspace }: TaskBoardProps) => {
216193
margin: '0 auto',
217194
}}
218195
>
219-
<CustomScrollBar>
220-
{prioritizeStartedStates(workflowStates).map((list, index) => (
221-
<DragDropHandler
196+
{prioritizeStartedStates(workflowStates).map((list, index) => (
197+
<DragDropHandler
198+
key={list.id}
199+
accept={'taskCard'}
200+
index={index}
201+
id={list.id}
202+
onDropItem={onDropItem}
203+
droppable // Make TaskRow droppable
204+
>
205+
<TaskRow
206+
mode={mode}
207+
workflowStateId={list.id}
222208
key={list.id}
223-
accept={'taskCard'}
224-
index={index}
225-
id={list.id}
226-
onDropItem={onDropItem}
227-
droppable // Make TaskRow droppable
209+
columnName={list.name}
210+
taskCount={taskCountForWorkflowStateId(list.id)}
211+
display={!!filterTaskWithWorkflowStateId(list.id).length}
212+
showAddBtn={mode === UserRole.IU || !!previewMode}
228213
>
229-
<TaskRow
214+
<TasksColumnVirtualizer
215+
rows={sortTaskByDescendingOrder<TaskResponse>(filterTaskWithWorkflowStateId(list.id))}
216+
list={list}
230217
mode={mode}
231-
workflowStateId={list.id}
232-
key={list.id}
233-
columnName={list.name}
234-
taskCount={taskCountForWorkflowStateId(list.id)}
235-
display={!!filterTaskWithWorkflowStateId(list.id).length}
236-
showAddBtn={mode === UserRole.IU || !!previewMode}
237-
>
238-
{sortTaskByDescendingOrder<TaskResponse>(filterTaskWithWorkflowStateId(list.id)).map((task, index) => {
239-
return (
240-
<DragDropHandler
241-
key={task.id}
242-
accept={'taskCard'}
243-
index={index}
244-
task={task}
245-
draggable // Make ListViewTaskCard draggable
246-
>
247-
<TaskCardList task={task} variant="task" key={task.id} workflowState={list} mode={mode} />
248-
</DragDropHandler>
249-
)
250-
})}
251-
</TaskRow>
252-
</DragDropHandler>
253-
))}
254-
</CustomScrollBar>
218+
token={token ?? null}
219+
/>
220+
</TaskRow>
221+
</DragDropHandler>
222+
))}
255223
</Stack>
256224
)}
257225
<CustomDragLayer>
@@ -260,3 +228,160 @@ export const TaskBoard = ({ mode, workspace }: TaskBoardProps) => {
260228
</>
261229
)
262230
}
231+
232+
interface TasksVirtualizerProps {
233+
rows: TaskResponse[]
234+
mode: UserRole
235+
token: string | null
236+
}
237+
238+
// virtualization component used in board view
239+
function TasksRowVirtualizer({ rows, mode, token }: TasksVirtualizerProps) {
240+
const parentRef = useRef<HTMLDivElement>(null)
241+
242+
const rowVirtualizer = useVirtualizer({
243+
count: rows.length,
244+
getScrollElement: () => parentRef.current,
245+
estimateSize: useCallback(
246+
(index: number) => {
247+
const task = rows[index]
248+
let estimate = 70
249+
if (task.isArchived) estimate += 24
250+
if (task.dueDate) estimate += 24
251+
if (task.title && task.title.length > 50) estimate += 20
252+
return estimate
253+
},
254+
[rows],
255+
),
256+
measureElement: (element) => element.getBoundingClientRect().height,
257+
overscan: 20,
258+
})
259+
260+
return (
261+
<div
262+
ref={parentRef}
263+
className="List"
264+
style={{
265+
height: `100vh`,
266+
width: '100%',
267+
overflow: 'auto',
268+
columnGap: '6px',
269+
}}
270+
>
271+
<div
272+
style={{
273+
height: `${rowVirtualizer.getTotalSize()}px`,
274+
width: '100%',
275+
position: 'relative',
276+
}}
277+
>
278+
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
279+
<div
280+
key={virtualRow.key}
281+
data-index={virtualRow.index}
282+
ref={(node) => rowVirtualizer.measureElement(node)}
283+
style={{
284+
display: 'flex',
285+
position: 'absolute',
286+
transform: `translateY(${virtualRow.start}px)`,
287+
width: '100%',
288+
}}
289+
>
290+
<div style={{ padding: '3px 0' }}>
291+
<CustomLink
292+
key={rows[virtualRow.index].id}
293+
href={{
294+
pathname: getCardHref(rows[virtualRow.index], mode),
295+
query: { token },
296+
}}
297+
style={{ width: 'fit-content' }}
298+
>
299+
<DragDropHandler
300+
key={rows[virtualRow.index].id}
301+
accept={'taskCard'}
302+
index={virtualRow.index}
303+
task={rows[virtualRow.index]}
304+
draggable
305+
>
306+
<Box>
307+
<TaskCard
308+
task={rows[virtualRow.index]}
309+
key={rows[virtualRow.index].id}
310+
href={{
311+
pathname: getCardHref(rows[virtualRow.index], mode),
312+
query: { token },
313+
}}
314+
/>
315+
</Box>
316+
</DragDropHandler>
317+
</CustomLink>
318+
</div>
319+
</div>
320+
))}
321+
</div>
322+
</div>
323+
)
324+
}
325+
326+
function TasksColumnVirtualizer({ rows, mode, token, list }: TasksVirtualizerProps & { list: WorkflowStateResponse }) {
327+
const parentRef = useRef<HTMLDivElement>(null)
328+
const columnVirtualizer = useVirtualizer({
329+
count: rows.length,
330+
getScrollElement: () => parentRef.current,
331+
estimateSize: () => 44,
332+
measureElement: (element) => element.getBoundingClientRect().height,
333+
overscan: 100,
334+
})
335+
return (
336+
<div
337+
ref={parentRef}
338+
className="List"
339+
style={{
340+
height: `100vh`,
341+
width: '100%',
342+
overflow: 'auto',
343+
columnGap: '6px',
344+
}}
345+
>
346+
<div
347+
style={{
348+
height: `${columnVirtualizer.getTotalSize()}px`,
349+
width: '100%',
350+
position: 'relative',
351+
}}
352+
>
353+
{columnVirtualizer.getVirtualItems().map((virtualRow) => (
354+
<div
355+
key={virtualRow.key}
356+
data-index={virtualRow.index}
357+
ref={(node) => columnVirtualizer.measureElement(node)}
358+
style={{
359+
display: 'flex',
360+
position: 'absolute',
361+
transform: `translateY(${virtualRow.start}px)`,
362+
width: '100%',
363+
}}
364+
>
365+
<div style={{ padding: '3px 0', width: '100%' }}>
366+
<DragDropHandler
367+
key={rows[virtualRow.index].id}
368+
accept={'taskCard'}
369+
index={virtualRow.index}
370+
task={rows[virtualRow.index]}
371+
draggable
372+
>
373+
<TaskCardList
374+
task={rows[virtualRow.index]}
375+
variant="task"
376+
key={rows[virtualRow.index].id}
377+
workflowState={list}
378+
mode={mode}
379+
/>
380+
</DragDropHandler>
381+
</div>
382+
</div>
383+
))}
384+
</div>
385+
</div>
386+
)
387+
}

src/hooks/useFilter.tsx

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import store from '@/redux/store'
33
import { TaskResponse } from '@/types/dto/tasks.dto'
44
import { FilterOptions, FilterOptionsKeywords, IAssigneeCombined, IFilterOptions, UserIds } from '@/types/interfaces'
55
import { checkEmptyAssignee, getAssigneeName, UserIdsSchema, UserIdsType } from '@/utils/assignee'
6-
import { useEffect } from 'react'
6+
import { useEffect, useTransition } from 'react'
77
import { useSelector } from 'react-redux'
88

99
interface KeywordMatchable {
@@ -54,32 +54,32 @@ function filterByKeyword(
5454
accessibleTasks?: TaskResponse[],
5555
assignee?: IAssigneeCombined[],
5656
): TaskResponse[] {
57-
const keyword = (filterValue as string).toLowerCase()
58-
59-
const matchKeyword = (task: KeywordMatchable) =>
60-
// Match title, body or task label (case-insensitive)
61-
{
62-
const assigneeNameMatches = [task.assigneeId, task.companyId]
63-
.map((id) => getAssigneeName(assignee?.find((el) => el.id === id)))
64-
.filter(Boolean)
65-
.some((name) => name!.toLowerCase().includes(keyword)) //Logic to match tasks whose assignee name matches the keyword. Also, Extra logic to match client tasks' whose company name matches the keyword.
66-
67-
return (
68-
task.title?.toLowerCase().includes(keyword) ||
69-
task.body?.toLowerCase().includes(keyword) ||
70-
task.label?.toLowerCase().includes(keyword) ||
71-
assigneeNameMatches ||
72-
false
73-
)
74-
}
57+
const keyword = filterValue.toLowerCase()
58+
59+
const assigneeNameMap = new Map(assignee?.map((a) => [a.id, getAssigneeName(a)?.toLowerCase() ?? '']) ?? [])
60+
61+
const matchKeyword = (task: KeywordMatchable) => {
62+
const assigneeMatches = [task.assigneeId, task.companyId]
63+
.map((id) => assigneeNameMap.get(id || ''))
64+
.filter(Boolean)
65+
.some((name) => name && name.includes(keyword))
66+
67+
return (
68+
task.title?.toLowerCase().includes(keyword) ||
69+
task.body?.toLowerCase().includes(keyword) ||
70+
task.label?.toLowerCase().includes(keyword) ||
71+
assigneeMatches
72+
)
73+
}
7574

76-
const keywordMatchingParentIds = accessibleTasks?.filter(matchKeyword).map((task) => task.parentId) || []
77-
filteredTasks = filteredTasks.filter((task) => {
78-
// Either match parent with keyword, or match child task with keyword and link it to its parentId
79-
return matchKeyword(task) || keywordMatchingParentIds.includes(task.id)
80-
})
75+
const keywordMatchingParentIds = new Set(
76+
accessibleTasks
77+
?.filter(matchKeyword)
78+
.map((task) => task.parentId)
79+
.filter(Boolean),
80+
)
8181

82-
return filteredTasks
82+
return filteredTasks.filter((task) => matchKeyword(task) || keywordMatchingParentIds.has(task.id))
8383
}
8484

8585
function filterByType(filteredTasks: TaskResponse[], filterValue: string): TaskResponse[] {
@@ -97,6 +97,7 @@ function filterByType(filteredTasks: TaskResponse[], filterValue: string): TaskR
9797

9898
export const useFilter = (filterOptions: IFilterOptions) => {
9999
const { tasks, accessibleTasks, assignee } = useSelector(selectTaskBoard)
100+
const [isPending, startTransition] = useTransition()
100101

101102
function applyFilter(tasks: TaskResponse[], filterOptions: IFilterOptions) {
102103
let filteredTasks = [...tasks]
@@ -116,7 +117,9 @@ export const useFilter = (filterOptions: IFilterOptions) => {
116117
filteredTasks = FilterFunctions[FilterOptions.TYPE](filteredTasks, filterValue as string)
117118
}
118119
}
119-
store.dispatch(setFilteredTasks(filteredTasks))
120+
startTransition(() => {
121+
store.dispatch(setFilteredTasks(filteredTasks))
122+
})
120123
}
121124

122125
useEffect(() => {

0 commit comments

Comments
 (0)