Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: KnpLabs/training-react-gitclicker
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.3
Choose a base ref
...
head repository: KnpLabs/training-react-gitclicker
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref

Commits on Mar 14, 2021

  1. feat: step 5 - added tests

    Antoine Lelaisant committed Mar 14, 2021
    Copy the full SHA
    c696164 View commit details

Commits on Mar 15, 2021

  1. feat: step 6 - redux thunk

    Antoine Lelaisant committed Mar 15, 2021
    Copy the full SHA
    dbdff68 View commit details
  2. feat: step 7 - API calls

    Antoine Lelaisant committed Mar 15, 2021
    Copy the full SHA
    0cb3c71 View commit details
  3. feat: step 8 - Routing and page skeleton for configurator

    Antoine Lelaisant committed Mar 15, 2021
    Copy the full SHA
    03a2e0a View commit details

Commits on Mar 16, 2021

  1. feat: step 9 - CRUD with API

    Antoine Lelaisant committed Mar 16, 2021
    Copy the full SHA
    80f8798 View commit details

Commits on Mar 18, 2021

  1. feat: step 10 - mock API calls with msw

    Antoine Lelaisant committed Mar 18, 2021
    Copy the full SHA
    de33282 View commit details

Commits on Mar 19, 2021

  1. feat: storybook

    Antoine Lelaisant committed Mar 19, 2021
    Copy the full SHA
    7b51918 View commit details

Commits on Mar 22, 2021

  1. feat: optimize form input and install storybook

    Antoine Lelaisant committed Mar 22, 2021
    Copy the full SHA
    efa5b3f View commit details

Commits on Feb 23, 2024

  1. Copy the full SHA
    ed84856 View commit details
  2. Copy the full SHA
    4153cbf View commit details
  3. Copy the full SHA
    61a1181 View commit details

Commits on Feb 26, 2024

  1. Copy the full SHA
    4222561 View commit details
  2. Copy the full SHA
    ad08ede View commit details
  3. Copy the full SHA
    6c4e4ec View commit details
  4. Copy the full SHA
    38ce231 View commit details
  5. Copy the full SHA
    c6ec063 View commit details
  6. Copy the full SHA
    fda82ec View commit details
  7. feat: add redux thunk

    AntoineGonzalez committed Feb 26, 2024
    Copy the full SHA
    ee13ed4 View commit details
  8. Copy the full SHA
    1398cef View commit details
  9. Copy the full SHA
    125efa7 View commit details
  10. Copy the full SHA
    e8186c6 View commit details

Commits on Feb 27, 2024

  1. Merge pull request #2 from KnpLabs/chore/typescript-version

    chore: typescript version
    AntoineGonzalez authored Feb 27, 2024
    Copy the full SHA
    1eae806 View commit details
Showing with 8,445 additions and 9,157 deletions.
  1. +1 −0 .env.dist
  2. +32 −27 .eslintrc.js
  3. +1 −0 .gitignore
  4. +12 −12 README.md
  5. +0 −13 jsconfig.json
  6. +24 −18 package.json
  7. 0 plan.md
  8. +0 −2 public/index.html
  9. +3 −1 src/assets/github.svg
  10. BIN src/assets/intern.jpg
  11. +2 −2 src/components/App.css
  12. +0 −32 src/components/App.js
  13. +52 −0 src/components/App.tsx
  14. +21 −21 src/components/Game/Game.css
  15. +31 −0 src/components/Game/Game.test.tsx
  16. +16 −12 src/components/Game/{Game.js → Game.tsx}
  17. 0 src/components/Game/{index.js → index.ts}
  18. +23 −23 src/components/Gitcoin/Gitcoin.css
  19. +0 −20 src/components/Gitcoin/Gitcoin.js
  20. +40 −0 src/components/Gitcoin/Gitcoin.test.tsx
  21. +18 −0 src/components/Gitcoin/Gitcoin.tsx
  22. 0 src/components/Gitcoin/{index.js → index.ts}
  23. +16 −0 src/components/Home.test.tsx
  24. +8 −8 src/components/{Home.js → Home.tsx}
  25. +24 −0 src/components/Rules/CreateItemForm.css
  26. +164 −0 src/components/Rules/CreateItemForm.tsx
  27. +185 −0 src/components/Rules/EditItemForm.tsx
  28. +91 −0 src/components/Rules/ItemsList.tsx
  29. +44 −0 src/components/Rules/Rules.tsx
  30. +1 −0 src/components/Rules/index.ts
  31. +0 −14 src/components/Score.js
  32. +20 −0 src/components/Score/Score.test.tsx
  33. +14 −0 src/components/Score/Score.tsx
  34. +1 −0 src/components/Score/index.ts
  35. +39 −0 src/components/Skills/Section.test.tsx
  36. +15 −9 src/components/Skills/{Section.js → Section.tsx}
  37. +31 −0 src/components/Skills/Skills.test.tsx
  38. +6 −6 src/components/Skills/{Skills.js → Skills.tsx}
  39. 0 src/components/Skills/{index.js → index.ts}
  40. +17 −17 src/components/Store/Item.css
  41. +0 −46 src/components/Store/Item.js
  42. +61 −0 src/components/Store/Item.test.tsx
  43. +41 −0 src/components/Store/Item.tsx
  44. +2 −2 src/components/Store/Store.css
  45. +0 −31 src/components/Store/Store.js
  46. +60 −0 src/components/Store/Store.test.tsx
  47. +29 −0 src/components/Store/Store.tsx
  48. 0 src/components/Store/{index.js → index.ts}
  49. +0 −2 src/components/layout/{Navbar.js → Navbar.tsx}
  50. +0 −8 src/configureStore.js
  51. +5 −0 src/custom.d.ts
  52. +8 −5 src/{index.js → index.tsx}
  53. +8 −6 src/{items.js → items.ts}
  54. +0 −53 src/modules/game.js
  55. +299 −0 src/modules/game.test.ts
  56. +94 −0 src/modules/game.ts
  57. +0 −6 src/modules/index.js
  58. +8 −0 src/modules/index.ts
  59. +129 −0 src/modules/rules.ts
  60. +3 −1 src/{reportWebVitals.js → reportWebVitals.ts}
  61. +1 −0 src/setupTests.ts
  62. +25 −0 src/store.ts
  63. +17 −0 src/type.ts
  64. +27 −0 src/utils/getItemIcon.test.ts
  65. +21 −0 src/utils/getItemIcon.ts
  66. +18 −0 src/utils/numberFormat.test.ts
  67. +15 −0 src/utils/numberFormat.ts
  68. +21 −0 tsconfig.json
  69. +6,601 −8,760 yarn.lock
