From 5c51ab47b1f5241a7818e80c772f4190dd9bb92d Mon Sep 17 00:00:00 2001 From: guramidev Date: Mon, 26 Jul 2021 17:15:38 +0400 Subject: [PATCH] repository - functionality --- src/App.tsx | 2 + src/api/github/repository/api.ts | 19 +++++ src/api/github/repository/enums.ts | 12 +++ src/api/github/repository/types.ts | 7 ++ src/features/dashboard/Dashboard.tsx | 17 ++++ .../features/repositories/Repositories.tsx | 19 +++++ .../routes/Repositories/Repositories.tsx | 31 +++++++ .../RepositoryGrid/RepositoryGrid.tsx | 26 ++++++ .../RepositoryGridItem/RepositoryGridItem.tsx | 59 +++++++++++++ .../RepositoryPagination.tsx | 38 +++++++++ .../RepositorySearch/RepositorySearch.tsx | 83 +++++++++++++++++++ .../RepositorySearchFormContext.tsx | 25 ++++++ .../components/RepositorySearch/types.ts | 7 ++ .../hooks/useRepositorySearchFormContext.ts | 6 ++ .../hooks/useSearchRepositories.ts | 57 +++++++++++++ .../components/GridProgress/GridProgress.tsx | 28 +++++++ .../PageContainer/PageContainer.tsx | 16 ++++ .../components/PageHeader/PageHeader.tsx | 20 +++++ src/shared/constants/table.ts | 1 + src/shared/redux/store.ts | 5 +- 20 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 src/api/github/repository/api.ts create mode 100644 src/api/github/repository/enums.ts create mode 100644 src/api/github/repository/types.ts create mode 100644 src/features/dashboard/Dashboard.tsx create mode 100644 src/features/dashboard/features/repositories/Repositories.tsx create mode 100644 src/features/dashboard/features/repositories/routes/Repositories/Repositories.tsx create mode 100644 src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryGrid/RepositoryGrid.tsx create mode 100644 src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryGridItem/RepositoryGridItem.tsx create mode 100644 src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryPagination/RepositoryPagination.tsx create mode 100644 src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/RepositorySearch.tsx create mode 100644 src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/RepositorySearchFormContext.tsx create mode 100644 src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/types.ts create mode 100644 src/features/dashboard/features/repositories/routes/Repositories/hooks/useRepositorySearchFormContext.ts create mode 100644 src/features/dashboard/features/repositories/routes/Repositories/hooks/useSearchRepositories.ts create mode 100644 src/shared/components/GridProgress/GridProgress.tsx create mode 100644 src/shared/components/PageContainer/PageContainer.tsx create mode 100644 src/shared/components/PageHeader/PageHeader.tsx create mode 100644 src/shared/constants/table.ts diff --git a/src/App.tsx b/src/App.tsx index 5104e53..267cdb4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { QueryParamProvider } from 'use-query-params'; import './index.css'; import Auth from './features/auth/Auth'; import UserMiddleware from './features/auth/components/UserMiddleware/UserMiddleware'; +import Dashboard from './features/dashboard/Dashboard'; import FullscreenProgress from './shared/components/FullscreenProgress/FullscreenProgress'; import { persistor, store } from './shared/redux/store'; @@ -19,6 +20,7 @@ const App = () => { + diff --git a/src/api/github/repository/api.ts b/src/api/github/repository/api.ts new file mode 100644 index 0000000..696ba17 --- /dev/null +++ b/src/api/github/repository/api.ts @@ -0,0 +1,19 @@ +import { endpoint } from '@octokit/endpoint'; +import { createApi } from '@reduxjs/toolkit/query/react'; +import { githubBaseQuery } from '../index'; +import { ResponseWithLink } from '../types'; +import { RepositorySearchArgs, RepositorySearchData } from './types'; + +export const REPOSITORY_API_REDUCER_KEY = 'repositoryApi'; +export const repositoryApi = createApi({ + reducerPath: REPOSITORY_API_REDUCER_KEY, + baseQuery: githubBaseQuery, + endpoints: (builder) => ({ + searchRepositories: builder.query, RepositorySearchArgs>({ + query: (args) => { + return endpoint('GET /search/repositories', args); + }, + }), + }), + refetchOnMountOrArgChange: 60 +}); diff --git a/src/api/github/repository/enums.ts b/src/api/github/repository/enums.ts new file mode 100644 index 0000000..0024652 --- /dev/null +++ b/src/api/github/repository/enums.ts @@ -0,0 +1,12 @@ +export enum RepositoryVisibilityEnum { + PUBLIC, + PRIVATE +} + +export enum SearchRepositorySortEnum { + STARS = 'stars', + FORKS = 'forks', + HELP_WANTED_ISSUES = 'help-wanted-issues', + UPDATED = 'updated', + BEST_MATCH = 'best-match' +} diff --git a/src/api/github/repository/types.ts b/src/api/github/repository/types.ts new file mode 100644 index 0000000..35641fc --- /dev/null +++ b/src/api/github/repository/types.ts @@ -0,0 +1,7 @@ +import { components, operations } from '@octokit/openapi-types/dist-types/generated/types'; +import { Endpoints } from '@octokit/types'; + +export type Repository = components['schemas']['repo-search-result-item']; + +export type RepositorySearchData = Endpoints['GET /search/repositories']['response']['data']; +export type RepositorySearchArgs = operations['search/repos']['parameters']['query']; diff --git a/src/features/dashboard/Dashboard.tsx b/src/features/dashboard/Dashboard.tsx new file mode 100644 index 0000000..c3c8666 --- /dev/null +++ b/src/features/dashboard/Dashboard.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Redirect, Switch } from 'react-router-dom'; +import AuthenticatedRoute from '../auth/components/AuthenticatedRoute/AuthenticatedRoute'; +import Repositories from './features/repositories/Repositories'; + +const Dashboard = () => { + return ( + + + + + + + ); +}; + +export default Dashboard; diff --git a/src/features/dashboard/features/repositories/Repositories.tsx b/src/features/dashboard/features/repositories/Repositories.tsx new file mode 100644 index 0000000..c3aafc0 --- /dev/null +++ b/src/features/dashboard/features/repositories/Repositories.tsx @@ -0,0 +1,19 @@ +import React, { Suspense } from 'react'; +import { Switch } from 'react-router-dom'; +import FullscreenProgress from '../../../../shared/components/FullscreenProgress/FullscreenProgress'; +import AuthenticatedRoute from '../../../auth/components/AuthenticatedRoute/AuthenticatedRoute'; + +const RepositoriesRoute = React.lazy(() => import('./routes/Repositories/Repositories')); +const Repositories = () => { + return ( + }> + + + + + + + ); +}; + +export default Repositories; diff --git a/src/features/dashboard/features/repositories/routes/Repositories/Repositories.tsx b/src/features/dashboard/features/repositories/routes/Repositories/Repositories.tsx new file mode 100644 index 0000000..0f9ceee --- /dev/null +++ b/src/features/dashboard/features/repositories/routes/Repositories/Repositories.tsx @@ -0,0 +1,31 @@ +import { Grid } from '@material-ui/core'; +import React from 'react'; +import PageContainer from '../../../../../../shared/components/PageContainer/PageContainer'; +import PageHeader from '../../../../../../shared/components/PageHeader/PageHeader'; +import RepositoryGrid from './components/RepositoryGrid/RepositoryGrid'; +import RepositoryPagination from './components/RepositoryPagination/RepositoryPagination'; +import RepositorySearch from './components/RepositorySearch/RepositorySearch'; +import RepositorySearchFormContext from './components/RepositorySearch/RepositorySearchFormContext'; + +const Repositories = () => { + return ( + + + + + + + + + + + + + + + + + ) +} + +export default Repositories; diff --git a/src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryGrid/RepositoryGrid.tsx b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryGrid/RepositoryGrid.tsx new file mode 100644 index 0000000..dd09fa7 --- /dev/null +++ b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryGrid/RepositoryGrid.tsx @@ -0,0 +1,26 @@ +import { Grid } from '@material-ui/core'; +import React, { useMemo } from 'react'; +import GridProgress from '../../../../../../../../shared/components/GridProgress/GridProgress'; +import { useSearchRepositories } from '../../hooks/useSearchRepositories'; +import RepositoryGridItem from '../RepositoryGridItem/RepositoryGridItem'; + +const RepositoryGrid = () => { + const { data, isFetching, isUninitialized } = useSearchRepositories(); + const isLoading = isFetching || isUninitialized; + + return useMemo(() => ( + + {data?.response.items?.map((repo: any) => ( + + + + ))} + + ), [isLoading, data]); +} + +export default RepositoryGrid; diff --git a/src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryGridItem/RepositoryGridItem.tsx b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryGridItem/RepositoryGridItem.tsx new file mode 100644 index 0000000..d18a623 --- /dev/null +++ b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryGridItem/RepositoryGridItem.tsx @@ -0,0 +1,59 @@ +import { Badge, Box, Chip, Divider, Grid, Typography } from '@material-ui/core'; +import StarOutlineIcon from '@material-ui/icons/StarOutline'; +import formatDistance from 'date-fns/formatDistance'; +import React, { FC } from 'react'; +import { Repository } from '../../../../../../../../api/github/repository/types'; + +const RepositoryGridItem: FC<{ repo: Repository }> = ({ + repo +}) => { + return ( + + + + {repo.name} + + + + + + {repo.description} + + + + + + + + + + + {repo.language} + + + + + + + + + + {repo.stargazers_count} + + + + + + Updated {formatDistance(new Date(repo.pushed_at), new Date())} ago + + + + + + + + + ) +} + +export default RepositoryGridItem; diff --git a/src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryPagination/RepositoryPagination.tsx b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryPagination/RepositoryPagination.tsx new file mode 100644 index 0000000..445f362 --- /dev/null +++ b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositoryPagination/RepositoryPagination.tsx @@ -0,0 +1,38 @@ +import TablePagination, { TablePaginationProps } from '@material-ui/core/TablePagination'; +import React, { FC, useCallback, useMemo } from 'react'; +import { DEFAULT_ROWS_PER_PAGE } from '../../../../../../../../shared/constants/table'; +import { useSearchRepositoriesState } from '../../hooks/useSearchRepositories'; +import { useRepositorySearchFormContext } from '../../hooks/useRepositorySearchFormContext'; +import { repositorySearchFormDefaultValues } from '../RepositorySearch/RepositorySearchFormContext'; + +export const repositoriesPerPage = [ + ...DEFAULT_ROWS_PER_PAGE +] + +const RepositoryPagination: FC = () => { + const { values, setFieldValue } = useRepositorySearchFormContext(); + const { data } = useSearchRepositoriesState(); + + const handlePageChange: TablePaginationProps['onChangePage'] = useCallback((e, page) => { + setFieldValue('page', page + 1); + }, [setFieldValue]); + + const handlePerPageChange: TablePaginationProps['onChangeRowsPerPage'] = useCallback((e) => { + setFieldValue('per_page', Number(e.target.value)); + setFieldValue('page', repositorySearchFormDefaultValues.page); + }, [setFieldValue]); + + return useMemo(() => ( + + ), [data, values, handlePageChange, handlePerPageChange]); +} + +export default RepositoryPagination; diff --git a/src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/RepositorySearch.tsx b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/RepositorySearch.tsx new file mode 100644 index 0000000..07cd889 --- /dev/null +++ b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/RepositorySearch.tsx @@ -0,0 +1,83 @@ +import { + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + TextField +} from '@material-ui/core'; +import { useFormikContext } from 'formik'; +import React, { FC, useCallback, useMemo } from 'react'; +import { + RepositoryVisibilityEnum, + SearchRepositorySortEnum +} from '../../../../../../../../api/github/repository/enums'; +import { repositorySearchFormDefaultValues } from './RepositorySearchFormContext'; +import { RepositorySearchFormValues } from './types'; + +const RepositorySearch: FC = () => { + const { values, handleChange, touched, setFieldValue } = useFormikContext(); + + const handleChangeAndResetPage = useCallback((e: React.ChangeEvent) => { + handleChange(e); + setFieldValue('page', repositorySearchFormDefaultValues.page); + }, [handleChange, setFieldValue]); + + return useMemo(() => ( + + + + + + + Type + + + + + + Sort + + + + + ), [values, handleChange, touched, handleChangeAndResetPage]); +} + +export default RepositorySearch; diff --git a/src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/RepositorySearchFormContext.tsx b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/RepositorySearchFormContext.tsx new file mode 100644 index 0000000..b180f23 --- /dev/null +++ b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/RepositorySearchFormContext.tsx @@ -0,0 +1,25 @@ +import { Formik } from 'formik'; +import { noop } from 'lodash'; +import React, { FC } from 'react'; +import { SearchRepositorySortEnum } from '../../../../../../../../api/github/repository/enums'; + +export const repositorySearchFormDefaultValues = { + name: '', + type: '', + sort: SearchRepositorySortEnum.BEST_MATCH, + per_page: 5, + page: 1 +} + +const RepositorySearchFormContext: FC = ({ children }) => { + return ( + + {children} + + ) +} + +export default RepositorySearchFormContext; diff --git a/src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/types.ts b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/types.ts new file mode 100644 index 0000000..1c30205 --- /dev/null +++ b/src/features/dashboard/features/repositories/routes/Repositories/components/RepositorySearch/types.ts @@ -0,0 +1,7 @@ +import { RepositoryVisibilityEnum } from '../../../../../../../../api/github/repository/enums'; +import { RepositorySearchArgs } from '../../../../../../../../api/github/repository/types'; + +export type RepositorySearchFormValues = { + name: string; + type: RepositoryVisibilityEnum; +} & Required> diff --git a/src/features/dashboard/features/repositories/routes/Repositories/hooks/useRepositorySearchFormContext.ts b/src/features/dashboard/features/repositories/routes/Repositories/hooks/useRepositorySearchFormContext.ts new file mode 100644 index 0000000..e2a7de5 --- /dev/null +++ b/src/features/dashboard/features/repositories/routes/Repositories/hooks/useRepositorySearchFormContext.ts @@ -0,0 +1,6 @@ +import { useFormikContext } from 'formik'; +import { RepositorySearchFormValues } from '../components/RepositorySearch/types'; + +export const useRepositorySearchFormContext = () => { + return useFormikContext(); +} diff --git a/src/features/dashboard/features/repositories/routes/Repositories/hooks/useSearchRepositories.ts b/src/features/dashboard/features/repositories/routes/Repositories/hooks/useSearchRepositories.ts new file mode 100644 index 0000000..e484a2c --- /dev/null +++ b/src/features/dashboard/features/repositories/routes/Repositories/hooks/useSearchRepositories.ts @@ -0,0 +1,57 @@ +import { debounce } from 'lodash'; +import { useCallback, useEffect, useMemo } from 'react'; +import urltemplate from 'url-template'; +import { repositoryApi } from '../../../../../../../api/github/repository/api'; +import { RepositorySearchArgs } from '../../../../../../../api/github/repository/types'; +import { useTypedDispatch } from '../../../../../../../shared/redux/store'; +import { useAuthUser } from '../../../../../../auth/hooks/useAuthUser'; +import { useRepositorySearchFormContext } from './useRepositorySearchFormContext'; + +const searchQs = urltemplate.parse('user:{user} {name} {visibility}'); +export const useSearchRepositoriesArgs = (): RepositorySearchArgs => { + const user = useAuthUser()!; + const { values } = useRepositorySearchFormContext(); + return useMemo(() => { + return { + q: decodeURIComponent( + searchQs.expand({ + user: user.login, + name: values.name && `${values.name} in:name`, + visibility: ['is:public', 'is:private'][values.type] ?? '', + }) + ).trim(), + sort: values.sort, + per_page: values.per_page, + page: values.page, + } + }, [values, user.login]); +} + +export const useSearchRepositoriesState = () => { + const searchArgs = useSearchRepositoriesArgs(); + return repositoryApi.endpoints.searchRepositories.useQueryState(searchArgs); +} + +export const useSearchRepositories = () => { + const dispatch = useTypedDispatch(); + const searchArgs = useSearchRepositoriesArgs(); + const repositorySearchFn = useCallback((args: typeof searchArgs) => { + dispatch(repositoryApi.endpoints.searchRepositories.initiate(args)); + }, [dispatch]); + const debouncedRepositorySearchFn = useMemo( () => debounce((args: typeof searchArgs) => { + repositorySearchFn(args); + }, 100), + [repositorySearchFn] + ); + + useEffect(() => { + repositorySearchFn(searchArgs); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + debouncedRepositorySearchFn(searchArgs); + }, [searchArgs, debouncedRepositorySearchFn]); + + return useSearchRepositoriesState(); +} diff --git a/src/shared/components/GridProgress/GridProgress.tsx b/src/shared/components/GridProgress/GridProgress.tsx new file mode 100644 index 0000000..0d3bb95 --- /dev/null +++ b/src/shared/components/GridProgress/GridProgress.tsx @@ -0,0 +1,28 @@ +import { Box, CircularProgress, Grid, GridProps } from '@material-ui/core'; +import React, { FC } from 'react'; + +type GridProgressProps = { + loading: boolean; +} & GridProps; + +const GridProgress: FC = ({ + loading, + children, + ...gridProps +}) => { + return ( + + {loading ? ( + + + + ) : ( + <> + {children} + + )} + + ); +} + +export default GridProgress; diff --git a/src/shared/components/PageContainer/PageContainer.tsx b/src/shared/components/PageContainer/PageContainer.tsx new file mode 100644 index 0000000..216b563 --- /dev/null +++ b/src/shared/components/PageContainer/PageContainer.tsx @@ -0,0 +1,16 @@ +import { Box, Container } from '@material-ui/core'; +import React, { FC } from 'react'; + +const PageContainer: FC = ({ children }) => { + return ( + + + <> + {children} + + + + ) +} + +export default PageContainer; diff --git a/src/shared/components/PageHeader/PageHeader.tsx b/src/shared/components/PageHeader/PageHeader.tsx new file mode 100644 index 0000000..7d3477c --- /dev/null +++ b/src/shared/components/PageHeader/PageHeader.tsx @@ -0,0 +1,20 @@ +import { Box, Grid, Typography } from '@material-ui/core'; +import React, { FC } from 'react'; + +const PageHeader: FC<{ title: string }> = ({ + title +}) => { + return ( + + + + + {title} + + + + + ) +} + +export default PageHeader; diff --git a/src/shared/constants/table.ts b/src/shared/constants/table.ts new file mode 100644 index 0000000..2f16d52 --- /dev/null +++ b/src/shared/constants/table.ts @@ -0,0 +1 @@ +export const DEFAULT_ROWS_PER_PAGE = [5, 10, 25, 50, 100]; diff --git a/src/shared/redux/store.ts b/src/shared/redux/store.ts index cafb76e..e29bef5 100644 --- a/src/shared/redux/store.ts +++ b/src/shared/redux/store.ts @@ -3,6 +3,7 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import { Reducer } from 'redux'; import { FLUSH, PAUSE, PERSIST, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist'; import { AUTH_API_REDUCER_KEY, authApi } from '../../api/auth/api'; +import { REPOSITORY_API_REDUCER_KEY, repositoryApi } from '../../api/github/repository/api'; import { USER_API_REDUCER_KEY, userApi } from '../../api/github/user/api'; import { authReducer, authSlice } from '../../features/auth/slice'; import { RESET_STATE_ACTION_TYPE } from './actions/resetState'; @@ -12,6 +13,7 @@ const reducers = { [authSlice.name]: authReducer, [AUTH_API_REDUCER_KEY]: authApi.reducer, [USER_API_REDUCER_KEY]: userApi.reducer, + [REPOSITORY_API_REDUCER_KEY]: repositoryApi.reducer }; const combinedReducer = combineReducers(reducers); @@ -33,7 +35,8 @@ export const store = configureStore({ }).concat([ unauthenticatedMiddleware, authApi.middleware, - userApi.middleware + userApi.middleware, + repositoryApi.middleware ]), });