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
]),
});