1 change: 1 addition & 0 deletions .env.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
REACT_APP_API_URL=http://localhost:7000
59 changes: 32 additions & 27 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,35 @@
module.exports = {
'root': true,
'env': {
'node': true,
'es6': true,
'browser': true,
},
'plugins': [
'react',
'react-hooks',
],
'parser': 'babel-eslint',
'extends': [
'eslint:recommended',
'plugin:react/recommended',
],
'rules': {
'semi': ['error', 'never'],
'quotes': ['error', 'single'],
'react/jsx-uses-react': 'error',
'react/jsx-uses-vars': 'error',
},
'settings': {
'react': {
'pragma': 'React',
'version': 'detect',
root: true,
env: {
node: true,
browser: true,
es6: true,
'jest/globals': true,
},
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:@typescript-eslint/recommended',
],
plugins: [
'react',
'react-hooks',
'@typescript-eslint',
'jest'
],
parser: '@typescript-eslint/parser',
rules: {
semi: ['error', 'never'],
quotes: ['error', 'single'],
'react/react-in-jsx-scope': 'off',
'react/jsx-uses-react': 'error',
'react/jsx-uses-vars': 'error',
},
settings: {
react: {
pragma: 'React',
version: 'detect',
},
},
}
}

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@
.env.development.local
.env.test.local
.env.production.local
.env

npm-debug.log*
yarn-debug.log*
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -6,20 +6,20 @@ This project was bootstrapped with [Create React App](https://github.com/faceboo

In the project directory, you can run:

### `yarn start`
### `npm start`

Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.

The page will reload if you make edits.\
You will also see any lint errors in the console.
The page will reload when you make changes.\
You may also see any lint errors in the console.

### `yarn test`
### `npm test`

Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.

### `yarn build`
### `npm run build`

Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
@@ -29,15 +29,15 @@ Your app is ready to be deployed!

See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.

### `yarn eject`
### `npm run eject`

**Note: this is a one-way operation. Once you `eject`, you cant go back!**
**Note: this is a one-way operation. Once you `eject`, you can't go back!**

If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.

Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.

You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.

## Learn More

@@ -65,6 +65,6 @@ This section has moved here: [https://facebook.github.io/create-react-app/docs/a

This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)

### `yarn build` fails to minify
### `npm run build` fails to minify

This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
13 changes: 0 additions & 13 deletions jsconfig.json

This file was deleted.

42 changes: 24 additions & 18 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
{
"name": "react-fpp",
"name": "training-react-gitclicker",
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2",
"@reduxjs/toolkit": "^2.2.1",
"devicon": "^2.10.1",
"prop-types": "^15.7.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-redux": "^7.2.2",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.2",
"redux": "^4.0.5",
"web-vitals": "^1.0.1"
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.1",
"react-scripts": "5.0.1",
"typescript": "5.1.6",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.20",
"@types/react": "^18.2.58",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"eslint": "^8.56.0",
"eslint-plugin-jest": "^27.9.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0"
},
"scripts": {
"start": "react-scripts start",
@@ -38,14 +53,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.6.3",
"babel-eslint": "^10.1.0",
"eslint": "^7.21.0",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-react-hooks": "^4.2.0"
}
}
Empty file removed plan.md
Empty file.
2 changes: 0 additions & 2 deletions public/index.html
Original file line number Diff line number Diff line change
@@ -24,8 +24,6 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link rel="stylesheet" type="text/css" href="//fonts.googleapis.com/css?family=Orbitron" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<title>React App</title>
</head>
<body>
4 changes: 3 additions & 1 deletion src/assets/github.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed src/assets/intern.jpg
Binary file not shown.
4 changes: 2 additions & 2 deletions src/components/App.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
a {
color: inherit;
text-decoration: none;
color: inherit;
text-decoration: none;
}
32 changes: 0 additions & 32 deletions src/components/App.js

This file was deleted.

52 changes: 52 additions & 0 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
createBrowserRouter,
RouterProvider
} from 'react-router-dom'
import { Game } from './Game'
import { Home } from './Home'
import './App.css'
import { CssBaseline } from '@material-ui/core'
import { Provider } from 'react-redux'
import store from '../store'
import { Rules } from './Rules'
import { ItemsList } from './Rules/ItemsList'
import { CreateItemForm } from './Rules/CreateItemForm'
import { EditItemForm } from './Rules/EditItemForm'

const router = createBrowserRouter([
{
path: '/',
element: <Home />
},
{
path: '/gitclicker',
element: <Game />
},
{
path: '/rules',
element: <Rules />,
children: [
{
path: '/rules',
element: <ItemsList />
},
{
path: '/rules/add',
element: <CreateItemForm />
},
{
path: '/rules/edit/:id',
element: <EditItemForm />
}
]
}
])

export default function App() {
return (
<Provider store={store}>
<CssBaseline />
<RouterProvider router={router} />
</Provider>
)
}
42 changes: 21 additions & 21 deletions src/components/Game/Game.css
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
.game {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}

.game .left {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 5rem;
margin: 4rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 5rem;
margin: 4rem 2rem;
}

.game .center {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
padding: 2rem 5rem;
margin: 4rem 0;
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
padding: 2rem 5rem;
margin: 4rem 0;
}

.game .right {
display: flex;
flex-direction: column;
flex: 1;
align-items: flex-start;
padding: 2rem 2rem;
margin: 4rem 2rem;
display: flex;
flex-direction: column;
flex: 1;
align-items: flex-start;
padding: 2rem 2rem;
margin: 4rem 2rem;
}
31 changes: 31 additions & 0 deletions src/components/Game/Game.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Game } from './Game'
import { Provider } from 'react-redux'
import { MemoryRouter as Router } from 'react-router-dom'
import { render, screen } from '@testing-library/react'
import { createStore } from '../../store'

describe('Game', () => {
it('it renders correctly', async () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
items: [],
skills: {}
}
}

render(
<Provider store={createStore(initialState)}>
<Router>
<Game />
</Router>
</Provider>
)

expect(screen.getByText(/6 lines/)).toBeInTheDocument()
expect(screen.getByText(/per second: 20/)).toBeInTheDocument()
expect(screen.getByText(/Skills/)).toBeInTheDocument()
expect(screen.getByText(/Store/)).toBeInTheDocument()
})
})
28 changes: 16 additions & 12 deletions src/components/Game/Game.js → src/components/Game/Game.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import React, { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import Paper from '@material-ui/core/Paper'
import { useEffect } from 'react'
import './Game.css'
import { loop } from 'modules/game'
import { Navbar } from 'components/layout/Navbar'
import { Gitcoin } from 'components/Gitcoin'
import { Score } from 'components/Score'
import { Store } from 'components/Store/Store'
import { Skills } from 'components/Skills'
import Paper from '@material-ui/core/Paper'
import { Navbar } from '../layout/Navbar'
import { Gitcoin } from '../Gitcoin'
import { Score } from '../Score'
import { Store } from '../Store'
import { Skills } from '../Skills'
import { start, stop, loop } from '../../modules/game.ts'
import { useAppDispatch } from '../../store.ts'

export const Game = () => {
const dispatch = useDispatch()
const dispatch = useAppDispatch()

useEffect(() => {
dispatch(start())
const interval = setInterval(() => {
dispatch(loop())
}, 100)

return () => clearInterval(interval)
})
return () => {
clearInterval(interval)
dispatch(stop())
}
}, [])

