-
Notifications
You must be signed in to change notification settings - Fork 103
feat(view): 클러스터 목록 무한 스크롤 구현 #1019
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
955a787
5310f4a
b1e7a17
ccfd2a8
9b208eb
e3ee1ac
2d6a4c0
d593905
476f139
3cd56a0
79e46df
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,12 @@ | ||
| import { Tooltip, Avatar } from "@mui/material"; | ||
|
|
||
| import type { AuthorInfo } from "types"; | ||
|
|
||
| import { GITHUB_URL } from "constants/constants"; | ||
|
|
||
| import { AVATAR_STYLE, TOOLTIP_STYLE } from "./Author.const"; | ||
|
|
||
| const isGitHubUser = (src: string): boolean => { | ||
| return src.startsWith(GITHUB_URL); | ||
| const isGitHubUser = (src: string | undefined): boolean => { | ||
| return Boolean(src?.startsWith(GITHUB_URL)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 코드 여쭤보려다가 copilot 한테 물어봤습니다. ''' 간단히: src가 undefined일 수 있어서요. src?.startsWith(...)는 src가 없으면 undefined를 반환하고, 함수는 항상 boolean을 반환해야 하므로 Boolean(...)으로 감싸서 undefined를 false로 바꿔 일관된 boolean을 반환하게 한 것입니다. 세부: src?.startsWith(GITHUB_URL) 결과는 true | false | undefined 입니다 (src가 없으면 undefined). return src?.startsWith(GITHUB_URL) ?? false; // optional chaining + nullish coalescing copilot 잘하네요 ㅋㅋ |
||
| }; | ||
|
|
||
| const getGitHubProfileUrl = (username: string): string => { | ||
|
|
@@ -42,6 +41,18 @@ const StaticAvatar = ({ name, src }: AuthorInfo) => { | |
| }; | ||
|
|
||
| const AvatarComponent = ({ name, src }: AuthorInfo) => { | ||
| // src가 undefined인 경우 이름의 첫 글자를 표시하는 기본 아바타 사용 | ||
|
||
| if (!src) { | ||
| return ( | ||
| <Avatar | ||
| alt={name} | ||
| sx={AVATAR_STYLE} | ||
| > | ||
| {name.charAt(0).toUpperCase()} | ||
| </Avatar> | ||
| ); | ||
| } | ||
|
|
||
| return isGitHubUser(src) ? ( | ||
| <ClickableAvatar | ||
| name={name} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,12 +15,12 @@ import { CLUSTER_HEIGHT, DETAIL_HEIGHT, NODE_GAP } from "../ClusterGraph/Cluster | |
| import { usePreLoadAuthorImg } from "./Summary.hook"; | ||
| import { getInitData, getClusterIds, getClusterById, getCommitLatestTag } from "./Summary.util"; | ||
| import { Content } from "./Content"; | ||
| import type { ClusterRowProps } from "./Summary.type"; | ||
| import type { ClusterRowProps, SummaryProps } from "./Summary.type"; | ||
|
|
||
| const COLLAPSED_ROW_HEIGHT = CLUSTER_HEIGHT + NODE_GAP * 2; | ||
| const EXPANDED_ROW_HEIGHT = DETAIL_HEIGHT + COLLAPSED_ROW_HEIGHT; | ||
|
|
||
| const Summary = () => { | ||
| const Summary = ({ onLoadMore, isLoadingMore, enabled, isLastPage }: SummaryProps) => { | ||
| const [filteredData, selectedData, toggleSelectedData] = useDataStore( | ||
| useShallow((state) => [state.filteredData, state.selectedData, state.toggleSelectedData]) | ||
| ); | ||
|
|
@@ -31,17 +31,78 @@ const Summary = () => { | |
| const listRef = useRef<List>(null); | ||
| const clusterSizes = getClusterSizes(filteredData); | ||
|
|
||
| const sentinelRef = useRef<HTMLDivElement>(null); | ||
| const observerRef = useRef<IntersectionObserver | null>(null); | ||
| const isObservingRef = useRef(false); | ||
|
|
||
| // Create IntersectionObserver once and reuse it | ||
| useEffect(() => { | ||
| observerRef.current = new IntersectionObserver( | ||
| (entries) => { | ||
| if (entries[0].isIntersecting && !isLoadingMore && enabled) { | ||
| onLoadMore(); | ||
| } | ||
| }, | ||
| { | ||
| root: null, | ||
| rootMargin: "100px", | ||
| threshold: 0.1, | ||
| } | ||
| ); | ||
|
|
||
| return () => { | ||
| if (observerRef.current) { | ||
| observerRef.current.disconnect(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기서 observer를 매번 지우는 이유가 따로 있을까요?? IntersectionObserver의 observe()로 매번 자동 감시가 되는 걸로 알고 있어서요!!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 좋은 습관(??) 이 아닐까요? 😄
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @yuminnnnni 그래서 변경될 때마다 Observer를 다시 등록해주도록 구현하였습니다ㅎㅎ
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 지금보니 useRef로 enabled, isLoadingMore를 컨트롤 해주었으면 어떨까싶네요ㅎㅎ 추후 작업때 반영해보겠습니다ㅎㅎ |
||
| observerRef.current = null; | ||
| } | ||
| isObservingRef.current = false; | ||
| }; | ||
| }, [enabled, isLoadingMore, onLoadMore]); | ||
|
|
||
| // Infinite scroll: Observe sentinel when it's rendered | ||
| const handleRowsRendered = ({ stopIndex }: { startIndex: number; stopIndex: number }) => { | ||
| if (!isLastPage && stopIndex >= clusters.length && sentinelRef.current && enabled && observerRef.current) { | ||
| if (!isObservingRef.current) { | ||
| observerRef.current.observe(sentinelRef.current); | ||
| isObservingRef.current = true; | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| // Unobserve when sentinel is no longer needed | ||
| useEffect(() => { | ||
| if (isLastPage && observerRef.current && isObservingRef.current) { | ||
| observerRef.current.disconnect(); | ||
| isObservingRef.current = false; | ||
| } | ||
| }, [isLastPage]); | ||
|
|
||
| const onClickClusterSummary = (clusterId: number) => () => { | ||
| const selected = getClusterById(filteredData, clusterId); | ||
| toggleSelectedData(selected, clusterId); | ||
| }; | ||
|
|
||
| const getRowHeight = ({ index }: { index: number }) => { | ||
| if (!isLastPage && index === clusters.length) { | ||
| return 10; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 나중에 상수로 빼시죵!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 반영했습니다! 의견 감사합니다ㅎㅎ |
||
| } | ||
|
|
||
| const cluster = clusters[index]; | ||
| return selectedClusterIds.includes(cluster.clusterId) ? EXPANDED_ROW_HEIGHT : COLLAPSED_ROW_HEIGHT; | ||
| }; | ||
|
|
||
| const rowRenderer = (props: ListRowProps) => { | ||
| // Render sentinel element | ||
| if (!isLastPage && props.index === clusters.length) { | ||
| return ( | ||
| <div | ||
| ref={sentinelRef} | ||
| key={props.index} | ||
| style={props.style} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| const cluster = clusters[props.index]; | ||
| const isExpanded = selectedClusterIds.includes(cluster.clusterId); | ||
| const { key, ...restProps } = props; | ||
|
|
@@ -80,9 +141,10 @@ const Summary = () => { | |
| ref={listRef} | ||
| width={width} | ||
| height={height} | ||
| rowCount={clusters.length} | ||
| rowCount={isLastPage ? clusters.length : clusters.length + 1} | ||
| rowHeight={getRowHeight} | ||
| rowRenderer={rowRenderer} | ||
| onRowsRendered={handleRowsRendered} | ||
| overscanRowCount={15} | ||
| className="cluster-summary" | ||
| /> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