-
Notifications
You must be signed in to change notification settings - Fork 0
[widgets] PR 리뷰 기반 UI 동작 보정 #34
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 all commits
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 | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -16,11 +16,9 @@ const searchDebounceMs = 200; | |||||||||||||||||||||
| // 그래야 디바운스 후 재조회로 목록이 로딩 상태에 빠져도 입력창(과 포커스)이 유지된다. | ||||||||||||||||||||||
| function ProblemBoard() { | ||||||||||||||||||||||
| const [keyword, setKeyword] = useState(""); | ||||||||||||||||||||||
| const [selectedProblemStatus, setSelectedProblemStatus] = useState("전체"); | ||||||||||||||||||||||
| const [selectedLanguage, setSelectedLanguage] = useState("전체"); | ||||||||||||||||||||||
| const debouncedKeyword = useDebouncedValue(keyword, searchDebounceMs); | ||||||||||||||||||||||
| const [currentPage, setCurrentPage] = useState(1); | ||||||||||||||||||||||
| const [, startTransition] = useTransition(); | ||||||||||||||||||||||
| const [isPending, startTransition] = useTransition(); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <section className="space-y-4"> | ||||||||||||||||||||||
|
|
@@ -40,18 +38,7 @@ function ProblemBoard() { | |||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| <ProblemSelectFilters | ||||||||||||||||||||||
| selectedProblemStatus={selectedProblemStatus} | ||||||||||||||||||||||
| selectedLanguage={selectedLanguage} | ||||||||||||||||||||||
| onSelectProblemStatus={(problemStatus) => { | ||||||||||||||||||||||
| setSelectedProblemStatus(problemStatus); | ||||||||||||||||||||||
| startTransition(() => setCurrentPage(1)); | ||||||||||||||||||||||
| }} | ||||||||||||||||||||||
| onSelectLanguage={(language) => { | ||||||||||||||||||||||
| setSelectedLanguage(language); | ||||||||||||||||||||||
| startTransition(() => setCurrentPage(1)); | ||||||||||||||||||||||
| }} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| <ProblemSelectFilters /> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| <QueryBoundary | ||||||||||||||||||||||
| loadingFallback={<ProblemBoardResults.Loading />} | ||||||||||||||||||||||
|
|
@@ -60,6 +47,7 @@ function ProblemBoard() { | |||||||||||||||||||||
| <ProblemBoardResults | ||||||||||||||||||||||
| keyword={debouncedKeyword} | ||||||||||||||||||||||
| currentPage={currentPage} | ||||||||||||||||||||||
| isPending={isPending} | ||||||||||||||||||||||
| onPageChange={(page) => startTransition(() => setCurrentPage(page))} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| </QueryBoundary> | ||||||||||||||||||||||
|
|
@@ -70,10 +58,12 @@ function ProblemBoard() { | |||||||||||||||||||||
| function ProblemBoardResults({ | ||||||||||||||||||||||
| keyword, | ||||||||||||||||||||||
| currentPage, | ||||||||||||||||||||||
| isPending, | ||||||||||||||||||||||
| onPageChange, | ||||||||||||||||||||||
| }: { | ||||||||||||||||||||||
| keyword: string; | ||||||||||||||||||||||
| currentPage: number; | ||||||||||||||||||||||
| isPending: boolean; | ||||||||||||||||||||||
| onPageChange: (page: number) => void; | ||||||||||||||||||||||
| }) { | ||||||||||||||||||||||
| const { data: problemPage } = useSuspenseQuery( | ||||||||||||||||||||||
|
|
@@ -89,7 +79,11 @@ function ProblemBoardResults({ | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <> | ||||||||||||||||||||||
| <div className="border-line bg-surface overflow-hidden rounded-lg border"> | ||||||||||||||||||||||
| <div | ||||||||||||||||||||||
| className={`border-line bg-surface overflow-hidden rounded-lg border transition-opacity ${ | ||||||||||||||||||||||
| isPending ? "opacity-60" : "opacity-100" | ||||||||||||||||||||||
| }`} | ||||||||||||||||||||||
|
Comment on lines
+83
to
+85
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. 검색어 입력 시에도 로딩 상태(불투명도 60%)가 올바르게 적용되도록
Suggested change
|
||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <ul className="divide-line divide-y md:hidden"> | ||||||||||||||||||||||
| {problemPage.items.map((row, index) => ( | ||||||||||||||||||||||
| <li key={row.id} className="space-y-3 px-4 py-4"> | ||||||||||||||||||||||
|
|
@@ -216,21 +210,16 @@ function ProblemBoardResults({ | |||||||||||||||||||||
| › | ||||||||||||||||||||||
| </button> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| {isPending ? ( | ||||||||||||||||||||||
| <p className="text-muted text-center text-xs" aria-live="polite"> | ||||||||||||||||||||||
| 문제 목록을 불러오는 중이에요. | ||||||||||||||||||||||
| </p> | ||||||||||||||||||||||
| ) : null} | ||||||||||||||||||||||
|
Comment on lines
+213
to
+217
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. 검색어 입력 시에도 하단의 로딩 안내 메시지가 올바르게 표시되도록
Suggested change
|
||||||||||||||||||||||
| </> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| function ProblemSelectFilters({ | ||||||||||||||||||||||
| selectedProblemStatus, | ||||||||||||||||||||||
| selectedLanguage, | ||||||||||||||||||||||
| onSelectProblemStatus, | ||||||||||||||||||||||
| onSelectLanguage, | ||||||||||||||||||||||
| }: { | ||||||||||||||||||||||
| selectedProblemStatus: string; | ||||||||||||||||||||||
| selectedLanguage: string; | ||||||||||||||||||||||
| onSelectProblemStatus: (problemStatus: string) => void; | ||||||||||||||||||||||
| onSelectLanguage: (language: string) => void; | ||||||||||||||||||||||
| }) { | ||||||||||||||||||||||
| function ProblemSelectFilters() { | ||||||||||||||||||||||
| const levels = ["전체"]; | ||||||||||||||||||||||
| const problemStatuses = ["전체"]; | ||||||||||||||||||||||
| const languages = ["전체"]; | ||||||||||||||||||||||
|
|
@@ -245,15 +234,15 @@ function ProblemSelectFilters({ | |||||||||||||||||||||
| /> | ||||||||||||||||||||||
| <FilterSelect | ||||||||||||||||||||||
| label="문제 상태" | ||||||||||||||||||||||
| value={selectedProblemStatus} | ||||||||||||||||||||||
| value={problemStatuses[0] ?? "전체"} | ||||||||||||||||||||||
| options={problemStatuses} | ||||||||||||||||||||||
| onChange={onSelectProblemStatus} | ||||||||||||||||||||||
| onChange={() => undefined} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| <FilterSelect | ||||||||||||||||||||||
| label="언어" | ||||||||||||||||||||||
| value={selectedLanguage} | ||||||||||||||||||||||
| value={languages[0] ?? "전체"} | ||||||||||||||||||||||
| options={languages} | ||||||||||||||||||||||
| onChange={onSelectLanguage} | ||||||||||||||||||||||
| onChange={() => undefined} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
|
@@ -279,7 +268,7 @@ function FilterSelect({ | |||||||||||||||||||||
| value={value} | ||||||||||||||||||||||
| onChange={(event) => onChange(event.target.value)} | ||||||||||||||||||||||
| disabled={isDisabled} | ||||||||||||||||||||||
| className="text-body absolute inset-0 w-full appearance-none rounded-md bg-transparent px-5 pr-14 text-transparent outline-none disabled:cursor-default" | ||||||||||||||||||||||
| className="absolute inset-0 w-full appearance-none rounded-md bg-transparent opacity-0 outline-none disabled:cursor-default" | ||||||||||||||||||||||
| aria-label={label} | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| {options.map((option) => ( | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useId, useMemo, useRef, useState } from "react"; | ||
| import { useEffect, useId, useRef, useState } from "react"; | ||
| import { useMutation, useQueryClient } from "@tanstack/react-query"; | ||
| import { memberKeys } from "@/entities/member/api/member-keys"; | ||
| import type { MemberProfile } from "@/entities/member/model/member-profile"; | ||
|
|
@@ -20,19 +20,21 @@ export function ProfileEditDialog({ open, profile, onClose }: ProfileEditDialogP | |
| const inputId = useId(); | ||
| const inputRef = useRef<HTMLInputElement>(null); | ||
| const [selectedFile, setSelectedFile] = useState<File | null>(null); | ||
|
|
||
| const previewUrl = useMemo(() => { | ||
| if (!selectedFile) return profile.profileImageUrl; | ||
| return URL.createObjectURL(selectedFile); | ||
| }, [profile.profileImageUrl, selectedFile]); | ||
| const [previewUrl, setPreviewUrl] = useState(profile.profileImageUrl); | ||
|
|
||
| useEffect(() => { | ||
| if (!selectedFile) { | ||
| setPreviewUrl(profile.profileImageUrl); | ||
| return; | ||
| } | ||
|
|
||
| const objectUrl = URL.createObjectURL(selectedFile); | ||
| setPreviewUrl(objectUrl); | ||
|
|
||
| return () => { | ||
| if (selectedFile && previewUrl.startsWith("blob:")) { | ||
| URL.revokeObjectURL(previewUrl); | ||
| } | ||
| URL.revokeObjectURL(objectUrl); | ||
| }; | ||
| }, [previewUrl, selectedFile]); | ||
| }, [profile.profileImageUrl, selectedFile]); | ||
|
Comment on lines
25
to
+37
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. 현재 하나의 역할을 명확히 분리하여, 프로필 이미지 URL 동기화와 선택된 파일의 Object URL 생성/해제 로직을 두 개의 독립된 |
||
|
|
||
| useEffect(() => { | ||
| if (!open) { | ||
|
|
@@ -113,7 +115,7 @@ export function ProfileEditDialog({ open, profile, onClose }: ProfileEditDialogP | |
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| void saveProfileImageMutation.mutateAsync(); | ||
| saveProfileImageMutation.mutate(); | ||
| }} | ||
| disabled={!selectedFile || saveProfileImageMutation.isPending} | ||
| className="bg-accent text-foreground-inverse inline-flex h-11 items-center justify-center gap-2 rounded-xl text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50" | ||
|
|
||
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.
useDebouncedValue내부에서 전역startTransition을 사용하여 디바운스된 검색어를 업데이트하기 때문에,ProblemBoard컴포넌트의useTransition에서 반환된isPending은 검색어 변경으로 인한 재조회(fetching) 상태를 감지하지 못합니다.이로 인해 사용자가 검색어를 입력하고 결과를 기다리는 동안 화면에 아무런 로딩 표시(불투명도 조절 및 하단 안내 메시지)가 나타나지 않아 UI가 멈춘 것처럼 보일 수 있습니다.
이를 해결하기 위해
useSuspenseQuery가 반환하는isFetching상태를 가져와isPending과 함께 결합한showPending변수를 정의하여 사용하는 것을 권장합니다. 이렇게 하면 페이지네이션 전환뿐만 아니라 검색어 입력에 따른 데이터 조회 중에도 로딩 상태가 올바르게 표시됩니다.추천하는 변경 사항:
useSuspenseQuery에서isFetching을 구조 분해 할당으로 가져오고,showPending변수를 정의합니다:isPending대신showPending을 사용하도록 수정합니다.