return (
<>
File renamed without changes.
46 changes: 23 additions & 23 deletions src/components/Gitcoin/Gitcoin.css
Original file line number Diff line number Diff line change
@@ -1,37 +1,37 @@
.gitcoin {
display: block;
width: 15rem;
height: 15rem;
border: none;
background: transparent;
outline: none;
display: block;
width: 15rem;
height: 15rem;
border: none;
background: transparent;
outline: none;
}

.gitcoin > img {
width: 100%;
border-radius: 100%;
animation: bounce-up 0.2s;
cursor: pointer;
width: 100%;
border-radius: 100%;
animation: bounce-up 0.2s;
cursor: pointer;
}

.gitcoin:active > img {
animation: bounce-down 0.2s;
animation: bounce-down 0.2s;
}

@keyframes bounce-up {
0% {
transform: scale(0.9)
}
100% {
transform: scale(1)
}
0% {
transform: scale(0.9)
}
100% {
transform: scale(1)
}
}

@keyframes bounce-down {
0% {
transform: scale(1)
}
100% {
transform: scale(0.9)
}
0% {
transform: scale(1)
}
100% {
transform: scale(0.9)
}
}
20 changes: 0 additions & 20 deletions src/components/Gitcoin/Gitcoin.js

This file was deleted.

40 changes: 40 additions & 0 deletions src/components/Gitcoin/Gitcoin.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Gitcoin } from './Gitcoin'
import { render, screen, fireEvent } from '@testing-library/react'
import { click } from '../../modules/game'
import { createStore } from '../../store'
import { Provider, useDispatch } from 'react-redux'

jest.mock('react-redux', () => {
const dispatch = jest.fn()

return {
...jest.requireActual('react-redux'),
useDispatch: () => dispatch
}
})

describe('Gitcoin', () => {
it('Allows to click', () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
skills: {},
items: []
}
}

render(
<Provider store={createStore(initialState)}>
<Gitcoin />
</Provider>
)

expect(screen.getByAltText(/Gitcoin/i)).toBeInTheDocument()

const dispatch = useDispatch()

fireEvent.click(screen.getByAltText(/Gitcoin/i))
expect(dispatch).toHaveBeenCalledWith(click())
})
})
18 changes: 18 additions & 0 deletions src/components/Gitcoin/Gitcoin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import './Gitcoin.css'
import githubIcon from '../../assets/github.svg'
import { useAppDispatch } from '../../store'
import { click } from '../../modules/game'

export const Gitcoin = () => {
const dispatch = useAppDispatch()
const handleClick = () => dispatch(click())

return (
<button
className="gitcoin"
onClick={handleClick}
>
<img src={githubIcon} alt="Gitcoin" />
</button>
)
}
File renamed without changes.
16 changes: 16 additions & 0 deletions src/components/Home.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Home } from './Home'
import { MemoryRouter as Router } from 'react-router-dom'
import { render, screen } from '@testing-library/react'

describe('Home', () => {
it('Renders correctly', () => {
render(
<Router>
<Home />
</Router>
)

expect(screen.getByText(/Dogs have boundless enthusiasm/i)).toBeInTheDocument()
expect(screen.getByText(/Play/i)).toBeInTheDocument()
})
})
16 changes: 8 additions & 8 deletions src/components/Home.js → src/components/Home.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import React from 'react'
import Button from '@material-ui/core/Button'
import Grid from '@material-ui/core/Grid'
import Typography from '@material-ui/core/Typography'
@@ -17,14 +16,13 @@ const useStyles = makeStyles((theme) => ({
},
}))

