11'use client' ;
22
3+ import { useQueryClient } from '@tanstack/react-query' ;
34import { ContentBadge , ThemeColorsToken , Typography } from '@wanteddev/wds' ;
45import { IconClose } from '@wanteddev/wds-icon' ;
6+ import { useRef } from 'react' ;
57
68import { TagItem } from '@/api/Api' ;
79import { ColorType } from '@/constants/badgeColor' ;
810import { useErrorToast } from '@/hooks/useErrorToast' ;
11+ import { tagKeys } from '@/queries/keys/tagKeys' ;
912import {
1013 useAddNodeTagMutation ,
1114 useCreateTagMutation ,
@@ -45,15 +48,19 @@ export function TagField({ projectId, nodeId, initialTags }: TagFieldProps) {
4548 setOutsideCloseDisabled,
4649 } = usePickerState ( ) ;
4750
51+ const queryClient = useQueryClient ( ) ;
4852 const showErrorToast = useErrorToast ( ) ;
4953 const { tags, yAddTag, yRemoveTag } = useYjsTags ( initialTags ) ;
5054 const { data : allTags = [ ] } = useProjectTagsQuery ( projectId ) ;
5155 const { mutate : addTag } = useAddNodeTagMutation ( projectId , nodeId ) ;
5256 const { mutate : removeTag } = useRemoveNodeTagMutation ( projectId , nodeId ) ;
53- const { mutate : createTag , isPending : isCreating } = useCreateTagMutation ( projectId ) ;
57+ const { mutate : createTag } = useCreateTagMutation ( projectId ) ;
5458 const { mutate : deleteTag } = useDeleteTagMutation ( projectId ) ;
5559 const { mutate : updateTagColor } = useUpdateTagColorMutation ( projectId ) ;
5660
61+ const isSubmittingRef = useRef ( false ) ;
62+ const pendingEnterRef = useRef ( false ) ;
63+
5764 const assignedTagIds = new Set ( tags . map ( ( t ) => t . tagId ) ) ;
5865
5966 const handleAdd = ( tag : TagItem ) => {
@@ -121,30 +128,52 @@ export function TagField({ projectId, nodeId, initialTags }: TagFieldProps) {
121128 ! allTags . some ( ( t ) => t . name ?. toLowerCase ( ) === inputValue . trim ( ) . toLowerCase ( ) ) ;
122129 const totalItems = filteredTags . length + ( canCreate ? 1 : 0 ) ;
123130
124- const handleCreateTag = ( ) => {
125- const trimmed = inputValue . trim ( ) ;
126- if ( ! trimmed || isCreating ) return ;
131+ const handleCreateTag = ( overrideValue ?: string ) => {
132+ if ( isSubmittingRef . current ) return ;
133+ const trimmed = ( overrideValue ?? inputValue ) . trim ( ) ;
134+ if ( ! trimmed ) return ;
127135
128136 const exactMatch = allTags . find ( ( t ) => t . name ?. toLowerCase ( ) === trimmed . toLowerCase ( ) ) ;
129137 if ( exactMatch ) {
130138 if ( ! assignedTagIds . has ( exactMatch . tagId ) ) handleAdd ( exactMatch ) ;
131139 return ;
132140 }
133141
142+ isSubmittingRef . current = true ;
134143 createTag (
135144 { name : trimmed , color : randomColor ( ) } ,
136145 {
137- onSuccess : ( newTag ) => {
138- if ( ! newTag ?. tagId ) return ;
139- handleAdd ( newTag ) ;
146+ onSuccess : async ( newTag ) => {
147+ isSubmittingRef . current = false ;
148+ if ( newTag ?. tagId ) {
149+ handleAdd ( newTag ) ;
150+ return ;
151+ }
152+ // API가 tagId를 응답에 포함하지 않는 경우: tags 목록을 refetch 후 이름으로 탐색
153+ await queryClient . refetchQueries ( { queryKey : tagKeys . list ( projectId ) } ) ;
154+ const freshTags = queryClient . getQueryData < TagItem [ ] > ( tagKeys . list ( projectId ) ) ;
155+ const created = freshTags ?. find ( ( t ) => t . name ?. toLowerCase ( ) === trimmed . toLowerCase ( ) ) ;
156+ if ( created ?. tagId ) handleAdd ( created ) ;
157+ } ,
158+ onError : ( err ) => {
159+ isSubmittingRef . current = false ;
160+ showErrorToast ( err , '태그 생성에 실패했어요.' ) ;
140161 } ,
141- onError : ( err ) => showErrorToast ( err , '태그 생성에 실패했어요.' ) ,
142162 } ,
143163 ) ;
144164 } ;
145165
166+ const handleCompositionEnd = ( e : React . CompositionEvent < HTMLInputElement > ) => {
167+ if ( ! pendingEnterRef . current ) return ;
168+ pendingEnterRef . current = false ;
169+ if ( selectedIndex >= 0 && selectedIndex < filteredTags . length ) {
170+ handleAdd ( filteredTags [ selectedIndex ] ) ;
171+ } else {
172+ handleCreateTag ( e . currentTarget . value ) ;
173+ }
174+ } ;
175+
146176 const handleKeyDown = ( e : React . KeyboardEvent < HTMLInputElement > ) => {
147- if ( e . nativeEvent . isComposing ) return ;
148177 if ( e . key === 'ArrowDown' ) {
149178 e . preventDefault ( ) ;
150179 setSelectedIndex ( ( prev ) => Math . min ( prev + 1 , totalItems - 1 ) ) ;
@@ -153,6 +182,10 @@ export function TagField({ projectId, nodeId, initialTags }: TagFieldProps) {
153182 setSelectedIndex ( ( prev ) => Math . max ( prev - 1 , - 1 ) ) ;
154183 } else if ( e . key === 'Enter' ) {
155184 e . preventDefault ( ) ;
185+ if ( e . nativeEvent . isComposing ) {
186+ pendingEnterRef . current = true ;
187+ return ;
188+ }
156189 if ( selectedIndex >= 0 && selectedIndex < filteredTags . length ) {
157190 handleAdd ( filteredTags [ selectedIndex ] ) ;
158191 } else if ( selectedIndex === filteredTags . length && canCreate ) {
@@ -202,6 +235,7 @@ export function TagField({ projectId, nodeId, initialTags }: TagFieldProps) {
202235 value = { inputValue }
203236 onChange = { handleInputChange }
204237 onKeyDown = { handleKeyDown }
238+ onCompositionEnd = { handleCompositionEnd }
205239 className = "text-label-normal min-w-20 flex-1 border-0 bg-transparent text-sm outline-none"
206240 onClick = { ( e ) => e . stopPropagation ( ) }
207241 />
@@ -250,7 +284,7 @@ export function TagField({ projectId, nodeId, initialTags }: TagFieldProps) {
250284 < button
251285 type = "button"
252286 onMouseDown = { ( e ) => e . preventDefault ( ) }
253- onClick = { handleCreateTag }
287+ onClick = { ( ) => handleCreateTag ( ) }
254288 className = { `text-label-alternative flex w-full items-center gap-2 px-3 py-2 ${ selectedIndex === filteredTags . length ? 'bg-gray-100' : 'bg-white hover:bg-gray-50' } ` }
255289 >
256290 < Typography variant = "caption1" >
0 commit comments