Add project

main
midudev 3 years ago
commit b0006c9027

@ -0,0 +1,8 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-runtime"]
}
}
}

@ -0,0 +1,3 @@
webpack.config.js
node_modules/
dist/

@ -0,0 +1,49 @@
module.exports = {
"env": {
"browser": true,
"es6": true,
"jest/globals": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 2018,
"sourceType": "module"
},
"plugins": [
"react", "jest"
],
"rules": {
"indent": [
"error",
2
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"never"
],
"eqeqeq": "error",
"no-trailing-spaces": "error",
"object-curly-spacing": [
"error", "always"
],
"arrow-spacing": [
"error", { "before": true, "after": true }
],
"no-console": "error",
"react/prop-types": 0
}
}

2
.gitignore vendored

@ -0,0 +1,2 @@
dist/
node_modules/

@ -0,0 +1,15 @@
# Full Stack open CI/CD
This repository is used for the CI/CD module of the Full stack open course
Fork the repository to complete course exercises
## Commands
Start by running `npm install` inside the project folder
`npm start` to run the webpack dev server
`npm test` to run tests
`npm run eslint` to run eslint
`npm run build` to make a production build
`npm run start-prod` to run your production build

@ -0,0 +1,11 @@
const express = require("express");
const app = express();
// Heroku dynamically sets a port
const PORT = process.env.PORT || 5000;
app.use(express.static("dist"));
app.listen(PORT, () => {
console.log("server started on port 5000");
});

12975
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,54 @@
{
"name": "fullstackopen-cicd",
"version": "1.0.0",
"description": "Full Stack Open",
"main": "src/index.js",
"scripts": {
"start": "webpack-dev-server --open --mode development",
"start-prod": "node app.js",
"test": "jest",
"eslint": "eslint './**/*.{js,jsx}'",
"build": "webpack --mode production"
},
"repository": {
"type": "git",
"url": "git+https://github.com/smartlyio/fullstackopen-cicd.git"
},
"keywords": [
"fullstack-open"
],
"author": "Smartly.io",
"license": "ISC",
"bugs": {
"url": "https://github.com/smartlyio/fullstackopen-cicd/issues"
},
"homepage": "https://github.com/smartlyio/fullstackopen-cicd#readme",
"devDependencies": {
"@babel/core": "^7.9.6",
"@babel/plugin-transform-runtime": "^7.10.0",
"@babel/preset-env": "^7.9.6",
"@babel/preset-react": "^7.9.4",
"@testing-library/jest-dom": "^5.8.0",
"@testing-library/react": "^10.0.4",
"babel-jest": "^25.5.1",
"babel-loader": "^8.1.0",
"css-loader": "^3.5.3",
"eslint": "^6.8.0",
"eslint-plugin-jest": "^23.11.0",
"eslint-plugin-react": "^7.19.0",
"html-loader": "^1.1.0",
"html-webpack-plugin": "^4.3.0",
"jest": "^25.5.4",
"style-loader": "^1.2.1",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.11.0"
},
"dependencies": {
"axios": "^0.19.2",
"express": "^4.17.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0"
}
}

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Pokemon</title>
</head>
<body>
<div id="app"></div>
<footer>Pokémon and Pokémon character names are trademarks of Nintendo.</footer>
</body>
</html>