export const Home = () => {
export function Home() {
const classes = useStyles()

return (
<>
<Navbar />
<main>
{/* Hero unit */}
<div className={classes.heroContent}>
<Container maxWidth="sm">
<Typography component="h1" variant="h2" align="center" color="textPrimary" gutterBottom>
@@ -34,18 +32,20 @@ export const Home = () => {
Dogs have boundless enthusiasm but no sense of shame. I should have a dog as a life coach.
</Typography>
<div className={classes.heroButtons}>
<Grid container spacing={2} justify="center">
<Grid container spacing={2} justifyContent="center">
<Grid item>
<Link to="/game">
<Link to="/gitclicker">
<Button variant="contained" color="primary">
Play
</Button>
</Link>
</Grid>
<Grid item>
<Button variant="outlined" color="primary">
Secondary action
</Button>
<Link to="/rules">
<Button variant="contained" color="primary">
Rules
</Button>
</Link>
</Grid>
</Grid>
</div>
24 changes: 24 additions & 0 deletions src/components/Rules/CreateItemForm.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.row {
margin-bottom: 10px;
}

.row.centered {
display: flex;
justify-content: center;
}

.input {
width: 100%;
padding: 10px;
margin: 5px 0;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}

.error-message {
color: red;
font-size: 12px;
margin-top: 0px;
font-style: italic;
}
164 changes: 164 additions & 0 deletions src/components/Rules/CreateItemForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Button, Input, InputLabel } from '@material-ui/core'
import AddIcon from '@material-ui/icons/Add'
import { useEffect, useState } from 'react'
import './CreateItemForm.css'
import { RootState, useAppDispatch } from '../../store'
import { addItem, setAddItemRequestStatus } from '../../modules/rules'
import { useNavigate } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { RequestStatus } from '../../type'

type FormValues = {
name: string;
price: number;
linesPerMillisecond: number;
}

type FormErrors = {
name?: string;
price?: string;
linesPerMillisecond?: string;
}

export function CreateItemForm() {
const dispatch = useAppDispatch()
const navigate = useNavigate()
const requestStatus = useSelector((state: RootState) => state.rules.addItemRequestStatus)

useEffect(() => {
if (requestStatus === RequestStatus.Succeeded) {
dispatch(setAddItemRequestStatus(RequestStatus.Idle))

navigate('/rules')
}
}, [requestStatus, navigate, dispatch])

const [formValues, setFormValues] = useState<FormValues>({
name: '',
price: 0,
linesPerMillisecond: 0,
})
const [formErrors, setFormErrors] = useState<FormErrors>({})

const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
name: string
) => {
setFormValues({ ...formValues, [name]: e.target.value })
}

const validateForm = () => {
const errors: FormErrors = {}

if (!formValues.name) {
errors.name = 'Name is required'
}

if (!formValues.price) {
errors.price = 'Price is required'
}

if (formValues.price < 0) {
errors.price = 'Price must be a positive number'
}

if (!formValues.linesPerMillisecond) {
errors.linesPerMillisecond = 'Lines per millisecond is required'
}

if (formValues.linesPerMillisecond <= 0) {
errors.linesPerMillisecond = 'Lines per millisecond must be greater than 0'
}

setFormErrors(errors)

return Object.keys(errors).length === 0
}

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()

if(!validateForm()) {
return
}

dispatch(addItem(formValues))
}

return (
<form onSubmit={handleSubmit}>
<div className='row'>
<InputLabel htmlFor='name'>Name *</InputLabel>
<Input
id='name'
className='input'
type='text'
name='name'
placeholder='Item Name'
value={formValues.name}
error={formErrors.name != null}
onChange={(e) => handleChange(e, 'name')}
/>
{
formErrors.name && (
<p className='error-message'>
{formErrors.name}
</p>
)
}
</div>
<div className='row'>
<InputLabel htmlFor='price'>Price *</InputLabel>
<Input
id='price'
className='input'
type='number'
name='price'
value={formValues.price}
error={formErrors.price != null}
onChange={(e) => handleChange(e, 'price')}
/>
{
formErrors.price && (
<p className='error-message'>
{formErrors.price}
</p>
)
}
</div>
<div className='row'>
<InputLabel htmlFor='linesPerMillisecond'>Lines per millisecond *</InputLabel>
<Input
id='linesPerMillisecond'
className='input'
type='number'
name='linesPerMillisecond'
inputProps={{
step: 0.1,
}}
value={formValues.linesPerMillisecond}
error={formErrors.linesPerMillisecond != null}
onChange={(e) => handleChange(e, 'linesPerMillisecond')}
/>
{
formErrors.linesPerMillisecond && (
<p className='error-message'>
{formErrors.linesPerMillisecond}
</p>
)
}
</div>
<div className='row centered'>
<Button
type='submit'
variant='contained'
disabled={requestStatus === RequestStatus.Loading}
startIcon={<AddIcon />}
color='primary'
>
Add Item
</Button>
</div>
</form>
)
}
185 changes: 185 additions & 0 deletions src/components/Rules/EditItemForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { Button, Input, InputLabel } from '@material-ui/core'
import SaveIcon from '@material-ui/icons/Save'
import { useEffect, useState } from 'react'
import './CreateItemForm.css'
import { RootState, useAppDispatch } from '../../store'
import { editItem, setEditItemRequestStatus } from '../../modules/rules'
import { useSelector } from 'react-redux'
import { RequestStatus } from '../../type'
import { useNavigate, useParams } from 'react-router-dom'

type FormValues = {
id: number;
name: string;
price: number;
linesPerMillisecond: number;
}

type FormErrors = {
name?: string;
price?: string;
linesPerMillisecond?: string;
}

export function EditItemForm() {
const dispatch = useAppDispatch()
const navigate = useNavigate()
const { id: itemId } = useParams()
const item = useSelector((state: RootState) => {
if(itemId == null) {
return null
}

return state.rules.items.find((item) => item.id === parseInt(itemId))
})
const requestStatus = useSelector((state: RootState) => state.rules.editItemRequestStatus)

useEffect(() => {
if (item == null) {
navigate('/rules')
}
}, [item, navigate])

useEffect(() => {
if (requestStatus === RequestStatus.Succeeded) {
dispatch(setEditItemRequestStatus(RequestStatus.Idle))

navigate('/rules')
}
}, [requestStatus, navigate, dispatch])

const [formValues, setFormValues] = useState<FormValues>({
id: item?.id ?? 0,
name: item?.name ?? '',
price: item?.price ?? 0,
linesPerMillisecond: item?.linesPerMillisecond ?? 0,
})

const [formErrors, setFormErrors] = useState<FormErrors>({})

const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
name: string
) => {
setFormValues({ ...formValues, [name]: e.target.value })
}

const validateForm = () => {
const errors: FormErrors = {}

if (!formValues.name) {
errors.name = 'Name is required'
}

if (!formValues.price) {
errors.price = 'Price is required'
}

if (formValues.price < 0) {
errors.price = 'Price must be a positive number'
}

if (!formValues.linesPerMillisecond) {
errors.linesPerMillisecond = 'Lines per millisecond is required'
}

if (formValues.linesPerMillisecond <= 0) {
errors.linesPerMillisecond = 'Lines per millisecond must be greater than 0'
}

setFormErrors(errors)

return Object.keys(errors).length === 0
}

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()

if(!validateForm()) {
return
}

dispatch(editItem(formValues))
}

if (item == null) {
return null
}

return (
<form onSubmit={handleSubmit}>
<div className='row'>
<InputLabel htmlFor='name'>Name *</InputLabel>
<Input
id='name'
className='input'
type='text'
name='name'
placeholder='Item Name'
value={formValues.name}
error={formErrors.name != null}
onChange={(e) => handleChange(e, 'name')}
/>
{
formErrors.name && (
<p className='error-message'>
{formErrors.name}
</p>
)
}
</div>
<div className='row'>
<InputLabel htmlFor='price'>Price *</InputLabel>
<Input
id='price'
className='input'
type='number'
name='price'
value={formValues.price}
error={formErrors.price != null}
onChange={(e) => handleChange(e, 'price')}
/>
{
formErrors.price && (
<p className='error-message'>
{formErrors.price}
</p>
)
}
</div>
<div className='row'>
<InputLabel htmlFor='linesPerMillisecond'>Lines per millisecond *</InputLabel>
<Input
id='linesPerMillisecond'
className='input'
type='number'
name='linesPerMillisecond'
inputProps={{
step: 0.1,
}}
value={formValues.linesPerMillisecond}
error={formErrors.linesPerMillisecond != null}
onChange={(e) => handleChange(e, 'linesPerMillisecond')}
/>
{
formErrors.linesPerMillisecond && (
<p className='error-message'>
{formErrors.linesPerMillisecond}
</p>
)
}
</div>
<div className='row centered'>
<Button
type='submit'
variant='contained'
disabled={requestStatus === RequestStatus.Loading}
startIcon={<SaveIcon />}
color='primary'
>
Save
</Button>
</div>
</form>
)
}
91 changes: 91 additions & 0 deletions src/components/Rules/ItemsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useSelector } from 'react-redux'
import DeleteIcon from '@material-ui/icons/Delete'
import EditIcon from '@material-ui/icons/Edit'
import IconButton from '@material-ui/core/IconButton'
import Table from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableHead from '@material-ui/core/TableHead'
import TableRow from '@material-ui/core/TableRow'
import Paper from '@material-ui/core/Paper'
import AddIcon from '@material-ui/icons/Add'
import numberFormat from '../../utils/numberFormat'
import { RootState, useAppDispatch } from '../../store'
import { useNavigate } from 'react-router-dom'
import { Fab, makeStyles } from '@material-ui/core'
import { deleteItem } from '../../modules/rules'
import { Item, RequestStatus } from '../../type'

const useStyles = makeStyles((theme) => ({
heroContent: {
padding: theme.spacing(8, 0, 6),
},
fab: {
position: 'absolute',
bottom: theme.spacing(2),
right: theme.spacing(2),
},
}))


