Skip to content

Commit 22aa7ef

Browse files
authored
MTB-364: refactor: implement BotDirectory component (#373)
* refactor: implement BotDirectory component * fix: change login into one pageOptions * rename Main to BotSearchResult
1 parent 301903d commit 22aa7ef

File tree

13 files changed

+548
-546
lines changed

13 files changed

+548
-546
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { useMemo } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { Pagination, Flex, Spin } from 'antd';
4+
import { AppstoreOutlined, BarsOutlined } from '@ant-design/icons';
5+
import { cn } from '@app/utils/cn';
6+
import Button from '@app/mtb-ui/Button';
7+
import MtbTypography from '@app/mtb-ui/Typography/Typography';
8+
import SingleSelect from '@app/mtb-ui/SingleSelect';
9+
import BotGridItem from '@app/components/BotGridItem/BotGridItem';
10+
import BotListItem from '@app/components/BotListItem/BotListItem';
11+
import { IBotDirectoryProps } from './BotDirectory.types';
12+
import { ViewMode } from '@app/enums/viewMode.enum';
13+
import { PAGE_OPTIONS, GRID_CLASSES } from '@app/constants/BotDirectory.constant';
14+
import { BotDirectoryVariant } from '@app/enums/BotDirectory.enum';
15+
16+
function BotDirectory({
17+
data,
18+
isLoading,
19+
currentPage,
20+
pageSize,
21+
totalCount,
22+
onPageChange,
23+
onPageSizeChange,
24+
pageSizeOptions,
25+
sortOption,
26+
onSortChange,
27+
sortOptions,
28+
viewMode,
29+
onViewModeChange,
30+
variant = BotDirectoryVariant.FULL,
31+
className,
32+
isPublic = true,
33+
showSort = true,
34+
showTitle = true,
35+
onRefresh,
36+
}: IBotDirectoryProps) {
37+
const { t } = useTranslation(['home_page', 'components']);
38+
39+
const finalPageSizeOptions = useMemo(() => {
40+
return pageSizeOptions || PAGE_OPTIONS;
41+
}, [pageSizeOptions]);
42+
43+
const paginationSelectOptions = useMemo(() => {
44+
return finalPageSizeOptions.map((val) => ({
45+
value: val,
46+
label: t('homepage.bots_per_page', { count: val }),
47+
}));
48+
}, [finalPageSizeOptions, t]);
49+
50+
const listContainerClass = useMemo(() => {
51+
if (viewMode === ViewMode.GRID) {
52+
return cn('grid', GRID_CLASSES[variant]);
53+
}
54+
return 'flex flex-col gap-4';
55+
}, [viewMode, variant]);
56+
57+
const totalPages = Math.ceil(totalCount / pageSize);
58+
59+
return (
60+
<div className={cn('w-full flex flex-col', className)}>
61+
<Flex justify="space-between" wrap="wrap" align='center' className="gap-4 pt-8 pb-8">
62+
<div className='flex-shrink-0'>
63+
{totalCount > 0 && (
64+
<>
65+
{showTitle && (
66+
<MtbTypography variant='h3' customClassName="mb-1">
67+
{t('homepage.mezon_bots')}
68+
</MtbTypography>
69+
)}
70+
<MtbTypography variant='h5' weight='normal'>
71+
{t('homepage.showing_page', { current: currentPage, total: totalPages || 1 })}
72+
</MtbTypography>
73+
</>
74+
)}
75+
</div>
76+
77+
<div className="flex flex-wrap items-center gap-4 sm:gap-6 justify-end">
78+
{showSort && sortOptions && sortOption && onSortChange && (
79+
<>
80+
<div className="flex items-center gap-2">
81+
<span className="text-secondary whitespace-nowrap hidden sm:inline-block">
82+
{t('homepage.sort_title')}:
83+
</span>
84+
<SingleSelect
85+
getPopupContainer={(trigger) => trigger.parentElement}
86+
options={sortOptions}
87+
value={sortOption}
88+
onChange={onSortChange}
89+
size='large'
90+
placeholder={t('homepage.sort_placeholder')}
91+
className='min-w-[160px]'
92+
dropdownStyle={{ width: 'max-content' }}
93+
data-e2e="selectSortOptions"
94+
/>
95+
</div>
96+
<div className="hidden sm:block h-6 w-[1px] bg-border"></div>
97+
</>
98+
)}
99+
100+
<div className="flex items-center gap-2">
101+
<span className="text-secondary whitespace-nowrap hidden sm:inline-block">{t('homepage.pagination_title')}:</span>
102+
<SingleSelect
103+
getPopupContainer={(trigger) => trigger.parentElement}
104+
onChange={onPageSizeChange}
105+
options={paginationSelectOptions}
106+
value={paginationSelectOptions.find(o => o.value === pageSize) || paginationSelectOptions[0]}
107+
size='large'
108+
className='w-[70px]'
109+
data-e2e="selectPageOptions"
110+
/>
111+
</div>
112+
113+
<div className="flex bg-container-secondary p-1 rounded-lg border border-border">
114+
<Button
115+
variant="text"
116+
color="default"
117+
icon={<BarsOutlined />}
118+
onClick={() => onViewModeChange(ViewMode.LIST)}
119+
className={cn(
120+
"min-w-[40px] px-3",
121+
viewMode === ViewMode.LIST
122+
? '!bg-heading !text-primary !shadow-sm hover:!text-accent-primary'
123+
: '!text-secondary hover:!text-accent-primary'
124+
)}
125+
size="middle"
126+
/>
127+
<Button
128+
variant="text"
129+
color="default"
130+
icon={<AppstoreOutlined />}
131+
onClick={() => onViewModeChange(ViewMode.GRID)}
132+
className={cn(
133+
"min-w-[40px] px-3",
134+
viewMode === ViewMode.GRID
135+
? '!bg-heading !text-primary !shadow-sm hover:!text-accent-primary'
136+
: '!text-secondary hover:!text-accent-primary'
137+
)}
138+
size="middle"
139+
/>
140+
</div>
141+
</div>
142+
</Flex>
143+
144+
{isLoading ? (
145+
<div className='flex items-center justify-center h-64'>
146+
<Spin size='large' />
147+
</div>
148+
) : data.length > 0 ? (
149+
<div className={listContainerClass}>
150+
{data.map((bot) => (
151+
viewMode === ViewMode.GRID ? (
152+
<BotGridItem key={bot.id} data={bot} isPublic={isPublic} onRefresh={onRefresh} />
153+
) : (
154+
<BotListItem key={bot.id} readonly={true} data={bot} onRefresh={onRefresh} />
155+
)
156+
))}
157+
</div>
158+
) : (
159+
<MtbTypography variant='h4' weight='normal' customClassName='!text-center !block !text-secondary py-12'>
160+
{t('homepage.no_result')}
161+
</MtbTypography>
162+
)}
163+
164+
{totalCount > 0 && (
165+
<div className='flex flex-col items-center gap-5 pt-10'>
166+
<div className='flex flex-col items-center relative w-full'>
167+
<Pagination
168+
onChange={onPageChange}
169+
pageSize={pageSize}
170+
showSizeChanger={false}
171+
current={currentPage}
172+
total={totalCount}
173+
/>
174+
</div>
175+
</div>
176+
)}
177+
</div>
178+
);
179+
}
180+
181+
export default BotDirectory;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { GetMezonAppDetailsResponse } from '@app/services/api/mezonApp/mezonApp.types';
2+
import { IOption } from '@app/mtb-ui/SingleSelect';
3+
import { ViewMode } from '@app/enums/viewMode.enum';
4+
import { BotDirectoryVariant } from '@app/enums/BotDirectory.enum';
5+
6+
export interface IBotDirectoryProps {
7+
data: GetMezonAppDetailsResponse[];
8+
isLoading?: boolean;
9+
currentPage: number;
10+
pageSize: number;
11+
totalCount: number;
12+
onPageChange: (page: number) => void;
13+
onPageSizeChange: (option: IOption) => void;
14+
pageSizeOptions?: number[];
15+
sortOption?: IOption;
16+
onSortChange?: (option: IOption) => void;
17+
sortOptions?: IOption[];
18+
viewMode: ViewMode;
19+
onViewModeChange: (mode: ViewMode) => void;
20+
variant?: BotDirectoryVariant;
21+
className?: string;
22+
isPublic?: boolean;
23+
showSort?: boolean;
24+
showTitle?: boolean;
25+
onRefresh?: () => void;
26+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import BotDirectory from '@app/components/BotDirectory/BotDirectory'
2+
import { useBotDirectory } from '@app/hook/useBotDirectory'
3+
import { MezonAppType } from '@app/enums/mezonAppType.enum'
4+
import SearchBar from '@app/mtb-ui/SearchBar/SearchBar'
5+
import MtbTypography from '@app/mtb-ui/Typography/Typography'
6+
import { useLazyMezonAppControllerSearchMezonAppQuery } from '@app/services/api/mezonApp/mezonApp'
7+
import { useLazyTagControllerGetTagsQuery } from '@app/services/api/tag/tag'
8+
import { RootState } from '@app/store'
9+
import { IMezonAppStore } from '@app/store/mezonApp'
10+
import { ApiError } from '@app/types/API.types'
11+
import { getPageFromParams } from '@app/utils/uri'
12+
import { Divider } from 'antd'
13+
import { useEffect, useMemo, useRef, useState } from 'react'
14+
import { useSelector } from 'react-redux'
15+
import { useTranslation } from 'react-i18next'
16+
import { useNavigate, useSearchParams } from 'react-router-dom'
17+
import { toast } from 'react-toastify'
18+
import { IBotSearchResultProps } from './BotSearchResult.types'
19+
import { ViewMode } from '@app/enums/viewMode.enum'
20+
import { BotDirectoryVariant } from '@app/enums/BotDirectory.enum'
21+
22+
function BotSearchResult({ isSearchPage = false }: IBotSearchResultProps) {
23+
const { t } = useTranslation(['home_page'])
24+
const navigate = useNavigate()
25+
const mainRef = useRef<HTMLDivElement>(null)
26+
const { mezonApp } = useSelector<RootState, IMezonAppStore>((s) => s.mezonApp)
27+
const [getTagList] = useLazyTagControllerGetTagsQuery()
28+
const [getMezonApp, { isError, error, isFetching }] = useLazyMezonAppControllerSearchMezonAppQuery()
29+
30+
const [searchParams, setSearchParams] = useSearchParams()
31+
const queryParam = searchParams.get('q');
32+
const pageFromUrl = getPageFromParams(searchParams);
33+
const {
34+
page,
35+
setPage,
36+
pageSize,
37+
viewMode,
38+
sortField,
39+
sortOrder,
40+
selectedSort,
41+
sortOptions,
42+
handleViewModeChange,
43+
handlePageSizeChange,
44+
handleSortChange
45+
} = useBotDirectory({ initialViewMode: ViewMode.LIST, initialPage: pageFromUrl });
46+
47+
const defaultSearchQuery = useMemo(() => queryParam?.trim() || '', [queryParam]);
48+
const defaultTagIds = useMemo(
49+
() => searchParams.get('tags')?.split(',').filter(Boolean) || [],
50+
[searchParams.get('tags')]
51+
)
52+
const defaultType = searchParams?.get('type') as MezonAppType | undefined
53+
54+
const [isInitialized, setIsInitialized] = useState<boolean>(false)
55+
const [searchQuery, setSearchQuery] = useState<string>(searchParams.get('q')?.trim() || '')
56+
const [tagIds, setTagIds] = useState<string[]>(searchParams.get('tags')?.split(',').filter(Boolean) || [])
57+
const [type, setType] = useState<MezonAppType | undefined>(defaultType)
58+
59+
useEffect(() => {
60+
getTagList()
61+
}, [])
62+
63+
useEffect(() => {
64+
const newPage = getPageFromParams(searchParams)
65+
if (newPage !== page) {
66+
setPage(newPage)
67+
}
68+
}, [searchParams])
69+
70+
useEffect(() => {
71+
if (!isSearchPage) return;
72+
setSearchQuery(defaultSearchQuery);
73+
setTagIds(defaultTagIds);
74+
setType(defaultType);
75+
76+
if (isInitialized || defaultSearchQuery || defaultTagIds.length || defaultType) {
77+
searchMezonAppList(defaultSearchQuery, defaultTagIds, defaultType);
78+
}
79+
setIsInitialized(true);
80+
}, [searchParams, page, pageSize, selectedSort]);
81+
82+
useEffect(() => {
83+
if (isError && error) {
84+
const apiError = error as ApiError
85+
if (apiError?.status === 404 || apiError?.data?.statusCode === 404) {
86+
navigate('/404')
87+
} else {
88+
toast.error(apiError?.data?.message)
89+
}
90+
}
91+
}, [isError, error])
92+
93+
useEffect(() => {
94+
searchMezonAppList(searchQuery, tagIds, type)
95+
}, [page, pageSize, isSearchPage, selectedSort])
96+
97+
const searchMezonAppList = (searchQuery?: string, tagIds?: string[], type?: MezonAppType) => {
98+
getMezonApp({
99+
search: isSearchPage ? searchQuery : undefined,
100+
tags: tagIds && tagIds.length ? tagIds : undefined,
101+
type,
102+
pageNumber: page,
103+
pageSize: pageSize,
104+
sortField: sortField,
105+
sortOrder: sortOrder
106+
})
107+
}
108+
109+
const handleMainPageChange = (newPage: number) => {
110+
setPage(newPage);
111+
const newParams = new URLSearchParams(searchParams)
112+
newParams.set('page', newPage.toString())
113+
setSearchParams(newParams)
114+
if (mainRef.current) {
115+
mainRef.current.scrollIntoView({ behavior: 'auto' })
116+
}
117+
}
118+
119+
const onPressSearch = (text: string, tagIds?: string[], type?: MezonAppType) => {
120+
setSearchQuery(text)
121+
setTagIds(tagIds ?? [])
122+
setType(type)
123+
if (page !== 1) {
124+
setPage(1)
125+
return
126+
}
127+
searchMezonAppList(text, tagIds, type)
128+
}
129+
130+
return (
131+
<div ref={mainRef} className='flex flex-col justify-center pt-8 pb-12 max-w-6xl mx-auto relative z-1 md:px-6 px-2 w-full'>
132+
<Divider variant='solid' className='!border-bg-secondary'>
133+
<MtbTypography variant='h1' customClassName='max-md:whitespace-normal'>
134+
{t('homepage.explore_title')}
135+
</MtbTypography>
136+
</Divider>
137+
<div className='pt-3'>
138+
<SearchBar
139+
onSearch={(val, tagIds, type) => onPressSearch(val ?? '', tagIds, type)}
140+
defaultValue={searchQuery}
141+
isResultPage={isSearchPage}
142+
></SearchBar>
143+
</div>
144+
145+
<div className='pt-8'>
146+
<BotDirectory
147+
variant={BotDirectoryVariant.FULL}
148+
data={mezonApp?.data || []}
149+
isLoading={isFetching}
150+
currentPage={page}
151+
pageSize={pageSize}
152+
totalCount={mezonApp?.totalCount || 0}
153+
viewMode={viewMode}
154+
onPageChange={handleMainPageChange}
155+
onPageSizeChange={handlePageSizeChange}
156+
onViewModeChange={handleViewModeChange}
157+
sortOption={selectedSort}
158+
sortOptions={sortOptions}
159+
onSortChange={handleSortChange}
160+
isPublic={true}
161+
/>
162+
</div>
163+
</div>
164+
)
165+
}
166+
167+
export default BotSearchResult
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface IBotSearchResultProps {
2+
isSearchPage?: boolean
3+
}

0 commit comments

Comments
 (0)