@ -0,0 +1,41 @@
import React from 'react'
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'
import { useApi } from './useApi'
import LoadingSpinner from './LoadingSpinner'
import ErrorMessage from './ErrorMessage'
import PokemonPage from './PokemonPage'
import PokemonList from './PokemonList'
const mapResults = (({ results }) => results.map(({ url, name }) => ({
url,
name,
id: parseInt(url.match(/\/(\d+)\//)[1])
})))
const App = () => {
const { data: pokemonList, error, isLoading } = useApi('https://pokeapi.co/api/v2/pokemon/?limit=784', mapResults)
if (isLoading) {
return <LoadingSpinner />
}
if (error) {
return <ErrorMessage error={error} />
}
return (
<Router>
<Switch>
<Route exact path="/">
<PokemonList pokemonList={pokemonList} />
</Route>
<Route path="/pokemon/:name" render={(routeParams) => {
const pokemonId = pokemonList.find(({ name }) => name === routeParams.match.params.name).id
const previous = pokemonList.find(({ id }) => id === pokemonId - 1)
const next = pokemonList.find(({ id }) => id === pokemonId + 1)
return <PokemonPage pokemonList={pokemonList} previous={previous} next={next} />
}} />
</Switch>
</Router>
)
}
export default App

@ -0,0 +1,7 @@
import React from 'react'
const ErrorMessage = ({ error }) => (
<div data-testid="error">An error occured: {error.toString()}</div>
)
export default ErrorMessage

@ -0,0 +1,7 @@
import React from 'react'
const LoadingSpinner = () => (
<img className="loading-spinner" alt="Loading..." src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png" />
)
export default LoadingSpinner

@ -0,0 +1,12 @@
import React from 'react'
const PokemonAbility = ({ abilityName }) => (
<div className="pokemon-ability">
<div className="pokemon-ability-type">Hidden ability</div>
<div className="pokemon-ability-name">
{abilityName}
</div>
</div>
)
export default PokemonAbility

@ -0,0 +1,20 @@
import React from 'react'
import { Link } from 'react-router-dom'
const PokemonList = ({ pokemonList }) => {
return (
<div className="list-container">
{pokemonList.map(({ id, name }) => (
<Link key={id} to={`/pokemon/${name}`} className="list-item" style={{ backgroundImage: `url(${`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`})` }}>
<div
className="list-item-name"
>
{name}
</div>
</Link>
))}
</div>
)
}
export default PokemonList

@ -0,0 +1,63 @@
import React from 'react'
import { Link, useParams } from 'react-router-dom'
import LoadingSpinner from './LoadingSpinner'
import { useApi } from './useApi'
import PokemonAbility from './PokemonAbility'
import ErrorMessage from './ErrorMessage'
const formatName = (nameWithDash) => nameWithDash.replace('-', ' ')
const PokemonPage = ({ previous, next }) => {
const { name } = useParams()
const { data: pokemon, error, isLoading } = useApi(`https://pokeapi.co/api/v2/pokemon/${name}`)
if (isLoading) {
return <LoadingSpinner />
}
if (error) {
return <ErrorMessage error={error} />
}
const { type } = pokemon.types.find((type) => type.slot === 1)
const stats = pokemon.stats.map((stat) => ({
name: formatName(stat.stat.name),
value: stat.base_stat
})).reverse()
const normalAbility = pokemon.abilities.find((ability) => !ability.is_hidden)
const hiddenAbility = pokemon.abilities.find((ability) => ability.is_hidden === true)
console.log('hiddenAbility=', hiddenAbility)
return (
<>
<div className="links">
{previous && <Link to={`/pokemon/${previous.name}`}>Previous</Link>}
<Link to="/">Home</Link>
{next && <Link to={`/pokemon/${previous.name}`}>Next</Link>}
</div>
<div className={`pokemon-page pokemon-type-${type.name}`}>
<div className="pokemon-image" style={{ backgroundImage: `url(${pokemon.sprites.front_default})` }} />
<div className="pokemon-info">
<div className="pokemon-name">{pokemon.name}</div>
<div className="pokemon-stats" data-testid="stats">
<table>
<tbody>
{stats.map(({ name, value }) => (
<tr key={name}>
<td className="pokemon-stats-name">{name}</td>
<td className="pokemon-stats-value">{value}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pokemon-abilities">
{normalAbility && <PokemonAbility abilityName={formatName(normalAbility.ability.name)} />}
{hiddenAbility && <PokemonAbility abilityName={formatName(hiddenAbility.ability.name)} />}
</div>
</div>
</div>
</>
)
}
export default PokemonPage

@ -0,0 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import './styles.css'
ReactDOM.render(<App />, document.getElementById('app'))

@ -0,0 +1,215 @@
@import url('https://fonts.googleapis.com/css?family=Open+Sans|Open+Sans+Condensed:300,700');
html, body {
height: 100%;
margin: 0;
}
body {
display: flex;
flex-direction: column;
font-family: "Open Sans";
font-size: 18px;
}
body > div {
flex: 1 0 auto;
}
footer {
color: #888;
font-size: 12px;
flex-shrink: 0;
}
.loading-spinner {
animation: rotation 2s infinite linear;
margin: 120px;
width: 80px;
}
@keyframes rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(359deg);
}
}
.list-container {
display: flex;
flex-wrap: wrap;
}
.list-item {
background-position: center;
background-repeat: no-repeat;
background-color: #eee;
margin: 10px;
height: 150px;
width: 150px;
position: relative;
}
.list-item-name {
position: absolute;
color: #000;
bottom: 0;
width: 100%;
text-align: center;
text-transform: capitalize;
background-color: #eee;
}
.links {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
text-decoration: none;
width: 332px;
}
.pokemon-page {
background: linear-gradient(140deg, var(--pokemon-type-color) 0%, #ddd 100%);
border-radius: 15px;
display: flex;
flex-direction: column;
height: 470px;
padding: 16px;
width: 300px;
}
.pokemon-image {
min-height: 150px;
width: 100%;
background-color: rgba(0, 0, 0, 0.7);
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.pokemon-name {
font-family: "Open Sans Condensed", "Open Sans";
font-size: 24px;
font-weight: bold;
text-align: center;
text-transform: capitalize;
}
.pokemon-info {
background-color: rgba(255, 255, 255, 0.65);
display: flex;
flex-direction: column;
flex-grow: 1;
height: 100%;
padding: 16px;
}
.pokemon-stats {
flex: 1;
font-family: "Open Sans Condensed", "Open Sans";
padding-top: 15px;
text-transform: capitalize;
}
.pokemon-stats table {
width: 100%;
}
.pokemon-stats-name {
font-weight: 300;
padding: 4px 8px 0 8px;
text-align: right;
width: 50%;
}
.pokemon-stats-value {
padding: 4px 8px 0 8px;
}
.pokemon-abilities {
display: flex;
margin-top: 20px;
text-transform: capitalize;
}
.pokemon-ability {
flex: 1;
}
.pokemon-ability-type {
font-size: 10px;
text-transform: uppercase;
}
.pokemon-type-bug {
--pokemon-type-color: #ac2;
}
.pokemon-type-dark {
--pokemon-type-color: #754;
}
.pokemon-type-dragon {
--pokemon-type-color: #73f;
}
.pokemon-type-electric {
--pokemon-type-color: #fd3;
}
.pokemon-type-fairy {
--pokemon-type-color: #d8b;
}
.pokemon-type-fighting {
--pokemon-type-color: #c33;
}
.pokemon-type-fire {
--pokemon-type-color: #f83;
}
.pokemon-type-flying {
--pokemon-type-color: #b9f;
}
.pokemon-type-ghost {
--pokemon-type-color: #759;
}
.pokemon-type-grass {
--pokemon-type-color: #8c5;
}
.pokemon-type-ground {
--pokemon-type-color: #ec6;
}
.pokemon-type-ice {
--pokemon-type-color: #9ed;
}
.pokemon-type-normal {
--pokemon-type-color: #ba8;
}
.pokemon-type-poison {
--pokemon-type-color: #a4a;
}
.pokemon-type-psychic {
--pokemon-type-color: #f59;
}
.pokemon-type-rock {
--pokemon-type-color: #ba3;
}
.pokemon-type-steel {
--pokemon-type-color: #bcd;
}
.pokemon-type-water {
--pokemon-type-color: #69f;
}

@ -0,0 +1,20 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
const useApi = (url, mapResults = (result) => result) => {
const [data, setData] = useState()
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState()
useEffect(() => {
setIsLoading(true)
axios
.get(url)
.then(response => setData(mapResults(response.data)))
.catch(setError)
.finally(() => setIsLoading(false))
}, [url])
return { data, isLoading, error }
}
export { useApi }

@ -0,0 +1,41 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import axiosMock from 'axios'
import { act } from 'react-dom/test-utils'
import '@testing-library/jest-dom/extend-expect'
import App from '../src/App'
jest.mock('axios')
describe('<App />', () => {
it('fetches data', async () => {
axiosMock.get.mockResolvedValueOnce(
{
data: {
results: [{ url: 'https://pokeapi.co/api/v2/pokemon/1/', name: 'bulbasaur', id: 1 }]
}
}
)
await act(async () => {
render(<App />)
})
expect(axiosMock.get).toHaveBeenCalledTimes(1)
expect(axiosMock.get).toHaveBeenCalledWith('https://pokeapi.co/api/v2/pokemon/?limit=784')
})
it('shows LoadingSpinner', async () => {
axiosMock.get.mockResolvedValueOnce({})
await act(async () => {
const { getByAltText } = render(<App />)
expect(getByAltText('Loading...')).toBeVisible()
})
})
it('shows error', async () => {
axiosMock.get.mockRejectedValueOnce(new Error())
await act(async () => {
render(<App />)
})
expect(screen.getByTestId('error')).toBeVisible()
})
})

@ -0,0 +1,28 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import '@testing-library/jest-dom/extend-expect'
import PokemonList from '../src/PokemonList'
const pokemonList = [{
url: 'https://pokeapi.co/api/v2/pokemon/1/',
name: 'bulbasaur',
id: 1
}, {
url: 'https://pokeapi.co/api/v2/pokemon/133/',
name: 'eevee',
id: 133
}]
describe('<PokemonList />', () => {
it('should render items', () => {
render(
<BrowserRouter>
<PokemonList pokemonList={pokemonList} />
</BrowserRouter>
)
expect(screen.getByText('bulbasaur')).toBeVisible()
expect(screen.getByText('eevee')).toBeVisible()
})
})

@ -0,0 +1,139 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { Router } from 'react-router-dom'
import { createMemoryHistory } from 'history'
import axiosMock from 'axios'
import { act } from 'react-dom/test-utils'
import '@testing-library/jest-dom/extend-expect'
import PokemonPage from '../src/PokemonPage'
jest.mock('axios')
const previous = {
url: 'https://pokeapi.co/api/v2/pokemon/132/',
name: 'ditto',
id: 132
}
const next = {
url: 'https://pokeapi.co/api/v2/pokemon/134/',
name: 'vaporeon',
id: 134
}
const pokemonList = {
id: 133,
abilities: [
{
ability: {
name: 'anticipation',
url: 'https://pokeapi.co/api/v2/ability/107/'
},
is_hidden: true,
slot: 3
},
{
ability: {
name: 'adaptability',
url: 'https://pokeapi.co/api/v2/ability/91/'
},
is_hidden: false,
slot: 2
}
],
name: 'eevee',
stats: [
{
base_stat: 55,
effort: 0,
stat: {
name: 'attack',
url: 'https://pokeapi.co/api/v2/stat/2/'
}
},
{
base_stat: 55,
effort: 0,
stat: {
name: 'hp',
url: 'https://pokeapi.co/api/v2/stat/1/'
}
}
],
types: [
{
slot: 1,
type: {
name: 'normal',
url: 'https://pokeapi.co/api/v2/type/1/'
}
}
],
sprites: { front_default: 'URL' }
}
const history = createMemoryHistory()
describe('<PokemonPage />', () => {
beforeEach(() => {
history.push('/pokemon/eevee')
})
it('should render abilities', async () => {
axiosMock.get.mockResolvedValueOnce({ data: pokemonList })
await act(async () => {
render(
<Router history={history}>
<PokemonPage />
</Router>
)
})
expect(screen.getByText('adaptability')).toBeVisible()
expect(screen.getByText('anticipation')).toBeVisible()
})
it('should render stats', async () => {
axiosMock.get.mockResolvedValueOnce({ data: pokemonList })
await act(async () => {
render(
<Router history={history}>
<PokemonPage />
</Router>
)
})
expect(screen.getByTestId('stats')).toHaveTextContent('hp55attack55')
})
it('should render previous and next urls if they exist', async () => {
axiosMock.get.mockResolvedValueOnce({ data: pokemonList })
await act(async () => {
render(
<Router history={history}>
<PokemonPage previous={previous} next={next} />
</Router>
)
})
expect(screen.getByText('Previous')).toHaveAttribute('href', '/pokemon/ditto')
expect(screen.getByText('Next')).toHaveAttribute('href', '/pokemon/vaporeon')
})
it('should not render previous and next urls if none exist', async () => {
axiosMock.get.mockResolvedValueOnce({ data: pokemonList })
await act(async () => {
render(
<Router history={history}>
<PokemonPage />
</Router>
)
})
expect(screen.queryByText('Previous')).toBeNull()
expect(screen.queryByText('Next')).toBeNull()
})
})

@ -0,0 +1,46 @@
const HtmlWebPackPlugin = require("html-webpack-plugin");
const path = require('path');
module.exports = {
entry: "./src/index.jsx",
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/'
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.html$/,
use: [
{
loader: "html-loader",
},
],
},
{
test: /\.css$/,
loaders: ['style-loader', 'css-loader'],
}
],
},
resolve: {
extensions: ["*", ".js", ".jsx"],
},
devServer: {
historyApiFallback: true,
},
plugins: [
new HtmlWebPackPlugin({
template: "./public/index.html",
filename: "./index.html",
}),
],
};
Loading…
Cancel
Save