export const ItemsList = () => {
const items = useSelector((state: RootState) => state.rules.items)
const requestStatus = useSelector((state: RootState) => state.rules.deleteItemRequestStatus)
const classes = useStyles()
const dispatch = useAppDispatch()
const navigate = useNavigate()
const handleDelete = (item: Item) => {
dispatch(deleteItem(item.id))
}

return (
<>
<TableContainer component={Paper}>
<Table aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell align="right">Price</TableCell>
<TableCell align="right">Lines per seconds</TableCell>
<TableCell align="right">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell component="th" scope="row">{item.name}</TableCell>
<TableCell align="right">{numberFormat(item.price)}</TableCell>
<TableCell align="right">{numberFormat(item.linesPerMillisecond * 10)}</TableCell>
<TableCell align="right">
<IconButton
onClick={() => navigate(`/rules/edit/${item.id}`)}
aria-label="edit"
>
<EditIcon />
</IconButton>
<IconButton
color="secondary"
aria-label="delete"
disabled={requestStatus === RequestStatus.Loading}
onClick={() => handleDelete(item)}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Fab
onClick={() => navigate('/rules/add')}
className={classes.fab}
color="primary"
aria-label="add"
>
<AddIcon />
</Fab>
</>
)
}
44 changes: 44 additions & 0 deletions src/components/Rules/Rules.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useEffect } from 'react'
import { Outlet } from 'react-router-dom'
import Typography from '@material-ui/core/Typography'
import { makeStyles } from '@material-ui/core/styles'
import Container from '@material-ui/core/Container'
import { fetchItems } from '../../modules/rules'
import { Navbar } from '../layout/Navbar'
import { useAppDispatch } from '../../store'

const useStyles = makeStyles((theme) => ({
heroContent: {
padding: theme.spacing(8, 0, 6),
},
fab: {
position: 'absolute',
bottom: theme.spacing(2),
right: theme.spacing(2),
},
}))

export const Rules = () => {
const classes = useStyles()
const dispatch = useAppDispatch()

useEffect(() => {
dispatch(fetchItems())
}, [])

return (
<>
<Navbar />
<main>
<div className={classes.heroContent}>
<Container maxWidth="sm">
<Typography component="h1" variant="h2" align="center" color="textPrimary" gutterBottom>
Configurator
</Typography>
<Outlet />
</Container>
</div>
</main>
</>
)
}
1 change: 1 addition & 0 deletions src/components/Rules/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Rules } from './Rules'
14 changes: 0 additions & 14 deletions src/components/Score.js

This file was deleted.

20 changes: 20 additions & 0 deletions src/components/Score/Score.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Score } from './Score'
import { Provider } from 'react-redux'
import { render } from '@testing-library/react'
import { createStore } from '../../store'

describe('Score', () => {
it('should displays the number of lines', () => {
const initialState = {
game: { lines: 6, linesPerMillisecond: 2, skills: {}, items: []}
}

const { getByText } = render(
<Provider store={createStore(initialState)}>
<Score />
</Provider>
)

expect(getByText(/6 lines/i)).toBeInTheDocument()
})
})
14 changes: 14 additions & 0 deletions src/components/Score/Score.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useSelector } from 'react-redux'
import { RootState } from '../../store'

export const Score = () => {
const lines = useSelector((state: RootState) => state.game.lines)
const linesPerMillisecond = useSelector((state: RootState) => state.game.linesPerMillisecond)

return (
<>
<h3 style={{fontFamily: 'Orbitron'}}>{Math.ceil(lines)} lines</h3>
<small>per second: {Math.ceil(linesPerMillisecond * 10)}</small>
</>
)
}
1 change: 1 addition & 0 deletions src/components/Score/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Score } from './Score'
39 changes: 39 additions & 0 deletions src/components/Skills/Section.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react'
import { Section } from './Section'
import { Provider } from 'react-redux'
import { createStore } from '../../store'

describe('Section', () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
skills: {},
items: [{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.1
}]
}
}

it('Displays the owned skills', () => {
render(
<Provider store={createStore(initialState)}>
<Section itemName="Bash" number={3} />
</Provider>
)

expect(screen.getByText('Bash')).toBeInTheDocument()
expect(screen.getAllByAltText('Bash')).toHaveLength(3)
})

it('Render anything on unknown skill', () => {
<Provider store={createStore(initialState)}>
render(<Section itemName="Unknown" number={3} />)
</Provider>

expect(screen.queryByText('Unknown')).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import React from 'react'
import PropTypes from 'prop-types'
import Typography from '@material-ui/core/Typography'
import items from '../../items'
import { useSelector } from 'react-redux'
import { RootState } from '../../store'
import getItemIcon from '../../utils/getItemIcon'

export const Section = ({ itemName, number }) => {
type Props = {
itemName: string
number: number
}

export const Section = ({ itemName, number }: Props) => {
const repeat = Array.from([ ...Array(number).keys() ])
const items = useSelector((state: RootState) => state.game.items)
const item = items.find(element => element.name === itemName)

if(item == null) {
return null
}

return (
<div className="section">
<Typography variant="subtitle2">{item.name}</Typography>
<div className="icons">
{repeat.map(key =>
<img
key={key}
src={item.icon}
src={getItemIcon(item)}
alt={item.name}
/>
)}
@@ -23,7 +33,3 @@ export const Section = ({ itemName, number }) => {
)
}

Section.propTypes = {
itemName: PropTypes.string.isRequired,
number: PropTypes.number.isRequired,
}
31 changes: 31 additions & 0 deletions src/components/Skills/Skills.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Provider } from 'react-redux'
import { render, screen } from '@testing-library/react'
import { Skills } from './Skills'
import { createStore } from '../../store'

describe('Skills', () => {
it('Renders correctly', () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
skills: { 'Bash': 2, 'Git': 3, 'Javascript': 4 },
items: [
{ id: 1, name: 'Bash', price: 10, linesPerMillisecond: 0.1 },
{ id: 2, name: 'Git', price: 20, linesPerMillisecond: 0.2 },
{ id: 3, name: 'Javascript', price: 30, linesPerMillisecond: 0.3 }
]
}
}

render(
<Provider store={createStore(initialState)}>
<Skills />
</Provider>
)

expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/Git/i)).toBeInTheDocument()
expect(screen.getByText(/Javascript/i)).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import React from 'react'
import { useSelector } from 'react-redux'
import './Skills.css'
import Typography from '@material-ui/core/Typography'
import { Section } from './Section'
import { useSelector } from 'react-redux'
import { RootState } from '../../store'

export const Skills = () => {
const skills = useSelector(state => state.game.skills)
const skills = useSelector((state: RootState) => state.game.skills)

return (
<>
<Typography variant="h5">Skills</Typography>
<div className="skills">
{Object.keys(skills).map((name, key) =>
<Section
<Section
key={key}
itemName={name}
number={skills[name]}
itemName={name}
number={skills[name]}
/>
)}
</div>
File renamed without changes.
34 changes: 17 additions & 17 deletions src/components/Store/Item.css
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
.item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding: 0.5rem;
border-radius: 0.3rem;
user-select: none;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding: 0.5rem;
border-radius: 0.3rem;
user-select: none;
}

.item:hover {
background-color: #f8f9fa;
cursor: pointer;
background-color: #f8f9fa;
cursor: pointer;
}

.item > .title {
display: flex;
align-items: center;
justify-content: flex-start;
display: flex;
align-items: center;
justify-content: flex-start;
}

.item > .title > h6 {
margin-bottom: 0;
margin-bottom: 0;
}

.item > .title > img {
width: 2rem;
height: 2rem;
margin-right: 0.8rem;
width: 2rem;
height: 2rem;
margin-right: 0.8rem;
}
46 changes: 0 additions & 46 deletions src/components/Store/Item.js

This file was deleted.

61 changes: 61 additions & 0 deletions src/components/Store/Item.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import BashIcon from 'devicon/icons/bash/bash-original.svg'
import { render, screen, fireEvent } from '@testing-library/react'
import { Item } from './Item'

describe('Item', () => {
it('Renders a buyable item', () => {
const item = {
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.1,
icon: BashIcon
}

const onBuy = jest.fn()

render(
<Item
item={item}
lines={150}
onBuy={onBuy}
/>
)

expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/1 lines per second/i)).toBeInTheDocument()
expect(screen.getByRole('button')).not.toBeDisabled()

fireEvent.click(screen.getByRole('button'))

expect(onBuy).toHaveBeenCalledWith(item)
})

it('Renders a non buyable item', () => {
const item = {
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.1,
icon: BashIcon
}

const onBuy = jest.fn()

render(
<Item
item={item}
lines={0}
onBuy={onBuy}
/>
)

expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/1 lines per second/i)).toBeInTheDocument()
expect(screen.getByRole(/button/i)).toBeDisabled()

fireEvent.click(screen.getByRole('button'))

expect(onBuy).not.toHaveBeenCalledWith(item)
})
})
41 changes: 41 additions & 0 deletions src/components/Store/Item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Button from '@material-ui/core/Button'
import Typography from '@material-ui/core/Typography'
import './Item.css'
import { Item as ItemType } from '../../type'
import getItemIcon from '../../utils/getItemIcon'

