Skip to content

Commit 5881db9

Browse files
committed
[WRFE0-70](feat): 카테고리, 태그 API연동 중
1 parent 52e7e89 commit 5881db9

File tree

8 files changed

+330
-55
lines changed

8 files changed

+330
-55
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type {CategoryItem} from '@/entities/category/type';
2+
import apiClient from '@/shared/api/apiClient';
3+
import {useQuery} from '@tanstack/react-query';
4+
5+
const fetchCategories = async () => {
6+
const response = await apiClient.get<{items: CategoryItem[]}>('/categories', {
7+
withAuth: true,
8+
});
9+
10+
return response.data;
11+
};
12+
13+
export const useCategoryQuery = () =>
14+
useQuery({
15+
queryKey: ['categories'],
16+
queryFn: () => fetchCategories(),
17+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type {
2+
CreateBody,
3+
CreateTag,
4+
TagList,
5+
TagQueryParams,
6+
} from '../config/type';
7+
import type {CursorPagination} from '@/entities/pagination/type';
8+
import apiClient from '@/shared/api/apiClient';
9+
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
10+
11+
const fetchTags = async ({itemsPerPage, uuid, prefix}: TagQueryParams) => {
12+
const response = await apiClient.get<{
13+
items: TagList[];
14+
pagination: CursorPagination;
15+
}>('/tags', {
16+
withAuth: true,
17+
params: {
18+
itemsPerPage: itemsPerPage,
19+
uuid: uuid,
20+
prefix: prefix,
21+
},
22+
});
23+
return response.data;
24+
};
25+
26+
export const useTagQuery = ({itemsPerPage, uuid, prefix}: TagQueryParams) =>
27+
useQuery({
28+
queryKey: ['tags', itemsPerPage, uuid, prefix],
29+
queryFn: () => fetchTags({itemsPerPage, uuid, prefix}),
30+
});
31+
32+
const createTag = async (tag: string) => {
33+
const response = await apiClient.post<CreateTag, CreateBody>('/tags', {
34+
body: {name: tag},
35+
withAuth: true,
36+
});
37+
return response.data;
38+
};
39+
40+
export const useCreateTag = () => {
41+
const queryClient = useQueryClient();
42+
return useMutation({
43+
mutationFn: (tag: string) => createTag(tag),
44+
onSuccess: () => {
45+
queryClient.invalidateQueries({queryKey: ['tags']});
46+
},
47+
});
48+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export interface TagList {
2+
id: number;
3+
name: string;
4+
uuid: string;
5+
}
6+
7+
export interface TagQueryParams {
8+
itemsPerPage: number;
9+
uuid: string;
10+
prefix: string;
11+
}
12+
13+
export interface CreateTag {
14+
id: number;
15+
name: string;
16+
}
17+
18+
export interface CreateBody {
19+
name: string;
20+
}
21+
22+
export interface CreateTagAndAddToList {
23+
tag: string;
24+
createTag: (tag: string) => Promise<CreateTag>;
25+
tagIds: number[];
26+
onTagChange: (newTags: number[]) => void;
27+
}
28+
29+
export interface AddTagFromUserInputIfDuplicate {
30+
tag: string;
31+
serverTags?: CreateTag[];
32+
tagIds: number[];
33+
onTagChange: (newTags: number[]) => void;
34+
}
35+
36+
export interface AddTagFromServerData {
37+
id: number;
38+
tagIds: number[];
39+
onTagChange: (newTags: number[]) => void;
40+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type {
2+
AddTagFromServerData,
3+
AddTagFromUserInputIfDuplicate,
4+
CreateTagAndAddToList,
5+
} from './type';
6+
7+
/**
8+
* 새 태그 서버에서 받아서 추가
9+
*/
10+
export const createTagAndAddToList = async ({
11+
tag,
12+
createTag,
13+
tagIds,
14+
onTagChange,
15+
}: CreateTagAndAddToList) => {
16+
try {
17+
const response = await createTag(tag);
18+
onTagChange([...tagIds, response.id]);
19+
} catch (error) {
20+
console.error('error:', error);
21+
}
22+
};
23+
24+
/**
25+
* id를 서버에서 받아온 태그 추가하는 곳
26+
* but 서버에서 받아온 리스트중 사용자 입력값과 중복된 값은 리스트에서 제거하고 맨 위로 올리기 때문에
27+
* id를 찾아서 제출태그 리스트에 넣어줌
28+
*/
29+
export const addTagFromUserInputIfDuplicate = ({
30+
tag,
31+
serverTags,
32+
tagIds,
33+
onTagChange,
34+
}: AddTagFromUserInputIfDuplicate) => {
35+
const foundid = serverTags?.find(item => item.name === tag)?.id ?? 0;
36+
onTagChange([...tagIds, foundid]);
37+
};
38+
39+
/**
40+
* id를 서버에서 받아온 태그 추가하는 곳
41+
*/
42+
export const addTagFromServerData = ({
43+
id,
44+
tagIds,
45+
onTagChange,
46+
}: AddTagFromServerData) => {
47+
onTagChange([...tagIds, id]);
48+
};

apps/front/wraffle-webview/src/shared/ui/tag/Tags.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,22 @@ import {Tag} from '@wraffle/ui';
33

44
interface TagsProps {
55
tags: string[];
6+
setTags: (tags: string[]) => void;
67
className: string;
78
}
89

9-
export const Tags = ({tags, className}: TagsProps) => (
10-
<div className={clsx('flex gap-1.5', className)}>
11-
{tags.map(tag => (
12-
<Tag handleRemoveTag={tag => tag} key={tag}>
13-
{tag}
14-
</Tag>
15-
))}
16-
</div>
17-
);
10+
export const Tags = ({tags, setTags, className}: TagsProps) => {
11+
const handleRemoveTag = (tag: string) => {
12+
setTags(tags.filter(t => t !== tag));
13+
};
14+
15+
return (
16+
<div className={clsx('flex gap-1.5', className)}>
17+
{tags.map(tag => (
18+
<Tag handleRemoveTag={tag => handleRemoveTag(tag)} key={tag}>
19+
{tag}
20+
</Tag>
21+
))}
22+
</div>
23+
);
24+
};
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {
2+
useCreateTag,
3+
useTagQuery,
4+
} from '../../../../../features/product/tag/api/useTagQuery';
5+
import {useEffect, useState} from 'react';
6+
import {
7+
addTagFromServerData,
8+
addTagFromUserInputIfDuplicate,
9+
createTagAndAddToList,
10+
} from '@/features/product/tag/config/utils';
11+
import {useDebounce} from '@/shared/hook';
12+
import {InputWithSearchIcon} from '@/shared/ui/input/InputWithSearchIcon';
13+
import {Tags} from '@/shared/ui/tag/Tags';
14+
import {TAG_LIMIT} from '@/shared/util';
15+
import {Label} from '@wraffle/ui';
16+
17+
interface TagList {
18+
id: number;
19+
name: string;
20+
}
21+
22+
interface TagSectionProps {
23+
tagIds: number[];
24+
onTagChange: (newTags: number[]) => void;
25+
}
26+
27+
export const TagSection = ({tagIds, onTagChange}: TagSectionProps) => {
28+
const [inputValue, setInputValue] = useState(''); // 입력하는 값
29+
const debouncedInputValue = useDebounce(inputValue);
30+
31+
const [autocompleteTags, setAutocompleteTags] = useState<TagList[]>([]); // 서버에서 받아오는 태그 리스트
32+
33+
const [isNew, setIsNew] = useState<boolean>(true); // 새로 생성할지 말지
34+
35+
const [selectedTagNames, setSelectedTagNames] = useState<string[]>([]); // 화면에 보여지는 태그 리스트
36+
37+
const {mutateAsync: createTag} = useCreateTag();
38+
const {isPending: isQueryPending, data} = useTagQuery({
39+
itemsPerPage: 100, //
40+
uuid: '',
41+
prefix: debouncedInputValue.toLocaleLowerCase(),
42+
});
43+
const serverTags = data?.items;
44+
45+
useEffect(() => {
46+
if (!serverTags) return;
47+
48+
setIsNew(true);
49+
50+
const isExistingTag = serverTags.some(tag => tag.name === inputValue);
51+
52+
if (isExistingTag) {
53+
const suggestions = serverTags.filter(tag => tag.name !== inputValue);
54+
setAutocompleteTags(suggestions);
55+
setIsNew(false);
56+
} else {
57+
setAutocompleteTags(serverTags);
58+
}
59+
}, [data, inputValue, serverTags]);
60+
61+
const handleAddTag = async (tag: string, id: number) => {
62+
if (selectedTagNames.length >= 5 || selectedTagNames.includes(tag)) {
63+
setInputValue('');
64+
return;
65+
}
66+
67+
if (isNew && id === 0) {
68+
createTagAndAddToList({tag, createTag, tagIds, onTagChange});
69+
} else if (!isNew && id === 0) {
70+
addTagFromUserInputIfDuplicate({tag, serverTags, tagIds, onTagChange});
71+
} else {
72+
addTagFromServerData({id, tagIds, onTagChange});
73+
}
74+
75+
setSelectedTagNames(prev => [...prev, tag]);
76+
setInputValue('');
77+
};
78+
79+
return (
80+
<div className='relative'>
81+
<Label className='text-xl font-bold'>태그</Label>
82+
83+
<InputWithSearchIcon
84+
placeholder='태그명을 입력해주세요. (최대 5개)'
85+
onClick={() => {}}
86+
maxLength={TAG_LIMIT}
87+
disabled={selectedTagNames.length === 5}
88+
value={inputValue}
89+
onChange={e => setInputValue(e.target.value.trim())}
90+
/>
91+
92+
{inputValue && (
93+
<div className='absolute z-10 mt-2 flex max-h-40 w-full flex-col gap-1 overflow-y-auto rounded-md border border-solid bg-[#FAFAFB] px-3 py-2 text-sm'>
94+
<p
95+
className='text-[#ADB5BD]'
96+
onClick={() => handleAddTag(inputValue, 0)}
97+
>
98+
# {inputValue}
99+
</p>
100+
{isQueryPending ? (
101+
<div className='px-2 pb-3 pt-2 text-sm text-gray-500'>
102+
검색 중...
103+
</div>
104+
) : (
105+
autocompleteTags.length > 0 && (
106+
<>
107+
{autocompleteTags.map(({id, name}) => (
108+
<div key={id} onClick={() => handleAddTag(name, id)}>
109+
<span className='text-[#ADB5BD]'>
110+
# {name.slice(0, inputValue.length)}
111+
</span>
112+
113+
<span className='text-black'>
114+
{name.slice(inputValue.length)}
115+
</span>
116+
</div>
117+
))}
118+
</>
119+
)
120+
)}
121+
</div>
122+
)}
123+
124+
<Tags
125+
tags={selectedTagNames}
126+
setTags={setSelectedTagNames}
127+
className='mt-2 h-20 flex-wrap'
128+
/>
129+
</div>
130+
);
131+
};

0 commit comments

Comments
 (0)