Initial commit

This commit is contained in:
forceoranj
2021-10-16 01:53:56 +02:00
commit ebdd8dccdd
104 changed files with 29770 additions and 0 deletions

42
src/app/__tests__/App.tsx Executable file
View File

@@ -0,0 +1,42 @@
import renderer from "react-test-renderer"
import { Provider } from "react-redux"
import { MemoryRouter } from "react-router-dom"
import App from ".."
describe("<App />", () => {
it("renders", () => {
const mockStore = {
default: () => null,
subscribe: () => null,
dispatch: () => null,
getState: () => ({ home: () => null }),
}
const mockRoute = {
routes: [
{
path: "/",
exact: true,
component: () => (
<div>
<h1>Welcome Home!</h1>
</div>
),
},
],
}
const tree = renderer
.create(
// @ts-expect-error
<Provider store={mockStore}>
<MemoryRouter>
<App route={mockRoute} />
</MemoryRouter>
</Provider>
)
.toJSON()
expect(tree).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<App /> renders 1`] = `
<div
className="App"
>
<a
className="header"
href="/"
onClick={[Function]}
>
<img
alt="Logo"
role="presentation"
src="IMAGE_MOCK"
/>
<h1>
<em>
REACT COOL STARTER
</em>
</h1>
</a>
<hr />
<div>
<h1>
Welcome Home!
</h1>
</div>
</div>
`;

30
src/app/index.tsx Executable file
View File

@@ -0,0 +1,30 @@
import { Link } from "react-router-dom"
import { RouteConfig, renderRoutes } from "react-router-config"
import { Helmet } from "react-helmet"
import logo from "../static/logo.svg"
import config from "../config"
// Import your global styles here
import "normalize.css/normalize.css"
import styles from "./styles.module.scss"
interface Route {
route: { routes: RouteConfig[] }
}
const App = ({ route }: Route): JSX.Element => (
<div className={styles.App}>
<Helmet {...config.APP} />
<Link to="/" className={styles.header}>
<img src={logo} alt="Logo" role="presentation" />
<h1>
<em>{config.APP.title}</em>
</h1>
</Link>
<hr />
{/* Child routes won't render without this */}
{renderRoutes(route.routes)}
</div>
)
export default App

30
src/app/styles.module.scss Executable file
View File

@@ -0,0 +1,30 @@
@import "../theme/variables";
body {
background-color: $color-dark-gray;
font-family: "Helvetica-Light", Helvetica, Arial, sans-serif;
}
.App {
color: $color-white;
.header {
color: inherit;
display: block;
overflow: auto;
padding: 15px;
text-decoration: none;
img {
float: left;
height: 70px;
margin-right: 1em;
}
}
hr {
margin-bottom: 0;
margin-top: 0;
opacity: 0.15;
}
}

29
src/client/index.tsx Executable file
View File

@@ -0,0 +1,29 @@
import ReactDOM from "react-dom"
import { Provider } from "react-redux"
import { ConnectedRouter } from "connected-react-router"
import { RouteConfig, renderRoutes } from "react-router-config"
import { loadableReady } from "@loadable/component"
import createStore from "../store"
import routes from "../routes"
// Get the initial state from server-side rendering
const initialState = window.__INITIAL_STATE__
const { store, history } = createStore({ initialState })
const render = (Routes: RouteConfig[]) =>
ReactDOM.hydrate(
<Provider store={store}>
<ConnectedRouter history={history}>{renderRoutes(Routes)}</ConnectedRouter>
</Provider>,
document.getElementById("react-view")
)
// loadable-component setup
loadableReady(() => render(routes as RouteConfig[]))
/**
* A temporary workaround for Webpack v5 + HMR, why? see this issue: https://github.com/webpack-contrib/webpack-hot-middleware/issues/390
*/
// @ts-expect-error
if (module.hot) module.hot.accept()

View File

@@ -0,0 +1,39 @@
/**
* @jest-environment jsdom
*/
import { ReactNode } from "react"
import { render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import ErrorBoundary from "../index"
describe("<ErrorBoundary />", () => {
const tree = (children?: ReactNode) =>
render(
<MemoryRouter>
<ErrorBoundary>{children}</ErrorBoundary>
</MemoryRouter>
).container.firstChild
it("renders nothing if no children", () => {
expect(tree()).toMatchSnapshot()
})
it("renders children if no error", () => {
expect(
tree(
<div>
<h1>I am PeL</h1>
</div>
)
).toMatchSnapshot()
})
it("renders error view if an error occurs", () => {
console.error = jest.fn()
tree(<div>{new Error()}</div>)
expect(screen.getByTestId("error-view")).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ErrorBoundary /> renders children if no error 1`] = `
<div>
<h1>
I am PeL
</h1>
</div>
`;
exports[`<ErrorBoundary /> renders nothing if no children 1`] = `null`;

View File

@@ -0,0 +1,45 @@
import { ReactNode, PureComponent } from "react"
interface Props {
children?: ReactNode
}
interface State {
error: Error | null
errorInfo: { componentStack: string } | null
}
class ErrorBoundary extends PureComponent<Props, State> {
constructor(props: Props) {
super(props)
this.state = { error: null, errorInfo: null }
}
componentDidCatch(error: Error, errorInfo: { componentStack: string }): void {
// Catch errors in any components below and re-render with error message
this.setState({ error, errorInfo })
// You can also log error messages to an error reporting service here
}
render(): ReactNode {
const { children } = this.props
const { errorInfo, error } = this.state
// If there's an error, render error path
return errorInfo ? (
<div data-testid="error-view">
<h2>Something went wrong.</h2>
<details style={{ whiteSpace: "pre-wrap" }}>
{error && error.toString()}
<br />
{errorInfo.componentStack}
</details>
</div>
) : (
children || null
)
}
}
export default ErrorBoundary

View File

@@ -0,0 +1,27 @@
/**
* @jest-environment jsdom
*/
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import Info from "../index"
describe("<Info />", () => {
it("renders", () => {
const tree = render(
<MemoryRouter>
<Info
item={{
id: 1,
name: "PeL",
phone: "+886 0970...",
email: "forceoranj@gmail.com",
website: "https://www.parisestludique.fr",
}}
/>
</MemoryRouter>
).container.firstChild
expect(tree).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Info /> renders 1`] = `
<div
class="UserCard"
>
<h4>
User Info
</h4>
<ul>
<li>
Name:
PeL
</li>
<li>
Phone:
+886 0970...
</li>
<li>
Email:
forceoranj@gmail.com
</li>
<li>
Website:
https://www.parisestludique.fr
</li>
</ul>
</div>
`;

22
src/components/Info/index.tsx Executable file
View File

@@ -0,0 +1,22 @@
import { memo } from "react"
import { User } from "../../services/jsonPlaceholder"
import styles from "./styles.module.scss"
interface Props {
item: User
}
const Info = ({ item }: Props) => (
<div className={styles.UserCard}>
<h4>User Info</h4>
<ul>
<li>Name: {item.name}</li>
<li>Phone: {item.phone}</li>
<li>Email: {item.email}</li>
<li>Website: {item.website}</li>
</ul>
</div>
)
export default memo(Info)

View File

@@ -0,0 +1,9 @@
.user-card {
ul {
padding-left: 17px;
li {
margin-bottom: 0.5em;
}
}
}

View File

@@ -0,0 +1,41 @@
/**
* @jest-environment jsdom
*/
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import List from "../index"
describe("<List />", () => {
it("renders", () => {
const tree = render(
<MemoryRouter>
<List
items={[
{
id: 5,
titre: "6 qui prend!",
auteur: "Wolfgang Kramer",
editeur: "(uncredited) , Design Edge , B",
minJoueurs: 2,
maxJoueurs: 10,
duree: 45,
type: "Ambiance",
poufpaf: "0-9-2/6-qui-prend-6-nimmt",
photo: "https://cf.geekdo-images.com/thumb/img/lzczxR5cw7an7tRWeHdOrRtLyes=/fit-in/200x150/pic772547.jpg",
bggPhoto: "",
bggId: 432,
exemplaires: 1,
dispoPret: 1,
nonRangee: 0,
horodatage: "0000-00-00",
ean: "3421272101313",
},
]}
/>
</MemoryRouter>
).container.firstChild
expect(tree).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<List /> renders 1`] = `
<div
class="JavGameList"
>
<h4>
JAV Games
</h4>
<ul>
<li>
<a
href="/UserInfo/5"
>
6 qui prend!
</a>
</li>
</ul>
</div>
`;

View File

@@ -0,0 +1,24 @@
import { memo } from "react"
import { Link } from "react-router-dom"
import { JavGame } from "../../services/javGames"
import styles from "./styles.module.scss"
interface Props {
items: JavGame[]
}
const List = ({ items }: Props) => (
<div className={styles.JavGameList}>
<h4>JAV Games</h4>
<ul>
{items.map(({ id, titre }) => (
<li key={id}>
<Link to={`/UserInfo/${id}`}>{titre}</Link>
</li>
))}
</ul>
</div>
)
export default memo(List)

View File

@@ -0,0 +1,17 @@
@import "../../theme/variables";
.jav-game-list {
color: $color-white;
ul {
padding-left: 17px;
li {
margin-bottom: 0.5em;
}
}
a {
color: $color-white;
}
}

View File

@@ -0,0 +1,29 @@
/**
* @jest-environment jsdom
*/
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import List from "../index"
describe("<List />", () => {
it("renders", () => {
const tree = render(
<MemoryRouter>
<List
items={[
{
id: 1,
name: "PeL",
phone: "+886 0970...",
email: "forceoranj@gmail.com",
website: "https://www.parisestludique.fr",
},
]}
/>
</MemoryRouter>
).container.firstChild
expect(tree).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<List /> renders 1`] = `
<div
class="UserList"
>
<h4>
User List
</h4>
<ul>
<li>
<a
href="/UserInfo/1"
>
PeL
</a>
</li>
</ul>
</div>
`;

24
src/components/List/index.tsx Executable file
View File

@@ -0,0 +1,24 @@
import { memo } from "react"
import { Link } from "react-router-dom"
import { User } from "../../services/jsonPlaceholder"
import styles from "./styles.module.scss"
interface Props {
items: User[]
}
const List = ({ items }: Props) => (
<div className={styles.UserList}>
<h4>User List</h4>
<ul>
{items.map(({ id, name }) => (
<li key={id}>
<Link to={`/UserInfo/${id}`}>{name}</Link>
</li>
))}
</ul>
</div>
)
export default memo(List)

View File

@@ -0,0 +1,17 @@
@import "../../theme/variables";
.UserList {
color: $color-white;
ul {
padding-left: 17px;
li {
margin-bottom: 0.5em;
}
}
a {
color: $color-white;
}
}

View File

@@ -0,0 +1,19 @@
/**
* @jest-environment jsdom
*/
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import Loading from "../index"
describe("<Loading />", () => {
it("renders", () => {
const tree = render(
<MemoryRouter>
<Loading />
</MemoryRouter>
).container.firstChild
expect(tree).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Loading /> renders 1`] = `
<div
class="Loading"
>
<p>
Loading...
</p>
</div>
`;

View File

@@ -0,0 +1,9 @@
import styles from "./styles.module.scss"
const Loading = (): JSX.Element => (
<div className={styles.Loading}>
<p>Loading...</p>
</div>
)
export default Loading

View File

@@ -0,0 +1,3 @@
.Loading {
padding: 0 15px;
}

7
src/components/index.ts Executable file
View File

@@ -0,0 +1,7 @@
import List from "./List"
import JavGameList from "./JavGameList"
import Info from "./Info"
import ErrorBoundary from "./ErrorBoundary"
import Loading from "./Loading"
export { List, JavGameList, Info, ErrorBoundary, Loading }

16
src/config/default.ts Executable file
View File

@@ -0,0 +1,16 @@
export default {
HOST: "localhost",
PORT: 3000,
API_URL: "",
APP: {
htmlAttributes: { lang: "en" },
title: "REACT COOL STARTER",
titleTemplate: "REACT COOL STARTER - %s",
meta: [
{
name: "description",
content: "The best react universal starter boilerplate in the world.",
},
],
},
}

4
src/config/index.ts Executable file
View File

@@ -0,0 +1,4 @@
import defaultConfig from "./default"
import prodConfig from "./prod"
export default __DEV__ ? defaultConfig : { ...defaultConfig, ...prodConfig }

3
src/config/prod.ts Executable file
View File

@@ -0,0 +1,3 @@
export default {
PORT: 8080,
}

133
src/gsheets/jav.ts Normal file
View File

@@ -0,0 +1,133 @@
import path from "path"
import fs from "fs"
import readline from "readline"
import _ from "lodash"
import { Request, Response, NextFunction } from "express"
import { google } from "googleapis"
const SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
const TOKEN_PATH = path.resolve(process.cwd(), "access/token.json")
const CRED_PATH = path.resolve(process.cwd(), "access/gsheets.json")
// eslint-disable-next-line no-unused-vars
export const getJAVGameList = async (
_request: Request,
response: Response,
_next: NextFunction
): Promise<void> => {
const auth = await authorize(JSON.parse(fs.readFileSync(CRED_PATH, "utf8")))
const sheets = google.sheets({ version: "v4", auth })
const r = await sheets.spreadsheets.values.get({
spreadsheetId: "1pMMKcYx6NXLOqNn6pLHJTPMTOLRYZmSNg2QQcAu7-Pw",
range: "Ongoing!A1:T",
})
console.log("r?.data?.values", r?.data?.values)
if (r?.data?.values && _.isArray(r.data.values)) {
const list = _.map(r.data.values, (val: any) => ({
id: val[0],
titre: val[1],
}))
response.status(200).json(list)
}
// if (r?.data?.values) {
// const rows: JAVGame[] = r.data.values as JAVGame[]
// if (rows) {
// if (rows.length) {
// console.log('Name, Major:')
// // Print columns A and E, which correspond to indices 0 and 4.
// rows.map((row) => {
// console.log(`${row[0]}, ${row[4]}`)
// })
// return { data: rows }
// } else {
// console.log('No data found.')
// }
// }
// }
}
// eslint-disable-next-line no-unused-vars
export const getJAVGameData = async (
_request: Request,
response: Response,
_next: NextFunction
): Promise<void> => {
console.log("CRED_PATH", CRED_PATH)
console.log("fs.readFileSync(CRED_PATH, 'utf8')")
const auth = await authorize(JSON.parse(fs.readFileSync(CRED_PATH, "utf8")))
const sheets = google.sheets({ version: "v4", auth })
const r = await sheets.spreadsheets.values.get({
spreadsheetId: "1pMMKcYx6NXLOqNn6pLHJTPMTOLRYZmSNg2QQcAu7-Pw",
range: "Ongoing!A1:T",
})
console.log("r?.data?.values", r?.data?.values)
response.status(200).json(r?.data?.values)
// if (r?.data?.values) {
// const rows: JAVGame[] = r.data.values as JAVGame[]
// if (rows) {
// if (rows.length) {
// console.log('Name, Major:')
// // Print columns A and E, which correspond to indices 0 and 4.
// rows.map((row) => {
// console.log(`${row[0]}, ${row[4]}`)
// })
// return { data: rows }
// } else {
// console.log('No data found.')
// }
// }
// }
}
async function authorize(cred: any) {
const {
client_secret: clientSecret,
client_id: clientId,
redirect_uris: redirectUris,
} = cred.web
const oAuth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUris[0])
if (fs.existsSync(TOKEN_PATH)) {
oAuth2Client.setCredentials(JSON.parse(fs.readFileSync(TOKEN_PATH, "utf8")))
return oAuth2Client
}
return getNewToken<typeof oAuth2Client>(oAuth2Client)
}
async function getNewToken<T = any>(oAuth2Client: any): Promise<T> {
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: SCOPES,
})
console.log("Authorize this app by visiting this url:", authUrl)
const code = await readlineAsync("Enter the code from that page here: ")
const token = await new Promise((resolve, reject) => {
oAuth2Client.getToken(code, (err: any, _token: any) =>
err ? reject(err) : resolve(_token)
)
})
oAuth2Client.setCredentials(token)
// Store the token to disk for later program executions
fs.writeFileSync(TOKEN_PATH, JSON.stringify(token))
console.log("Token stored to", TOKEN_PATH)
return oAuth2Client
}
async function readlineAsync(question: string) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close()
resolve(answer)
})
})
}

46
src/pages/Home/Home.tsx Executable file
View File

@@ -0,0 +1,46 @@
import { FC, useEffect, memo } from "react"
import { RouteComponentProps } from "react-router-dom"
import { useDispatch, useSelector, shallowEqual } from "react-redux"
import { Helmet } from "react-helmet"
import { AppState, AppThunk } from "../../store"
import { fetchJavGameListIfNeed } from "../../store/javGameList"
import { JavGameList } from "../../components"
import styles from "./styles.module.scss"
export type Props = RouteComponentProps
function useList(stateToProp: (state: AppState) => any, fetchDataIfNeed: () => AppThunk) {
const dispatch = useDispatch()
const { readyStatus, items } = useSelector(stateToProp, shallowEqual)
// Fetch client-side data here
useEffect(() => {
dispatch(fetchDataIfNeed())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch])
return () => {
if (!readyStatus || readyStatus === "invalid" || readyStatus === "request")
return <p>Loading...</p>
if (readyStatus === "failure") return <p>Oops, Failed to load list!</p>
return <JavGameList items={items} />
}
}
const Home: FC<Props> = (): JSX.Element => (
<div className={styles.Home}>
<Helmet title="Home" />
{useList((state: AppState) => state.javGameList, fetchJavGameListIfNeed)()}
</div>
)
// Fetch server-side data here
export const loadData = (): AppThunk[] => [
fetchJavGameListIfNeed(),
// More pre-fetched actions...
]
export default memo(Home)

View File

@@ -0,0 +1,78 @@
/**
* @jest-environment jsdom
*/
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { fetchJavGameListIfNeed } from "../../../store/javGameList"
import mockStore from "../../../utils/mockStore"
import Home from "../Home"
describe("<Home />", () => {
const renderHelper = (reducer = { readyStatus: "invalid" }) => {
const { dispatch, ProviderWithStore } = mockStore({ javGameList: reducer })
const { container } = render(
<ProviderWithStore>
<MemoryRouter>
{/*
@ts-expect-error */}
<Home />
</MemoryRouter>
</ProviderWithStore>
)
return { dispatch, firstChild: container.firstChild }
}
it("should fetch data when page loaded", () => {
const { dispatch } = renderHelper()
expect(dispatch).toHaveBeenCalledTimes(1)
expect(dispatch.mock.calls[0][0].toString()).toBe(fetchJavGameListIfNeed().toString())
})
it("renders the loading status if data invalid", () => {
expect(renderHelper().firstChild).toMatchSnapshot()
})
it("renders the loading status if requesting data", () => {
const reducer = { readyStatus: "request" }
expect(renderHelper(reducer).firstChild).toMatchSnapshot()
})
it("renders an error if loading failed", () => {
const reducer = { readyStatus: "failure" }
expect(renderHelper(reducer).firstChild).toMatchSnapshot()
})
it("renders the <List /> if loading was successful", () => {
const reducer = {
readyStatus: "success",
items: [
{
id: 5,
titre: "6 qui prend!",
auteur: "Wolfgang Kramer",
editeur: "(uncredited) , Design Edge , B",
minJoueurs: 2,
maxJoueurs: 10,
duree: 45,
type: "Ambiance",
poufpaf: "0-9-2/6-qui-prend-6-nimmt",
photo: "https://cf.geekdo-images.com/thumb/img/lzczxR5cw7an7tRWeHdOrRtLyes=/fit-in/200x150/pic772547.jpg",
bggPhoto: "",
bggId: 432,
exemplaires: 1,
dispoPret: 1,
nonRangee: 0,
horodatage: "0000-00-00",
ean: "3421272101313",
},
],
}
expect(renderHelper(reducer).firstChild).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,54 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Home /> renders an error if loading failed 1`] = `
<div
class="Home"
>
<p>
Oops, Failed to load list!
</p>
</div>
`;
exports[`<Home /> renders the <List /> if loading was successful 1`] = `
<div
class="Home"
>
<div
class="JavGameList"
>
<h4>
JAV Games
</h4>
<ul>
<li>
<a
href="/UserInfo/5"
>
6 qui prend!
</a>
</li>
</ul>
</div>
</div>
`;
exports[`<Home /> renders the loading status if data invalid 1`] = `
<div
class="Home"
>
<p>
Loading...
</p>
</div>
`;
exports[`<Home /> renders the loading status if requesting data 1`] = `
<div
class="Home"
>
<p>
Loading...
</p>
</div>
`;

15
src/pages/Home/index.tsx Executable file
View File

@@ -0,0 +1,15 @@
import loadable from "@loadable/component"
import { Loading, ErrorBoundary } from "../../components"
import { Props, loadData } from "./Home"
const Home = loadable(() => import("./Home"), {
fallback: <Loading />,
})
export default (props: Props): JSX.Element => (
<ErrorBoundary>
<Home {...props} />
</ErrorBoundary>
)
export { loadData }

View File

@@ -0,0 +1,3 @@
.Home {
padding: 0 15px;
}

View File

@@ -0,0 +1,21 @@
/**
* @jest-environment jsdom
*/
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import NotFound from "../index"
describe("<NotFound />", () => {
it("renders", () => {
const tree = render(
<MemoryRouter>
{/*
@ts-expect-error */}
<NotFound />
</MemoryRouter>
).container.firstChild
expect(tree).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<NotFound /> renders 1`] = `
<div
class="NotFound"
>
<p>
Oops, Page was not found!
</p>
</div>
`;

23
src/pages/NotFound/index.tsx Executable file
View File

@@ -0,0 +1,23 @@
import { memo } from "react"
import { RouteComponentProps } from "react-router-dom"
import { Helmet } from "react-helmet"
import styles from "./styles.module.scss"
type Props = RouteComponentProps
const NotFound = ({ staticContext }: Props) => {
// We have to check if staticContext exists
// because it will be undefined if rendered through a BrowserRoute
/* istanbul ignore next */
if (staticContext) staticContext.statusCode = 404
return (
<div className={styles.NotFound}>
<Helmet title="Oops" />
<p>Oops, Page was not found!</p>
</div>
)
}
export default memo(NotFound)

View File

@@ -0,0 +1,3 @@
.NotFound {
padding: 0 15px;
}

47
src/pages/UserInfo/UserInfo.tsx Executable file
View File

@@ -0,0 +1,47 @@
import { useEffect, memo } from "react"
import { RouteComponentProps } from "react-router-dom"
import { useDispatch, useSelector, shallowEqual } from "react-redux"
import { Helmet } from "react-helmet"
import { AppState, AppThunk } from "../../store"
import { User } from "../../services/jsonPlaceholder"
import { fetchUserDataIfNeed } from "../../store/userData"
import { Info } from "../../components"
import styles from "./styles.module.scss"
export type Props = RouteComponentProps<{ id: string }>
const UserInfo = ({ match }: Props): JSX.Element => {
const { id } = match.params
const dispatch = useDispatch()
const userData = useSelector((state: AppState) => state.userData, shallowEqual)
useEffect(() => {
dispatch(fetchUserDataIfNeed(id))
}, [dispatch, id])
const renderInfo = () => {
const userInfo = userData[id]
if (!userInfo || userInfo.readyStatus === "request") return <p>Loading...</p>
if (userInfo.readyStatus === "failure") return <p>Oops! Failed to load data.</p>
return <Info item={userInfo.item as User} />
}
return (
<div className={styles.UserInfo}>
<Helmet title="User Info" />
{renderInfo()}
</div>
)
}
interface LoadDataArgs {
params: { id: string }
}
export const loadData = ({ params }: LoadDataArgs): AppThunk[] => [fetchUserDataIfNeed(params.id)]
export default memo(UserInfo)

View File

@@ -0,0 +1,66 @@
/**
* @jest-environment jsdom
*/
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { fetchUserDataIfNeed } from "../../../store/userData"
import mockStore from "../../../utils/mockStore"
import UserInfo from "../UserInfo"
describe("<UserInfo />", () => {
const mockData = {
id: "1",
name: "PeL",
phone: "+886 0970...",
email: "forceoranj@gmail.com",
website: "https://www.parisestludique.fr",
}
const { id } = mockData
const renderHelper = (reducer = {}) => {
const { dispatch, ProviderWithStore } = mockStore({ userData: reducer })
const { container } = render(
<ProviderWithStore>
<MemoryRouter>
{/*
@ts-expect-error */}
<UserInfo match={{ params: { id } }} />
</MemoryRouter>
</ProviderWithStore>
)
return { dispatch, firstChild: container.firstChild }
}
it("should fetch data when page loaded", () => {
const { dispatch } = renderHelper()
expect(dispatch).toHaveBeenCalledTimes(1)
expect(dispatch.mock.calls[0][0].toString()).toBe(
fetchUserDataIfNeed(id.toString()).toString()
)
})
it("renders the loading status if data invalid", () => {
expect(renderHelper().firstChild).toMatchSnapshot()
})
it("renders the loading status if requesting data", () => {
const reducer = { [id]: { readyStatus: "request" } }
expect(renderHelper(reducer).firstChild).toMatchSnapshot()
})
it("renders an error if loading failed", () => {
const reducer = { [id]: { readyStatus: "failure" } }
expect(renderHelper(reducer).firstChild).toMatchSnapshot()
})
it("renders the <Info /> if loading was successful", () => {
const reducer = { [id]: { readyStatus: "success", item: mockData } }
expect(renderHelper(reducer).firstChild).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<UserInfo /> renders an error if loading failed 1`] = `
<div
class="UserInfo"
>
<p>
Oops! Failed to load data.
</p>
</div>
`;
exports[`<UserInfo /> renders the <Info /> if loading was successful 1`] = `
<div
class="UserInfo"
>
<div
class="UserCard"
>
<h4>
User Info
</h4>
<ul>
<li>
Name:
PeL
</li>
<li>
Phone:
+886 0970...
</li>
<li>
Email:
forceoranj@gmail.com
</li>
<li>
Website:
https://www.parisestludique.fr
</li>
</ul>
</div>
</div>
`;
exports[`<UserInfo /> renders the loading status if data invalid 1`] = `
<div
class="UserInfo"
>
<p>
Loading...
</p>
</div>
`;
exports[`<UserInfo /> renders the loading status if requesting data 1`] = `
<div
class="UserInfo"
>
<p>
Loading...
</p>
</div>
`;

15
src/pages/UserInfo/index.tsx Executable file
View File

@@ -0,0 +1,15 @@
import loadable from "@loadable/component"
import { Loading, ErrorBoundary } from "../../components"
import { Props, loadData } from "./UserInfo"
const UserInfo = loadable(() => import("./UserInfo"), {
fallback: <Loading />,
})
export default (props: Props): JSX.Element => (
<ErrorBoundary>
<UserInfo {...props} />
</ErrorBoundary>
)
export { loadData }

View File

@@ -0,0 +1,3 @@
.UserInfo {
padding: 0 15px;
}

28
src/routes/index.ts Executable file
View File

@@ -0,0 +1,28 @@
import { RouteConfig } from "react-router-config"
import App from "../app"
import AsyncHome, { loadData as loadHomeData } from "../pages/Home"
import AsyncUserInfo, { loadData as loadUserInfoData } from "../pages/UserInfo"
import NotFound from "../pages/NotFound"
export default [
{
component: App,
routes: [
{
path: "/",
exact: true,
component: AsyncHome, // Add your page here
loadData: loadHomeData, // Add your pre-fetch method here
},
{
path: "/UserInfo/:id",
component: AsyncUserInfo,
loadData: loadUserInfoData,
},
{
component: NotFound,
},
],
},
] as RouteConfig[]

28
src/server/devServer.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Express } from "express"
import chalk from "chalk"
import config from "../config"
export default (app: Express): void => {
const webpack = require("webpack")
const webpackConfig = require("../../webpack/client.config").default
const compiler = webpack(webpackConfig)
const instance = require("webpack-dev-middleware")(compiler, {
headers: { "Access-Control-Allow-Origin": "*" },
serverSideRender: true,
})
app.use(instance)
app.use(
require("webpack-hot-middleware")(compiler, {
log: false,
path: "/__webpack_hmr",
heartbeat: 10 * 1000,
})
)
instance.waitUntilValid(() => {
const url = `http://${config.HOST}:${config.PORT}`
console.info(chalk.green(`==> 🌎 Listening at ${url}`))
})
}

42
src/server/index.ts Executable file
View File

@@ -0,0 +1,42 @@
import path from "path"
import express from "express"
import logger from "morgan"
import compression from "compression"
import helmet from "helmet"
import hpp from "hpp"
import favicon from "serve-favicon"
import chalk from "chalk"
import devServer from "./devServer"
import ssr from "./ssr"
import { getJAVGameList } from "../gsheets/jav"
import config from "../config"
const app = express()
// Use helmet to secure Express with various HTTP headers
app.use(helmet({ contentSecurityPolicy: false }))
// Prevent HTTP parameter pollution
app.use(hpp())
// Compress all requests
app.use(compression())
// Use for http request debug (show errors only)
app.use(logger("dev", { skip: (_, res) => res.statusCode < 400 }))
app.use(favicon(path.resolve(process.cwd(), "public/favicon.ico")))
app.use(express.static(path.resolve(process.cwd(), "public")))
// Enable dev-server in development
if (__DEV__) devServer(app)
// Google Sheets requests
app.get("/javGames", getJAVGameList)
// Use React server-side rendering middleware
app.get("*", ssr)
// @ts-expect-error
app.listen(config.PORT, config.HOST, (error) => {
if (error) console.error(chalk.red(`==> 😭 OMG!!! ${error}`))
})

63
src/server/renderHtml.ts Executable file
View File

@@ -0,0 +1,63 @@
import { ChunkExtractor } from "@loadable/server"
import { HelmetData } from "react-helmet"
import serialize from "serialize-javascript"
import { minify } from "html-minifier"
export default (
head: HelmetData,
extractor: ChunkExtractor,
htmlContent: string,
initialState: typeof window.__INITIAL_STATE__
): any => {
const html = `
<!doctype html>
<html ${head.htmlAttributes.toString()}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
${head.title.toString()}
${head.base.toString()}
${head.meta.toString()}
${head.link.toString()}
<!-- Insert bundled styles into <link> tag -->
${extractor.getLinkTags()}
${extractor.getStyleTags()}
</head>
<body>
<!-- Insert the router, which passed from server-side -->
<div id="react-view">${htmlContent}</div>
<!-- Store the initial state into window -->
<script>
// Use serialize-javascript for mitigating XSS attacks. See the following security issues:
// http://redux.js.org/docs/recipes/ServerRendering.html#security-considerations
window.__INITIAL_STATE__=${serialize(initialState)};
</script>
<!-- Insert bundled scripts into <script> tag -->
${extractor.getScriptTags()}
${head.script.toString()}
</body>
</html>
`
// html-minifier configuration, refer to "https://github.com/kangax/html-minifier" for more configuration
const minifyConfig = {
collapseWhitespace: true,
removeComments: true,
trimCustomFragments: true,
minifyCSS: true,
minifyJS: true,
minifyURLs: true,
}
// Minify HTML in production
return __DEV__ ? html : minify(html, minifyConfig)
}

83
src/server/ssr.tsx Normal file
View File

@@ -0,0 +1,83 @@
import path from "path"
import { renderToString } from "react-dom/server"
import { StaticRouter } from "react-router-dom"
import { renderRoutes, matchRoutes } from "react-router-config"
import { Provider } from "react-redux"
import { ChunkExtractor } from "@loadable/server"
import { Helmet } from "react-helmet"
import chalk from "chalk"
import { Request, Response, NextFunction } from "express"
import { Action } from "@reduxjs/toolkit"
import createStore from "../store"
import renderHtml from "./renderHtml"
import routes from "../routes"
export default async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const { store } = createStore({ url: req.url })
// The method for loading data from server-side
const loadBranchData = (): Promise<any> => {
const branch = matchRoutes(routes, req.path)
const promises = branch.map(({ route, match }) => {
if (route.loadData)
return Promise.all(
route
.loadData({
params: match.params,
getState: store.getState,
req,
res,
})
.map((item: Action) => store.dispatch(item))
)
return Promise.resolve(null)
})
return Promise.all(promises)
}
try {
// Load data from server-side first
await loadBranchData()
const statsFile = path.resolve(process.cwd(), "public/loadable-stats")
const extractor = new ChunkExtractor({ statsFile })
const staticContext: Record<string, any> = {}
const App = extractor.collectChunks(
<Provider store={store}>
{/* Setup React-Router server-side rendering */}
<StaticRouter location={req.path} context={staticContext}>
{renderRoutes(routes)}
</StaticRouter>
</Provider>
)
const initialState = store.getState()
const htmlContent = renderToString(App)
// head must be placed after "renderToString"
// see: https://github.com/nfl/react-helmet#server-usage
const head = Helmet.renderStatic()
// Check if the render result contains a redirect, if so we need to set
// the specific status and redirect header and end the response
if (staticContext.url) {
res.status(301).setHeader("Location", staticContext.url)
res.end()
return
}
// Pass the route and initial state into html template, the "statusCode" comes from <NotFound />
res.status(staticContext.statusCode === "404" ? 404 : 200).send(
renderHtml(head, extractor, htmlContent, initialState)
)
} catch (error) {
res.status(404).send("Not Found :(")
console.error(chalk.red(`==> 😭 Rendering routes error: ${error}`))
}
next()
}

51
src/services/javGames.ts Normal file
View File

@@ -0,0 +1,51 @@
import axios from "axios"
import config from "../config"
export interface JavGame {
id: number
titre: string
auteur: string
editeur: string
minJoueurs: number
maxJoueurs: number
duree: number
type: "Ambiance" | "Famille" | "Expert" | ""
poufpaf: string
photo: string
bggPhoto: string
bggId: number
exemplaires: number // Defaults to 1
dispoPret: number
nonRangee: number
horodatage: string
ean: string
}
export interface JavGameList {
data?: JavGame[]
error?: Error
}
export interface JavGameData {
data?: JavGame
error?: Error
}
export const getJavGameList = async (): Promise<JavGameList> => {
try {
const { data } = await axios.get(`${config.API_URL}/javGames`)
return { data }
} catch (error) {
return { error: error as Error }
}
}
export const getJavGameData = async (id: string): Promise<JavGameData> => {
try {
const { data } = await axios.get(`${config.API_URL}/users/${id}`)
return { data }
} catch (error) {
return { error: error as Error }
}
}

View File

@@ -0,0 +1,37 @@
import axios from "axios"
export interface User {
id: number
name: string
phone: string
email: string
website: string
}
interface UserList {
data?: User[]
error?: Error
}
interface UserData {
data?: User
error?: Error
}
export const getUserList = async (): Promise<UserList> => {
try {
const { data } = await axios.get(`https://jsonplaceholder.typicode.com/users`)
return { data }
} catch (error) {
return { error: error as Error }
}
}
export const getUserData = async (id: string): Promise<UserData> => {
try {
const { data } = await axios.get(`https://jsonplaceholder.typicode.com/users/${id}`)
return { data }
} catch (error) {
return { error: error as Error }
}
}

13
src/static/logo.svg Executable file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0" y="0" width="570" height="510" viewBox="0, 0, 570, 510">
<g id="Background">
<rect x="0" y="0" width="570" height="510" fill="#000000" fill-opacity="0"/>
</g>
<g id="Layer_1">
<path d="M334.696,254.628 C334.696,282.334 312.235,304.795 284.529,304.795 C256.823,304.795 234.362,282.334 234.362,254.628 C234.362,226.922 256.823,204.461 284.529,204.461 C312.235,204.461 334.696,226.922 334.696,254.628 z" fill="#00D8FF"/>
<path d="M284.529,152.628 C351.885,152.628 414.457,162.293 461.636,178.535 C518.48,198.104 553.43,227.768 553.43,254.628 C553.43,282.619 516.389,314.131 455.347,334.356 C409.196,349.647 348.468,357.628 284.529,357.628 C218.975,357.628 156.899,350.136 110.239,334.187 C51.193,314.005 15.628,282.084 15.628,254.628 C15.628,227.986 48.998,198.552 105.043,179.012 C152.398,162.503 216.515,152.628 284.529,152.628 z" fill-opacity="0" stroke="#00D8FF" stroke-width="24" stroke-miterlimit="10"/>
<path d="M195.736,203.922 C229.385,145.574 269.017,96.198 306.656,63.442 C352.006,23.976 395.163,8.519 418.431,21.937 C442.679,35.92 451.473,83.751 438.498,146.733 C428.688,194.351 405.264,250.945 373.322,306.334 C340.573,363.122 303.072,413.153 265.945,445.606 C218.964,486.674 173.545,501.535 149.76,487.819 C126.681,474.509 117.854,430.898 128.926,372.586 C138.281,323.316 161.758,262.841 195.736,203.922 z" fill-opacity="0" stroke="#00D8FF" stroke-width="24" stroke-miterlimit="10"/>
<path d="M195.821,306.482 C162.075,248.19 139.09,189.195 129.509,140.227 C117.965,81.228 126.127,36.118 149.373,22.661 C173.597,8.637 219.428,24.905 267.513,67.601 C303.869,99.881 341.201,148.438 373.236,203.774 C406.08,260.507 430.697,317.983 440.272,366.356 C452.389,427.569 442.581,474.34 418.819,488.096 C395.762,501.444 353.57,487.312 308.58,448.597 C270.567,415.886 229.898,365.344 195.821,306.482 z" fill-opacity="0" stroke="#00D8FF" stroke-width="24" stroke-miterlimit="10"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,96 @@
import axios from "axios"
import mockStore from "../../utils/mockStore"
import javGameList, {
initialState,
getRequesting,
getSuccess,
getFailure,
fetchJavGameList,
} from "../javGameList"
jest.mock("axios")
const mockData = [
{
id: 5,
titre: "6 qui prend!",
auteur: "Wolfgang Kramer",
editeur: "(uncredited) , Design Edge , B",
minJoueurs: 2,
maxJoueurs: 10,
duree: 45,
type: "Ambiance",
poufpaf: "0-9-2/6-qui-prend-6-nimmt",
photo: "https://cf.geekdo-images.com/thumb/img/lzczxR5cw7an7tRWeHdOrRtLyes=/fit-in/200x150/pic772547.jpg",
bggPhoto: "",
bggId: 432,
exemplaires: 1,
dispoPret: 1,
nonRangee: 0,
horodatage: "0000-00-00",
ean: "3421272101313",
},
]
const mockError = "Oops! Something went wrong."
describe("javGameList reducer", () => {
it("should handle initial state", () => {
// @ts-expect-error
expect(javGameList(undefined, {})).toEqual(initialState)
})
it("should handle requesting correctly", () => {
expect(javGameList(undefined, { type: getRequesting.type })).toEqual({
readyStatus: "request",
items: [],
error: null,
})
})
it("should handle success correctly", () => {
expect(javGameList(undefined, { type: getSuccess.type, payload: mockData })).toEqual({
...initialState,
readyStatus: "success",
items: mockData,
})
})
it("should handle failure correctly", () => {
expect(javGameList(undefined, { type: getFailure.type, payload: mockError })).toEqual({
...initialState,
readyStatus: "failure",
error: mockError,
})
})
})
describe("javGameList action", () => {
it("fetches javGame list successful", async () => {
const { dispatch, getActions } = mockStore()
const expectedActions = [
{ type: getRequesting.type },
{ type: getSuccess.type, payload: mockData },
]
// @ts-expect-error
axios.get.mockResolvedValue({ data: mockData })
await dispatch(fetchJavGameList())
expect(getActions()).toEqual(expectedActions)
})
it("fetches javGame list failed", async () => {
const { dispatch, getActions } = mockStore()
const expectedActions = [
{ type: getRequesting.type },
{ type: getFailure.type, payload: mockError },
]
// @ts-expect-error
axios.get.mockRejectedValue({ message: mockError })
await dispatch(fetchJavGameList())
expect(getActions()).toEqual(expectedActions)
})
})

View File

@@ -0,0 +1,83 @@
import axios from "axios"
import mockStore from "../../utils/mockStore"
import userData, { getRequesting, getSuccess, getFailure, fetchUserData } from "../userData"
jest.mock("axios")
const mockData = {
id: 1,
name: "PeL",
phone: "+886 0970...",
email: "forceoranj@gmail.com",
website: "https://www.parisestludique.fr",
}
const { id } = mockData
const mockError = "Oops! Something went wrong."
describe("userData reducer", () => {
it("should handle initial state correctly", () => {
// @ts-expect-error
expect(userData(undefined, {})).toEqual({})
})
it("should handle requesting correctly", () => {
expect(userData(undefined, { type: getRequesting.type, payload: id })).toEqual({
[id]: { readyStatus: "request" },
})
})
it("should handle success correctly", () => {
expect(
userData(undefined, {
type: getSuccess.type,
payload: { id, item: mockData },
})
).toEqual({
[id]: { readyStatus: "success", item: mockData },
})
})
it("should handle failure correctly", () => {
expect(
userData(undefined, {
type: getFailure.type,
payload: { id, error: mockError },
})
).toEqual({
[id]: { readyStatus: "failure", error: mockError },
})
})
})
describe("userData action", () => {
const strId = id.toString()
it("fetches user data successful", async () => {
const { dispatch, getActions } = mockStore()
const expectedActions = [
{ type: getRequesting.type, payload: strId },
{ type: getSuccess.type, payload: { id: strId, item: mockData } },
]
// @ts-expect-error
axios.get.mockResolvedValue({ data: mockData })
await dispatch(fetchUserData(strId))
expect(getActions()).toEqual(expectedActions)
})
it("fetches user data failed", async () => {
const { dispatch, getActions } = mockStore()
const expectedActions = [
{ type: getRequesting.type, payload: strId },
{ type: getFailure.type, payload: { id: strId, error: mockError } },
]
// @ts-expect-error
axios.get.mockRejectedValue({ message: mockError })
await dispatch(fetchUserData(strId))
expect(getActions()).toEqual(expectedActions)
})
})

View File

@@ -0,0 +1,84 @@
import axios from "axios"
import mockStore from "../../utils/mockStore"
import userList, {
initialState,
getRequesting,
getSuccess,
getFailure,
fetchUserList,
} from "../userList"
jest.mock("axios")
const mockData = [
{
id: 1,
name: "PeL",
phone: "+886 0970...",
email: "forceoranj@gmail.com",
website: "https://www.parisestludique.fr",
},
]
const mockError = "Oops! Something went wrong."
describe("userList reducer", () => {
it("should handle initial state", () => {
// @ts-expect-error
expect(userList(undefined, {})).toEqual(initialState)
})
it("should handle requesting correctly", () => {
expect(userList(undefined, { type: getRequesting.type })).toEqual({
readyStatus: "request",
items: [],
error: null,
})
})
it("should handle success correctly", () => {
expect(userList(undefined, { type: getSuccess.type, payload: mockData })).toEqual({
...initialState,
readyStatus: "success",
items: mockData,
})
})
it("should handle failure correctly", () => {
expect(userList(undefined, { type: getFailure.type, payload: mockError })).toEqual({
...initialState,
readyStatus: "failure",
error: mockError,
})
})
})
describe("userList action", () => {
it("fetches user list successful", async () => {
const { dispatch, getActions } = mockStore()
const expectedActions = [
{ type: getRequesting.type },
{ type: getSuccess.type, payload: mockData },
]
// @ts-expect-error
axios.get.mockResolvedValue({ data: mockData })
await dispatch(fetchUserList())
expect(getActions()).toEqual(expectedActions)
})
it("fetches user list failed", async () => {
const { dispatch, getActions } = mockStore()
const expectedActions = [
{ type: getRequesting.type },
{ type: getFailure.type, payload: mockError },
]
// @ts-expect-error
axios.get.mockRejectedValue({ message: mockError })
await dispatch(fetchUserList())
expect(getActions()).toEqual(expectedActions)
})
})

41
src/store/index.ts Normal file
View File

@@ -0,0 +1,41 @@
import { createMemoryHistory, createBrowserHistory } from "history"
import { Action, configureStore } from "@reduxjs/toolkit"
import { ThunkAction } from "redux-thunk"
import { routerMiddleware } from "connected-react-router"
import createRootReducer from "./rootReducer"
interface Arg {
initialState?: typeof window.__INITIAL_STATE__
url?: string
}
// Use inferred return type for making correctly Redux types
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
const createStore = ({ initialState, url }: Arg = {}) => {
const history = __SERVER__
? createMemoryHistory({ initialEntries: [url || "/"] })
: createBrowserHistory()
const store = configureStore({
preloadedState: initialState,
reducer: createRootReducer(history),
middleware: (getDefaultMiddleware) => [
// Included default middlewares: https://redux-toolkit.js.org/api/getDefaultMiddleware#included-default-middleware
...getDefaultMiddleware(),
routerMiddleware(history),
],
devTools: __DEV__,
})
return { store, history }
}
const { store } = createStore()
export type AppState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export type AppThunk = ThunkAction<void, AppState, unknown, Action<string>>
export default createStore

57
src/store/javGameList.ts Normal file
View File

@@ -0,0 +1,57 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
import { JavGame, getJavGameList } from "../services/javGames"
import { AppThunk, AppState } from "."
interface JavGameList {
readyStatus: string
items: JavGame[]
error: string | null
}
export const initialState: JavGameList = {
readyStatus: "invalid",
items: [],
error: null,
}
const javGameList = createSlice({
name: "javGameList",
initialState,
reducers: {
getRequesting: (state: JavGameList) => {
state.readyStatus = "request"
},
getSuccess: (state, { payload }: PayloadAction<JavGame[]>) => {
state.readyStatus = "success"
state.items = payload
},
getFailure: (state, { payload }: PayloadAction<string>) => {
state.readyStatus = "failure"
state.error = payload
},
},
})
export default javGameList.reducer
export const { getRequesting, getSuccess, getFailure } = javGameList.actions
export const fetchJavGameList = (): AppThunk => async (dispatch) => {
dispatch(getRequesting())
const { error, data } = await getJavGameList()
if (error) {
dispatch(getFailure(error.message))
} else {
dispatch(getSuccess(data as JavGame[]))
}
}
const shouldFetchJavGameList = (state: AppState) => state.javGameList.readyStatus !== "success"
export const fetchJavGameListIfNeed = (): AppThunk => (dispatch, getState) => {
if (shouldFetchJavGameList(getState())) return dispatch(fetchJavGameList())
return null
}

16
src/store/rootReducer.ts Normal file
View File

@@ -0,0 +1,16 @@
import { History } from "history"
import { connectRouter } from "connected-react-router"
import userList from "./userList"
import userData from "./userData"
import javGameList from "./javGameList"
// Use inferred return type for making correctly Redux types
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export default (history: History) => ({
userList,
userData,
javGameList,
router: connectRouter(history) as any,
// Register more reducers...
})

66
src/store/userData.ts Normal file
View File

@@ -0,0 +1,66 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
import { User, getUserData } from "../services/jsonPlaceholder"
import { AppThunk, AppState } from "."
interface UserDate {
[id: string]: {
readyStatus: string
item?: User
error?: string
}
}
interface Success {
id: string
item: User
}
interface Failure {
id: string
error: string
}
const userData = createSlice({
name: "userData",
initialState: {} as UserDate,
reducers: {
getRequesting: (state, { payload }: PayloadAction<string>) => {
state[payload] = { readyStatus: "request" }
},
getSuccess: (state, { payload }: PayloadAction<Success>) => {
state[payload.id] = { readyStatus: "success", item: payload.item }
},
getFailure: (state, { payload }: PayloadAction<Failure>) => {
state[payload.id] = { readyStatus: "failure", error: payload.error }
},
},
})
export default userData.reducer
export const { getRequesting, getSuccess, getFailure } = userData.actions
export const fetchUserData =
(id: string): AppThunk =>
async (dispatch) => {
dispatch(getRequesting(id))
const { error, data } = await getUserData(id)
if (error) {
dispatch(getFailure({ id, error: error.message }))
} else {
dispatch(getSuccess({ id, item: data as User }))
}
}
const shouldFetchUserData = (state: AppState, id: string) =>
state.userData[id]?.readyStatus !== "success"
export const fetchUserDataIfNeed =
(id: string): AppThunk =>
(dispatch, getState) => {
if (shouldFetchUserData(getState(), id)) return dispatch(fetchUserData(id))
return null
}

57
src/store/userList.ts Normal file
View File

@@ -0,0 +1,57 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
import { User, getUserList } from "../services/jsonPlaceholder"
import { AppThunk, AppState } from "."
interface UserList {
readyStatus: string
items: User[]
error: string | null
}
export const initialState: UserList = {
readyStatus: "invalid",
items: [],
error: null,
}
const userList = createSlice({
name: "userList",
initialState,
reducers: {
getRequesting: (state: UserList) => {
state.readyStatus = "request"
},
getSuccess: (state, { payload }: PayloadAction<User[]>) => {
state.readyStatus = "success"
state.items = payload
},
getFailure: (state, { payload }: PayloadAction<string>) => {
state.readyStatus = "failure"
state.error = payload
},
},
})
export default userList.reducer
export const { getRequesting, getSuccess, getFailure } = userList.actions
export const fetchUserList = (): AppThunk => async (dispatch) => {
dispatch(getRequesting())
const { error, data } = await getUserList()
if (error) {
dispatch(getFailure(error.message))
} else {
dispatch(getSuccess(data as User[]))
}
}
const shouldFetchUserList = (state: AppState) => state.userList.readyStatus !== "success"
export const fetchUserListIfNeed = (): AppThunk => (dispatch, getState) => {
if (shouldFetchUserList(getState())) return dispatch(fetchUserList())
return null
}

2
src/theme/variables.scss Executable file
View File

@@ -0,0 +1,2 @@
$color-dark-gray: #333;
$color-white: #eee;

26
src/types/index.d.ts vendored Normal file
View File

@@ -0,0 +1,26 @@
declare const __CLIENT__: boolean
declare const __SERVER__: boolean
declare const __DEV__: boolean
declare module "*.svg"
declare module "*.gif"
declare module "*.png"
declare module "*.jpg"
declare module "*.jpeg"
declare module "*.webp"
declare module "*.css"
declare module "*.scss"
declare namespace NodeJS {
interface Global {
__CLIENT__: boolean
__SERVER__: boolean
__DEV__: boolean
$RefreshReg$: () => void
$RefreshSig$$: () => void
}
}
interface Window {
__INITIAL_STATE__: Record<string, unknown>
}

16
src/utils/mockStore.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { ReactNode } from "react"
import { Provider } from "react-redux"
import thunk from "redux-thunk"
import configurecreateMockStore from "redux-mock-store"
export default (obj = {}): Record<string, any> => {
const store = configurecreateMockStore([thunk])(obj)
const originalDispatch = store.dispatch
store.dispatch = jest.fn(originalDispatch)
const ProviderWithStore = ({ children }: { children: ReactNode }) => (
<Provider store={store}>{children}</Provider>
)
return { ...store, ProviderWithStore }
}