type Props = {
item: ItemType;
lines: number;
onBuy: (item: ItemType) => void;
}

export const Item = ({ item, lines, onBuy }: Props) => {
const canBuy = (item: ItemType) => {
return lines >= item.price
}

const linePerSecond = Math.ceil(item.linesPerMillisecond * 10)

return (
<div
className="item"
onClick={() => canBuy(item) && onBuy(item)}
>
<div className="title">
<img src={getItemIcon(item)} alt={item.name} />
<div>
<Typography variant="subtitle1">{item.name}</Typography>
<small>{linePerSecond} lines per second</small>
</div>
</div>
<Button
variant="contained"
color="secondary"
disabled={!canBuy(item)}
>
{item.price}
</Button>
</div>
)
}
4 changes: 2 additions & 2 deletions src/components/Store/Store.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.store {
width: 100%;
width: 100%;
}

.store h5 {
margin-bottom: 2rem;
margin-bottom: 2rem;
}
31 changes: 0 additions & 31 deletions src/components/Store/Store.js

This file was deleted.

60 changes: 60 additions & 0 deletions src/components/Store/Store.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Provider } from 'react-redux'
import { render, screen } from '@testing-library/react'
import { Store } from './Store'
import { createStore } from '../../store'

describe('Store', () => {
it('Renders correctly', () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
skills: {},
items: [
{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.1
},
{
id: 2,
name: 'Git',
price: 100,
linesPerMillisecond: 1.2,
},
{
id: 3,
name: 'Javascript',
price: 10000,
linesPerMillisecond: 14.0,
},
{
id: 4,
name: 'React',
price: 50000,
linesPerMillisecond: 75.0,
},
{
id: 5,
name: 'Vim',
price: 999999,
linesPerMillisecond: 10000.0,
}
]
}
}

render(
<Provider store={createStore(initialState)}>
<Store />
</Provider>
)

expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/Git/i)).toBeInTheDocument()
expect(screen.getByText(/Javascript/i)).toBeInTheDocument()
expect(screen.getByText(/React/i)).toBeInTheDocument()
expect(screen.getByText(/Vim/i)).toBeInTheDocument()
})
})
29 changes: 29 additions & 0 deletions src/components/Store/Store.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Typography from '@material-ui/core/Typography'
import './Store.css'
import { Item } from './Item'
import { Item as ItemType } from '../../type'
import { useSelector } from 'react-redux'
import { buyItem } from '../../modules/game'
import { RootState, useAppDispatch } from '../../store'

export const Store = () => {
const lines = useSelector((state: RootState) => state.game.lines)
const items = useSelector((state: RootState) => state.game.items)
const dispatch = useAppDispatch()
const handleBuy = (item: ItemType) => dispatch(buyItem(item))

return (
<div className="store">
<Typography variant="h5">Store</Typography>

{items.map((item) =>
<Item
key={item.id}
item={item}
lines={lines}
onBuy={handleBuy}
/>
)}
</div>
)
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import React from 'react'
import { Link } from 'react-router-dom'
import AppBar from '@material-ui/core/AppBar'
import GithubIcon from '@material-ui/icons/GitHub'
@@ -30,4 +29,3 @@ export const Navbar = () => {
</AppBar>
)
}

8 changes: 0 additions & 8 deletions src/configureStore.js

This file was deleted.

5 changes: 5 additions & 0 deletions src/custom.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module '*.svg' {
const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>
const src: string
export default src
}
13 changes: 8 additions & 5 deletions src/index.js → src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom'
import ReactDOM from 'react-dom/client'
import reportWebVitals from './reportWebVitals'
import { App } from './components/App'
import App from './components/App'

ReactDOM.render(
<App />,
document.getElementById('root')
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)

root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)

// If you want to start measuring performance in your app, pass a function
14 changes: 8 additions & 6 deletions src/items.js → src/items.ts
Original file line number Diff line number Diff line change
@@ -4,35 +4,37 @@ import JavascriptIcon from 'devicon/icons/javascript/javascript-original.svg'
import ReactIcon from 'devicon/icons/react/react-original.svg'
import VimIcon from 'devicon/icons/vim/vim-original.svg'

export default [
const items = [
{
'name': 'Bash',
'price': 10,
'multiplier': 0.1,
'linesPerMillisecond': 0.1,
'icon': BashIcon
},
{
'name': 'Git',
'price': 100,
'multiplier': 1.2,
'linesPerMillisecond': 1.2,
'icon': GitIcon
},
{
'name': 'Javascript',
'price': 10000,
'multiplier': 14.0,
'linesPerMillisecond': 14.0,
'icon': JavascriptIcon
},
{
'name': 'React',
'price': 50000,
'multiplier': 75.0,
'linesPerMillisecond': 75.0,
'icon': ReactIcon
},
{
'name': 'Vim',
'price': 999999,
'multiplier': 10000.0,
'linesPerMillisecond': 10000.0,
'icon': VimIcon
}
]

export default items
53 changes: 0 additions & 53 deletions src/modules/game.js

This file was deleted.

299 changes: 299 additions & 0 deletions src/modules/game.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
import game, { buyItem, click, loop } from './game'

describe('game reducer', () => {
it('should handle loop action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: []
}

const action = loop()

const expectedState = {
lines: 12,
linesPerMillisecond: 6,
skills: {},
items: []
}

expect(game.reducer(state, action)).toEqual(expectedState)
})

it('should handle click action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: []
}

const action = click()

const expectedState = {
lines: 7,
linesPerMillisecond: 6,
skills: {},
items: []
}

expect(game.reducer(state, action)).toEqual(expectedState)
})

it('should handle buyItem action, with no existing skills', () => {
const item = {
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg'
}

const action = buyItem(item)

const state = {
lines: 25,
linesPerMillisecond: 6,
skills: {},
items: []
}

const expectedState = {
lines: 15,
linesPerMillisecond: 6.5,
skills: {
'Bash': 1
},
items: []
}

expect(game.reducer(state, action)).toEqual(expectedState)
})

it('should handle buyItem action, when the skill has already been bought', () => {
const item = {
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg'
}

const action = buyItem(item)

const state = {
lines: 25,
linesPerMillisecond: 6,
skills: {
'Bash': 4
},
items: []
}

const expectedState = {
lines: 15,
linesPerMillisecond: 6.5,
skills: {
'Bash': 5
},
items: []
}

expect(game.reducer(state, action)).toEqual(expectedState)
})

it('should handle buyItem action, when another skill has already been bought', () => {
const item = {
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg'
}

const action = buyItem(item)

const state = {
lines: 25,
linesPerMillisecond: 6,
skills: {
'Bash': 4,
'Javascript': 2,
'Vim': 1
},
items: []
}

const expectedState = {
lines: 15,
linesPerMillisecond: 6.5,
skills: {
'Bash': 5,
'Javascript': 2,
'Vim': 1
},
items: []
}

expect(game.reducer(state, action)).toEqual(expectedState)
})

it('should handle unknown action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: []
}

const action = { type: 'UNKNOWN ACTION' }

expect(game.reducer(state, action)).toEqual(state)
})

it('should handle initGame action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: []
}

const action = {
type: 'game/initGame',
payload: {
lines: 10,
linesPerMillisecond: 10,
skills: {
'Bash': 5,
'Javascript': 2
},
items: [
{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg'
},
{
id: 2,
name: 'Git',
price: 100,
linesPerMillisecond: 1.2,
icon: '/some/icon/path.svg'
},
{
id: 3,
name: 'Javascript',
price: 10000,
linesPerMillisecond: 14.0,
icon: '/some/icon/path.svg'
}
]
}
}

const expectedState = {
lines: 10,
linesPerMillisecond: 10,
skills: {
'Bash': 5,
'Javascript': 2,
},
items: [
{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg'
},
{
id: 2,
name: 'Git',
price: 100,
linesPerMillisecond: 1.2,
icon: '/some/icon/path.svg'
},
{
id: 3,
name: 'Javascript',
price: 10000,
linesPerMillisecond: 14.0,
icon: '/some/icon/path.svg'
},
]
}

expect(game.reducer(state, action)).toEqual(expectedState)
})

it('should handle fetchedItems action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: []
}

const action = {
type: 'game/fetchedItems',
payload: [
{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg'
},
{
id: 2,
name: 'Git',
price: 100,
linesPerMillisecond: 1.2,
icon: '/some/icon/path.svg'
},
{
id: 3,
name: 'Javascript',
price: 10000,
linesPerMillisecond: 14.0,
icon: '/some/icon/path.svg'
}
]
}

const expectedState = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: [
{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg'
},
{
id: 2,
name: 'Git',
price: 100,
linesPerMillisecond: 1.2,
icon: '/some/icon/path.svg'
},
{
id: 3,
name: 'Javascript',
price: 10000,
linesPerMillisecond: 14.0,
icon: '/some/icon/path.svg'
}
]
}

expect(game.reducer(state, action)).toEqual(expectedState)
})
})
94 changes: 94 additions & 0 deletions src/modules/game.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { Item, OwnedItems } from '../type'
import { RootState } from '../store'

// Initial state
type GameState = {
lines: number;
linesPerMillisecond: number;
skills: OwnedItems;
items: Item[];
}

const INITIAL_STATE: GameState = {
lines: 0,
linesPerMillisecond: 0,
skills: {},
items: []
}

// Side Effects / thunks
export const start = createAsyncThunk(
'game/start',
async (_, { dispatch }) => {
const localStoredGame = localStorage.getItem('game')
const initalGameState = localStoredGame ? JSON.parse(localStoredGame) : {}

dispatch(initGame(initalGameState))

const response = await fetch(`${process.env.REACT_APP_API_URL}/api/shop/items`)
const items = await response.json()

dispatch(fetchedItems(items))
}
)

export const stop = createAsyncThunk(
'game/stop',
async (_, { getState }) => {
const state = getState() as RootState
const serializedGameState = JSON.stringify(state.game)

localStorage.setItem('game', serializedGameState)
}
)

const game = createSlice({
name: 'game',
initialState: INITIAL_STATE,
reducers: {
initGame: (state, action: PayloadAction<GameState>) => {
return {
...state,
...action.payload
}
},
fetchedItems: (state, action: PayloadAction<Item[]>) => {
return {
...state,
items: action.payload
}
},
click: state => {
state.lines += 1
},
buyItem: (state, action: PayloadAction<Item>) => {
const { name, price, linesPerMillisecond: itemLinesPerMillisecond } = action.payload

return {
...state,
lines: state.lines - price,
linesPerMillisecond: state.linesPerMillisecond + itemLinesPerMillisecond,
skills: {
...state.skills,
[name]: (state.skills[name] || 0) + 1
}
}
},
loop: state => {
state.lines += state.linesPerMillisecond
}
}
})

const {
click,
buyItem,
loop,
initGame,
fetchedItems
} = game.actions

export { click, buyItem, loop }

export default game
6 changes: 0 additions & 6 deletions src/modules/index.js

This file was deleted.

8 changes: 8 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { combineReducers } from '@reduxjs/toolkit'
import game from './game'
import rules from './rules'

export const rootReducer = combineReducers({
game: game.reducer,
rules: rules.reducer
})
129 changes: 129 additions & 0 deletions src/modules/rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { Item, RequestStatus } from '../type'

// Initial state
type RulesState = {
items: Item[];
addItemRequestStatus: RequestStatus;
editItemRequestStatus: RequestStatus;
deleteItemRequestStatus: RequestStatus;
}

const INITIAL_STATE: RulesState = {
items: [],
addItemRequestStatus: RequestStatus.Idle,
editItemRequestStatus: RequestStatus.Idle,
deleteItemRequestStatus: RequestStatus.Idle
}

// Side Effects / thunks
export const fetchItems = createAsyncThunk(
'rules/fetchItems',
async (_, { dispatch }) => {
const response = await fetch(`${process.env.REACT_APP_API_URL}/api/shop/items`)
const items = await response.json() as Item[]

dispatch(fetchedItems(items))
}
)

export const addItem = createAsyncThunk(
'rules/addItem',
async (itemData: Omit<Item, 'id'>, { dispatch }) => {
dispatch(setAddItemRequestStatus(RequestStatus.Loading))

const response = await fetch(`${process.env.REACT_APP_API_URL}/api/shop/items`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
})

const newItem = await response.json() as Item

dispatch(itemReceived(newItem))
dispatch(setAddItemRequestStatus(RequestStatus.Succeeded))
}
)

export const editItem = createAsyncThunk(
'rules/editItem',
async (itemData: Item, { dispatch }) => {
dispatch(setEditItemRequestStatus(RequestStatus.Loading))

const response = await fetch(`${process.env.REACT_APP_API_URL}/api/shop/items/${itemData.id}`, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
})
const updatedItem = await response.json() as Item

dispatch(itemUpdated(updatedItem))
dispatch(setEditItemRequestStatus(RequestStatus.Succeeded))
}
)

export const deleteItem = createAsyncThunk(
'rules/deleteItem',
async (itemId: number, { dispatch }) => {
await fetch(`${process.env.REACT_APP_API_URL}/api/shop/items/${itemId}`, {
method: 'DELETE',
})

dispatch(itemDeleted(itemId))
}
)

const rules = createSlice({
name: 'rule',
initialState: INITIAL_STATE,
reducers: {
fetchedItems: (state, action: PayloadAction<Item[]>) => {
state.items = action.payload
},
itemReceived: (state, action: PayloadAction<Item>) => {
state.items.push(action.payload)
},
itemUpdated: (state, action: PayloadAction<Item>) => {
const index = state.items.findIndex((item) => item.id === action.payload.id)

if (index !== -1) {
state.items[index] = action.payload
}
},
itemDeleted: (state, action: PayloadAction<number>) => {
state.items = state.items.filter((item) => item.id !== action.payload)
},
setAddItemRequestStatus: (state, action: PayloadAction<RequestStatus>) => {
state.addItemRequestStatus = action.payload
},
setEditItemRequestStatus: (state, action: PayloadAction<RequestStatus>) => {
state.editItemRequestStatus = action.payload
},
setDeleteItemRequestStatus: (state, action: PayloadAction<RequestStatus>) => {
state.deleteItemRequestStatus = action.payload
}
}
})

const {
fetchedItems,
setAddItemRequestStatus,
setEditItemRequestStatus,
itemReceived,
itemUpdated,
itemDeleted
} = rules.actions

export {
fetchedItems,
setAddItemRequestStatus,
setEditItemRequestStatus
}

export default rules
4 changes: 3 additions & 1 deletion src/reportWebVitals.js → src/reportWebVitals.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const reportWebVitals = onPerfEntry => {
import { ReportHandler } from 'web-vitals'

const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry)
1 change: 1 addition & 0 deletions src/setupTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import '@testing-library/jest-dom'
25 changes: 25 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { configureStore } from '@reduxjs/toolkit'
import { rootReducer } from './modules'
import game from './modules/game'
import { useDispatch } from 'react-redux'

const defaultState = {
game: game.getInitialState()
}

export function createStore(
initialState = defaultState
) {
return configureStore({
reducer: rootReducer,
preloadedState: initialState
})
}

const store = createStore()

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch: () => AppDispatch = useDispatch

export default store
17 changes: 17 additions & 0 deletions src/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export type Item = {
id: number;
name: string;
price: number;
linesPerMillisecond: number;
}

export type OwnedItems = {
[key: string]: number;
}

export enum RequestStatus {
Idle = 'idle',
Loading = 'loading',
Succeeded = 'succeeded',
Failed = 'failed'
}
27 changes: 27 additions & 0 deletions src/utils/getItemIcon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import BashIcon from 'devicon/icons/bash/bash-original.svg'
import IEIcon from 'devicon/icons/ie10/ie10-original.svg'
import getItemIcon from './getItemIcon'

describe('getItemIcon', () => {
it('provides icon for a known item', () => {
const item = {
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5
}

expect(getItemIcon(item)).toBe(BashIcon)
})

it('provides default icon for unknown item', () => {
const item = {
id: 1,
name: 'Unknown item',
price: 10,
linesPerMillisecond: 0.5
}

expect(getItemIcon(item)).toBe(IEIcon)
})
})
21 changes: 21 additions & 0 deletions src/utils/getItemIcon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import BashIcon from 'devicon/icons/bash/bash-original.svg'
import GitIcon from 'devicon/icons/git/git-original.svg'
import JavascriptIcon from 'devicon/icons/javascript/javascript-original.svg'
import ReactIcon from 'devicon/icons/react/react-original.svg'
import VimIcon from 'devicon/icons/vim/vim-original.svg'
import IEIcon from 'devicon/icons/ie10/ie10-original.svg'
import { Item } from '../type'

type ItemName = keyof typeof iconMap

const iconMap = {
'Bash': BashIcon,
'Git': GitIcon,
'Javascript': JavascriptIcon,
'React': ReactIcon,
'Vim': VimIcon
}

export default (item: Item) => {
return iconMap[item.name as ItemName] ?? IEIcon
}
18 changes: 18 additions & 0 deletions src/utils/numberFormat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import numberFormat from './numberFormat'

describe('numberFormat', () => {
it('format numbers bellow 1.000', () => {
const number = 234
expect(numberFormat(number)).toBe('234')
})

it('format numbers bellow 1.000.000', () => {
const number = 123234
expect(numberFormat(number)).toBe('123.234')
})

it('format numbers above 1.000.000', () => {
const number = 12123234
expect(numberFormat(number)).toBe('12.123 millions')
})
})
15 changes: 15 additions & 0 deletions src/utils/numberFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
function numberWithDot(x: number) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.')
}

function numberFormat(x: number) {
const number = Math.ceil(x)

if (number < 1000000) return numberWithDot(number)

const numberOfMillion = Math.floor(number / 1000000 * 1000) / 1000

return `${numberWithDot(numberOfMillion)} millions`
}

export default numberFormat
21 changes: 21 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"skipLibCheck": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": [
"src"
],
}
15,361 changes: 6,601 additions & 8,760 deletions yarn.lock

Large diffs are not rendered by default.