├── .gitignore ├── .husky ├── .gitignore ├── pre-commit └── pre-push ├── .prettierignore ├── .prettierrc.json ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── common │ ├── config │ │ └── constants.ts │ ├── declare.d.ts │ └── interfaces │ │ ├── ICurrentState.ts │ │ ├── ICurrentWeather.ts │ │ ├── IError.ts │ │ ├── IForecast.ts │ │ ├── IForecastState.ts │ │ ├── IGetCurrentWeatherResponse.ts │ │ ├── IGetForecastResponse.ts │ │ ├── IStore.ts │ │ └── IWeather.ts ├── components │ ├── App │ │ ├── App.scss │ │ ├── App.test.tsx │ │ ├── hooks │ │ │ ├── App.hooks.test.ts │ │ │ └── index.ts │ │ └── index.tsx │ ├── Current │ │ ├── Current.scss │ │ ├── Current.test.tsx │ │ ├── Meta │ │ │ ├── Meta.scss │ │ │ ├── Meta.test.tsx │ │ │ └── index.tsx │ │ └── index.tsx │ ├── Forecast │ │ ├── Forecast.scss │ │ ├── Forecast.test.tsx │ │ ├── Weather │ │ │ ├── Weather.scss │ │ │ ├── Weather.test.tsx │ │ │ └── index.tsx │ │ └── index.tsx │ └── VerticalDivider │ │ └── index.tsx ├── index.scss ├── index.tsx ├── mocks │ ├── axios.ts │ ├── current.ts │ ├── forecast.ts │ ├── renderWithStore.tsx │ ├── store.ts │ └── weather.ts ├── react-app-env.d.ts ├── reportWebVitals.ts ├── services │ ├── WeatherbitApp.test.ts │ └── WeatherbitApp.ts ├── setupTests.ts └── store │ ├── index.ts │ └── slices │ ├── current.slice.test.ts │ ├── current.slice.ts │ ├── forecast.slice.test.ts │ └── forecast.slice.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .env 25 | .eslintcache 26 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn format 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn test 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | .husky 3 | public 4 | node_modules 5 | coverage 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # weather-app 2 | 3 | ## What are included 4 | 5 | - Typescript 6 | - React 7 | - Redux 8 | - Jest 9 | - Scss 10 | - [Weatherbit public api](https://www.weatherbit.io/) 11 | 12 | ## Configuration 13 | 14 | go visit [Weatherbit public api](https://www.weatherbit.io/) and register an account to get the API key 15 | 16 | create a .env file at the root level 17 | 18 | REACT_APP_API_KEY = \ 19 | 20 | ## Run 21 | 22 | `yarn start` 23 | 24 | ## Build 25 | 26 | `yarn build` 27 | 28 | ## Test 29 | 30 | `yarn test` 31 | 32 | The coverage for all files is 100%. 33 | 34 | ![Screen Shot 2021-06-03 at 10 02 25 pm](https://user-images.githubusercontent.com/11530457/120642104-cea65a80-c4b7-11eb-8baa-cce1451aeac0.png) 35 | 36 | ## A11y 37 | 38 | All key elements can be read with proper description with screen reader. You can use TAB key to navigate through the page. 39 | 40 | ![Screen Shot 2021-06-03 at 10 03 52 pm](https://user-images.githubusercontent.com/11530457/120642277-090ff780-c4b8-11eb-8234-ac00510deb97.png) 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weather-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@reduxjs/toolkit": "^1.5.1", 7 | "axios": "^0.21.1", 8 | "date-fns": "^2.22.1", 9 | "lodash": "^4.17.20", 10 | "node-sass": "4.14.1", 11 | "react": "^17.0.1", 12 | "react-dom": "^17.0.1", 13 | "react-redux": "^7.2.4", 14 | "react-scripts": "4.0.1", 15 | "redux": "^4.0.5", 16 | "typescript": "^4.0.3", 17 | "web-vitals": "^0.2.4" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test --coverage --colors --silent --watchAll=false", 23 | "eject": "react-scripts eject", 24 | "prepare": "husky install", 25 | "format": "yarn prettier --write ." 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app", 30 | "react-app/jest" 31 | ] 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "@testing-library/jest-dom": "^5.11.4", 47 | "@testing-library/react": "^11.1.0", 48 | "@testing-library/react-hooks": "^6.0.0", 49 | "@testing-library/user-event": "^12.1.10", 50 | "@types/jest": "^26.0.15", 51 | "@types/lodash": "^4.14.168", 52 | "@types/node": "^12.0.0", 53 | "@types/react": "^16.9.53", 54 | "@types/react-dom": "^16.9.8", 55 | "@types/react-redux": "^7.1.16", 56 | "@types/redux-mock-store": "^1.0.2", 57 | "concurrently": "^5.3.0", 58 | "husky": "^6.0.0", 59 | "prettier": "2.3.0", 60 | "redux-mock-store": "^1.5.4" 61 | }, 62 | "jest": { 63 | "collectCoverageFrom": [ 64 | "src/**/*.{js,jsx,ts,tsx}", 65 | "!/node_modules/", 66 | "!/path/to/dir/" 67 | ], 68 | "coverageThreshold": { 69 | "global": { 70 | "branches": 90, 71 | "functions": 90, 72 | "lines": 90, 73 | "statements": 90 74 | } 75 | }, 76 | "coveragePathIgnorePatterns": [ 77 | "src/mocks", 78 | "src/common", 79 | "src/react-app-env.d.ts", 80 | "src/reportWebVitals.ts", 81 | "src/setupTests.ts", 82 | "src/index.tsx" 83 | ] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gzcisco720/typescript-react-weather-app/53288a6deba256cf20c3c73137363ea5f5920680/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 33 | 34 | 38 | 39 | 43 | Weather App 44 | 45 | 46 | 47 | 48 |
49 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gzcisco720/typescript-react-weather-app/53288a6deba256cf20c3c73137363ea5f5920680/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gzcisco720/typescript-react-weather-app/53288a6deba256cf20c3c73137363ea5f5920680/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/common/config/constants.ts: -------------------------------------------------------------------------------- 1 | export const WHEATHER_BASE_URL = 'https://api.weatherbit.io/v2.0'; 2 | -------------------------------------------------------------------------------- /src/common/declare.d.ts: -------------------------------------------------------------------------------- 1 | interface AlertifyJSStatic { 2 | error(a: string); 3 | } 4 | 5 | declare var alertify: AlertifyJSStatic; 6 | -------------------------------------------------------------------------------- /src/common/interfaces/ICurrentState.ts: -------------------------------------------------------------------------------- 1 | import ICurrentWeather from './ICurrentWeather'; 2 | import IError from './IError'; 3 | 4 | export interface ICurrentState { 5 | loading: boolean; 6 | data: ICurrentWeather | null; 7 | error: IError | null; 8 | } 9 | -------------------------------------------------------------------------------- /src/common/interfaces/ICurrentWeather.ts: -------------------------------------------------------------------------------- 1 | import IWeather from './IWeather'; 2 | 3 | export default interface ICurrentWeather { 4 | wind_cdir: string; 5 | rh: number; 6 | pod: string; 7 | lon: number; 8 | pres: number; 9 | timezone: string; 10 | ob_time: string; 11 | country_code: string; 12 | clouds: number; 13 | vis: number; 14 | wind_spd: number; 15 | wind_cdir_full: string; 16 | app_temp: number; 17 | state_code: string; 18 | ts: number; 19 | h_angle: number; 20 | dewpt: number; 21 | weather: IWeather; 22 | uv: number; 23 | aqi: number; 24 | station: string; 25 | wind_dir: number; 26 | elev_angle: number; 27 | datetime: string; 28 | precip: number; 29 | ghi: number; 30 | dni: number; 31 | dhi: number; 32 | solar_rad: number; 33 | city_name: string; 34 | sunrise: string; 35 | sunset: string; 36 | temp: number; 37 | lat: number; 38 | slp: number; 39 | } 40 | -------------------------------------------------------------------------------- /src/common/interfaces/IError.ts: -------------------------------------------------------------------------------- 1 | export default interface IError { 2 | code: number; 3 | message: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/common/interfaces/IForecast.ts: -------------------------------------------------------------------------------- 1 | import IWeather from './IWeather'; 2 | 3 | export default interface IForecast { 4 | valid_date: string; 5 | ts: number; 6 | datetime: string; 7 | wind_gust_spd: number; 8 | wind_spd: number; 9 | wind_dir: number; 10 | wind_cdir: string; 11 | wind_cdir_full: string; 12 | temp: number; 13 | max_temp: number; 14 | min_temp: number; 15 | high_temp: number; 16 | low_temp: number; 17 | app_max_temp: number; 18 | app_min_temp: number; 19 | pop: number; 20 | precip: number; 21 | snow: number; 22 | snow_depth: number; 23 | slp: number; 24 | pres: number; 25 | dewpt: number; 26 | rh: number; 27 | weather: IWeather; 28 | clouds_low: number; 29 | clouds_mid: number; 30 | clouds_hi: number; 31 | clouds: number; 32 | vis: number; 33 | max_dhi: number | null; 34 | uv: number; 35 | moon_phase: number; 36 | moon_phase_lunation: number; 37 | moonrise_ts: number; 38 | moonset_ts: number; 39 | sunrise_ts: number; 40 | sunset_ts: number; 41 | } 42 | -------------------------------------------------------------------------------- /src/common/interfaces/IForecastState.ts: -------------------------------------------------------------------------------- 1 | import IError from './IError'; 2 | import IForecast from './IForecast'; 3 | 4 | export interface IForecastState { 5 | loading: boolean; 6 | data: IForecast[]; 7 | error: IError | null; 8 | } 9 | -------------------------------------------------------------------------------- /src/common/interfaces/IGetCurrentWeatherResponse.ts: -------------------------------------------------------------------------------- 1 | import { count } from 'console'; 2 | import ICurrentWather from './ICurrentWeather'; 3 | 4 | export default interface IGetCurrentWeatherResponse { 5 | data: ICurrentWather[]; 6 | count: number; 7 | } 8 | -------------------------------------------------------------------------------- /src/common/interfaces/IGetForecastResponse.ts: -------------------------------------------------------------------------------- 1 | import IForecast from './IForecast'; 2 | 3 | export default interface IGetForecastResponse { 4 | data: IForecast[]; 5 | city_name: string; 6 | lon: string; 7 | timezone: string; 8 | lat: string; 9 | country_code: string; 10 | state_code: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/common/interfaces/IStore.ts: -------------------------------------------------------------------------------- 1 | import { ICurrentState } from './ICurrentState'; 2 | import { IForecastState } from './IForecastState'; 3 | 4 | export interface IStore { 5 | current: ICurrentState; 6 | forecast: IForecastState; 7 | } 8 | -------------------------------------------------------------------------------- /src/common/interfaces/IWeather.ts: -------------------------------------------------------------------------------- 1 | export default interface IWeather { 2 | icon: string; 3 | code: number; 4 | description: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/App/App.scss: -------------------------------------------------------------------------------- 1 | .App { 2 | min-height: 100vh; 3 | text-align: center; 4 | background-image: url(https://wallpaperaccess.com/full/2629319.png); 5 | background-size: cover; 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | justify-content: center; 10 | 11 | &__WeatherWrapper { 12 | background: white; 13 | border-radius: 32px; 14 | box-shadow: 0 0 16px rgba(0, 0, 0, 0.5); 15 | max-width: 850px; 16 | } 17 | &__Search { 18 | & input { 19 | border-radius: 30px; 20 | min-width: 350px; 21 | } 22 | & .form-group { 23 | display: inline-block; 24 | } 25 | & .fa-info-circle { 26 | margin-left: 10px; 27 | font-size: 1.2rem; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/App/App.test.tsx: -------------------------------------------------------------------------------- 1 | import App from '.'; 2 | import { renderWithStore } from '../../mocks/renderWithStore'; 3 | import * as hooks from './hooks'; 4 | 5 | describe('', () => { 6 | it('should call hooks', () => { 7 | const useHandleSearchChange = jest 8 | .spyOn(hooks, 'useHandleSearchChange') 9 | .mockImplementation(() => ({ 10 | searchCity: 'Melbourne, AU', 11 | handleSearchChange: jest.fn(), 12 | })); 13 | renderWithStore(); 14 | expect(useHandleSearchChange).toBeCalled(); 15 | }); 16 | it('should render correct value into input element', () => { 17 | jest.spyOn(hooks, 'useHandleSearchChange').mockImplementation(() => ({ 18 | searchCity: 'Melbourne, AU', 19 | handleSearchChange: jest.fn(), 20 | })); 21 | const { getByTestId } = renderWithStore(); 22 | const searchInput = getByTestId('searchCity'); 23 | expect((searchInput as HTMLInputElement).value).toEqual('Melbourne, AU'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/App/hooks/App.hooks.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { ChangeEvent } from 'react'; 3 | import { useHandleSearchChange } from '.'; 4 | import * as currentSlice from '../../../store/slices/current.slice'; 5 | import * as forecastSlice from '../../../store/slices/forecast.slice'; 6 | 7 | const mockDispatch = jest.fn(); 8 | jest.mock('react-redux', () => ({ 9 | useSelector: jest.fn(), 10 | useDispatch: () => mockDispatch, 11 | })); 12 | 13 | describe('App hooks', () => { 14 | const mockfetchCurrent = jest.spyOn(currentSlice, 'fetchCurrentWeather'); 15 | const mockfetchForecast = jest.spyOn(forecastSlice, 'fetchForecastWeather'); 16 | it('should fire useEffect', () => { 17 | renderHook(() => useHandleSearchChange()); 18 | expect(mockDispatch).toBeCalledTimes(2); 19 | expect(mockfetchCurrent).toBeCalledWith('Melbourne,AU'); 20 | expect(mockfetchForecast).toBeCalledWith('Melbourne,AU'); 21 | }); 22 | it('should fire call with new city string', async () => { 23 | const { result } = renderHook(() => useHandleSearchChange()); 24 | const mockEvent = { currentTarget: { value: '123' } }; 25 | act(() => { 26 | result.current.handleSearchChange( 27 | mockEvent as ChangeEvent, 28 | ); 29 | }); 30 | await new Promise((r) => setTimeout(r, 1000)); 31 | expect(mockDispatch).toBeCalledTimes(4); 32 | expect(mockfetchCurrent).toBeCalledWith('123'); 33 | expect(mockfetchForecast).toBeCalledWith('123'); 34 | }); 35 | it('should fire call with empty city string', async () => { 36 | const { result } = renderHook(() => useHandleSearchChange()); 37 | const mockEvent = { currentTarget: { value: '' } }; 38 | act(() => { 39 | result.current.handleSearchChange( 40 | mockEvent as ChangeEvent, 41 | ); 42 | }); 43 | await new Promise((r) => setTimeout(r, 1000)); 44 | expect(mockDispatch).toBeCalledTimes(4); 45 | expect(mockfetchCurrent).toBeCalledWith('Melbourne,AU'); 46 | expect(mockfetchForecast).toBeCalledWith('Melbourne,AU'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/components/App/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from 'lodash'; 2 | import { useCallback, useEffect, useState } from 'react'; 3 | import { useAppDispatch } from '../../../store'; 4 | import { fetchCurrentWeather } from '../../../store/slices/current.slice'; 5 | import { fetchForecastWeather } from '../../../store/slices/forecast.slice'; 6 | 7 | export const useHandleSearchChange = () => { 8 | const [searchCity, setSearchCity] = useState(''); 9 | const dispatch = useAppDispatch(); 10 | // eslint-disable-next-line react-hooks/exhaustive-deps 11 | const search = useCallback( 12 | debounce((serachText: string) => { 13 | let searchCity = serachText ? serachText : 'Melbourne,AU'; 14 | dispatch(fetchCurrentWeather(searchCity)); 15 | dispatch(fetchForecastWeather(searchCity)); 16 | }, 1000), 17 | [], 18 | ); 19 | const handleSearchChange = (event: React.ChangeEvent) => { 20 | const searchCity = event.currentTarget.value; 21 | setSearchCity(searchCity); 22 | search(searchCity); 23 | }; 24 | useEffect(() => { 25 | dispatch(fetchCurrentWeather('Melbourne,AU')); 26 | dispatch(fetchForecastWeather('Melbourne,AU')); 27 | }, [dispatch]); 28 | return { searchCity, handleSearchChange }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './App.scss'; 3 | import Current from '../Current'; 4 | import Forecast from '../Forecast'; 5 | import { useHandleSearchChange } from './hooks'; 6 | 7 | const App = () => { 8 | const { searchCity, handleSearchChange } = useHandleSearchChange(); 9 | return ( 10 |
11 |
12 |
13 | 22 |
23 |
24 |
25 |
26 | 27 |
28 |
29 | 30 |
31 |
32 |
33 | ); 34 | }; 35 | 36 | export default App; 37 | -------------------------------------------------------------------------------- /src/components/Current/Current.scss: -------------------------------------------------------------------------------- 1 | .Current { 2 | display: flex; 3 | padding: 40px 0; 4 | flex-direction: column; 5 | background-image: url(https://i.imgur.com/GhQZhaO.jpg); 6 | background-size: cover; 7 | border-top-left-radius: 32px; 8 | border-top-right-radius: 32px; 9 | color: white; 10 | position: relative; 11 | 12 | &__Content { 13 | &--left { 14 | padding: 0 24px; 15 | } 16 | &--right { 17 | padding: 0 24px; 18 | order: -1; 19 | text-align: center; 20 | } 21 | } 22 | 23 | &__Metas { 24 | margin-top: 3rem; 25 | display: flex; 26 | justify-content: space-around; 27 | } 28 | 29 | &__Loading { 30 | margin-top: 3rem; 31 | text-align: center; 32 | font-size: 3rem; 33 | } 34 | 35 | &__CurrentTemperature { 36 | margin-top: 3rem; 37 | text-align: center; 38 | font-size: 5rem; 39 | } 40 | 41 | &__WeatherDesc { 42 | text-align: center; 43 | font-size: 1.5rem; 44 | letter-spacing: 5px; 45 | margin-top: 0.25rem; 46 | } 47 | 48 | &__City { 49 | font-size: 2rem; 50 | font-weight: 500; 51 | line-height: 1.2; 52 | display: block; 53 | } 54 | 55 | &__Date { 56 | font-size: 0.9rem; 57 | font-weight: 500; 58 | line-height: 1.2; 59 | display: block; 60 | } 61 | } 62 | 63 | @media screen and (min-width: 1024px) { 64 | .Current { 65 | flex-direction: row; 66 | padding: 64px 0; 67 | 68 | &__Content { 69 | &--left { 70 | padding: 0 96px; 71 | } 72 | &--right { 73 | padding: 0 96px; 74 | flex: 1; 75 | order: 1; 76 | justify-content: flex-end; 77 | } 78 | } 79 | 80 | &__CurrentTemperature { 81 | margin: 0; 82 | text-align: center; 83 | font-size: 5rem; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/components/Current/Current.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Current from '.'; 3 | import { mockCurrent } from '../../mocks/current'; 4 | import { renderWithStore } from '../../mocks/renderWithStore'; 5 | import { mockInitialStore } from '../../mocks/store'; 6 | 7 | describe('', () => { 8 | it('should render loading text', () => { 9 | const { getByText } = renderWithStore(, { 10 | ...mockInitialStore, 11 | current: { 12 | loading: true, 13 | data: null, 14 | error: null, 15 | }, 16 | }); 17 | expect(getByText('Loading ...')).toBeInTheDocument(); 18 | }); 19 | it('should render correct info', () => { 20 | const { getByText } = renderWithStore(, { 21 | ...mockInitialStore, 22 | current: { 23 | loading: false, 24 | data: mockCurrent, 25 | error: null, 26 | }, 27 | }); 28 | expect(getByText('Clear sky')).toBeInTheDocument(); 29 | expect(getByText('Melbourne, AU')).toBeInTheDocument(); 30 | }); 31 | it('should render 00.0 when data is nil', () => { 32 | const { getByText } = renderWithStore(, { 33 | ...mockInitialStore, 34 | current: { 35 | loading: false, 36 | data: null, 37 | error: null, 38 | }, 39 | }); 40 | expect(getByText('00.0')).toBeInTheDocument(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/components/Current/Meta/Meta.scss: -------------------------------------------------------------------------------- 1 | .Meta { 2 | text-align: center; 3 | margin: 0 10px; 4 | &__Title { 5 | display: inline-block; 6 | margin-bottom: 0.75rem; 7 | } 8 | 9 | &__value { 10 | font-size: 1.25rem; 11 | letter-spacing: 1px; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Current/Meta/Meta.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, RenderResult } from '@testing-library/react'; 3 | import Meta from '.'; 4 | 5 | describe('', () => { 6 | let renderResult: RenderResult; 7 | 8 | const props = { 9 | title: 'HUMIDITY', 10 | value: 12, 11 | }; 12 | 13 | beforeEach(() => { 14 | renderResult = render(); 15 | }); 16 | 17 | it('should render day', () => { 18 | const { getByText } = renderResult; 19 | expect(getByText(props.title)).toBeInTheDocument(); 20 | expect(getByText(props.value)).toBeInTheDocument(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/Current/Meta/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import './Meta.scss'; 3 | 4 | interface IProps { 5 | title: string; 6 | value: string | number; 7 | } 8 | 9 | const Meta = ({ title, value }: IProps) => ( 10 |
11 | {title} 12 |
13 | {value} 14 |
15 | ); 16 | 17 | export default memo(Meta); 18 | -------------------------------------------------------------------------------- /src/components/Current/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import './Current.scss'; 3 | import Meta from './Meta'; 4 | import VerticalDivider from '../VerticalDivider'; 5 | import { useAppSelector } from '../../store'; 6 | import { format } from 'date-fns'; 7 | 8 | const Current = () => { 9 | const currentState = useAppSelector((state) => state.current); 10 | const { data, loading } = currentState; 11 | const date = useMemo(() => format(new Date(), 'EEEE, MMMM do, yyyy'), []); 12 | const srCountryCode = useMemo( 13 | () => data?.country_code.split('').join(' '), 14 | [data?.country_code], 15 | ); 16 | return ( 17 |
18 |
19 | {loading ? ( 20 |
Loading ...
21 | ) : ( 22 |
30 | {data ? data.app_temp : '00.0'} 31 |   32 | ° 33 |
34 | )} 35 |
36 | {data ? data.weather.description : ''} 37 |
38 |
39 | 40 | 41 | 42 |
43 |
44 |
51 |

52 | {data 53 | ? `${data.city_name}, ${data.country_code}` 54 | : 'Data Not Available'} 55 |

56 |

{date}

57 |
58 |
59 | ); 60 | }; 61 | 62 | export default Current; 63 | -------------------------------------------------------------------------------- /src/components/Forecast/Forecast.scss: -------------------------------------------------------------------------------- 1 | .Forecast { 2 | padding: 24px 36px; 3 | order: -1; 4 | min-height: 230px; 5 | 6 | &__Header { 7 | margin: 0; 8 | margin-bottom: 0.5rem; 9 | letter-spacing: 2px; 10 | font-weight: 300; 11 | font-size: 1rem; 12 | } 13 | 14 | &__Weathers { 15 | display: flex; 16 | align-items: center; 17 | justify-content: flex-start; 18 | min-width: 280px; 19 | flex-wrap: wrap; 20 | } 21 | 22 | &__Loading { 23 | height: 80px; 24 | width: 80px; 25 | margin-top: 30px; 26 | } 27 | } 28 | 29 | @media screen and (min-width: 1024px) { 30 | .Forecast { 31 | padding: 0 48px; 32 | flex: 1; 33 | order: 1; 34 | 35 | &__Header { 36 | margin-bottom: 1rem; 37 | font-size: 1.5rem; 38 | margin-top: 1rem; 39 | } 40 | 41 | &__Weathers { 42 | display: flex; 43 | align-items: center; 44 | justify-content: space-between; 45 | min-width: 280px; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/Forecast/Forecast.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Forecast from '.'; 3 | import { mockForecast } from '../../mocks/forecast'; 4 | import { renderWithStore } from '../../mocks/renderWithStore'; 5 | import { mockInitialStore } from '../../mocks/store'; 6 | 7 | describe('', () => { 8 | it('should render loading text', () => { 9 | const { getByText } = renderWithStore(, { 10 | ...mockInitialStore, 11 | forecast: { 12 | loading: true, 13 | data: [], 14 | error: null, 15 | }, 16 | }); 17 | expect(getByText('Loading ...')).toBeInTheDocument(); 18 | }); 19 | it('should render correct info', () => { 20 | const { getByTestId, getByText } = renderWithStore(, { 21 | ...mockInitialStore, 22 | forecast: { 23 | loading: false, 24 | data: mockForecast, 25 | error: null, 26 | }, 27 | }); 28 | expect(getByTestId('forecasts')).toBeInTheDocument(); 29 | expect(getByTestId('forecasts').children.length).toEqual(7); 30 | expect(getByText('Max: 19.4')).toBeInTheDocument(); 31 | expect(getByText('Min: 8')).toBeInTheDocument(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/components/Forecast/Weather/Weather.scss: -------------------------------------------------------------------------------- 1 | .Weather { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | min-width: 85px; 6 | 7 | &__Day { 8 | margin: 1rem 0; 9 | font-weight: normal; 10 | font-size: 1rem; 11 | } 12 | 13 | &__Icon { 14 | width: 50px; 15 | } 16 | 17 | &__Temperature { 18 | font-size: 0.9rem; 19 | margin-top: 1rem; 20 | margin-bottom: 1em; 21 | color: rgba(0, 0, 0, 0.5); 22 | } 23 | } 24 | 25 | @media screen and (min-width: 1024px) { 26 | .Weather { 27 | &__Icon { 28 | width: 70px; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Forecast/Weather/Weather.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, RenderResult } from '@testing-library/react'; 3 | import Weather from '.'; 4 | import { mockSingleWeather } from '../../../mocks/weather'; 5 | 6 | describe('', () => { 7 | let renderResult: RenderResult; 8 | 9 | it('should render correct info', () => { 10 | renderResult = render(); 11 | const { getByText, getByAltText } = renderResult; 12 | expect(getByText(`Max: ${mockSingleWeather.max_temp}`)).toBeInTheDocument(); 13 | expect(getByText(`Min: ${mockSingleWeather.min_temp}`)).toBeInTheDocument(); 14 | expect( 15 | getByAltText(mockSingleWeather.weather.description).getAttribute('src'), 16 | ).toBe('https://www.weatherbit.io/static/img/icons/c04d.png'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/Forecast/Weather/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useMemo } from 'react'; 2 | import IForecast from '../../../common/interfaces/IForecast'; 3 | import './Weather.scss'; 4 | import getDay from 'date-fns/getDay'; 5 | import { format } from 'date-fns'; 6 | 7 | const days = [ 8 | 'Sunday', 9 | 'Monday', 10 | 'Tuesday', 11 | 'Wednesday', 12 | 'Thursday', 13 | 'Friday', 14 | 'Saturday', 15 | ]; 16 | 17 | const Weather = (props: IForecast) => { 18 | const { valid_date, weather, max_temp, min_temp } = props; 19 | const dayOfWeek = useMemo(() => getDay(new Date(valid_date)), [valid_date]); 20 | const date = useMemo( 21 | () => format(new Date(valid_date), 'EEEE, MMMM do, yyyy'), 22 | [valid_date], 23 | ); 24 | return ( 25 |
33 |

{days[dayOfWeek]}

34 | {weather.description} 40 |
41 |
42 | Max: {max_temp} 43 |   44 | ° 45 |
46 |
47 | Min: {min_temp} 48 |   49 | ° 50 |
51 |
52 |
53 | ); 54 | }; 55 | 56 | export default memo(Weather); 57 | -------------------------------------------------------------------------------- /src/components/Forecast/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useAppSelector } from '../../store'; 3 | import './Forecast.scss'; 4 | import Weather from './Weather'; 5 | 6 | const Forecast = () => { 7 | const forecastState = useAppSelector((state) => state.forecast); 8 | const { data, loading } = forecastState; 9 | const nextFiveDays = data.slice(1, 8); 10 | return ( 11 |
12 |

13 | Forecast for next 7 days 14 |

15 | {loading ? ( 16 |
17 | Loading ... 18 |
19 | ) : ( 20 |
21 | {nextFiveDays.map((forecast, index) => ( 22 |
23 | 24 |
25 | ))} 26 |
27 | )} 28 |
29 | ); 30 | }; 31 | 32 | export default Forecast; 33 | -------------------------------------------------------------------------------- /src/components/VerticalDivider/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | 3 | interface IProps { 4 | color?: string; 5 | width?: string; 6 | className?: string; 7 | } 8 | 9 | const VerticalDivider = ({ color, width, className }: IProps) => ( 10 |
17 | ); 18 | 19 | export default memo(VerticalDivider); 20 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { store } from './store'; 5 | import './index.scss'; 6 | import App from './components/App'; 7 | import reportWebVitals from './reportWebVitals'; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | 13 | 14 | , 15 | document.getElementById('root'), 16 | ); 17 | 18 | // If you want to start measuring performance in your app, pass a function 19 | // to log results (for example: reportWebVitals(console.log)) 20 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 21 | reportWebVitals(); 22 | -------------------------------------------------------------------------------- /src/mocks/axios.ts: -------------------------------------------------------------------------------- 1 | import { AxiosStatic } from 'axios'; 2 | 3 | const mockAxios = jest.genMockFromModule('axios'); 4 | 5 | console.log('in mock setup'); 6 | mockAxios.interceptors = mockAxios.interceptors || {}; 7 | mockAxios.interceptors.response = mockAxios.interceptors.response || {}; 8 | mockAxios.interceptors.response.use = jest.fn(); 9 | mockAxios.create = () => { 10 | console.log('in mock create'); 11 | return mockAxios; 12 | }; 13 | 14 | export default mockAxios; 15 | -------------------------------------------------------------------------------- /src/mocks/current.ts: -------------------------------------------------------------------------------- 1 | export const mockCurrent = { 2 | rh: 48, 3 | pod: 'd', 4 | lon: 144.96332, 5 | pres: 1025.3, 6 | timezone: 'Australia/Melbourne', 7 | ob_time: '2021-05-22 01:47', 8 | country_code: 'AU', 9 | clouds: 0, 10 | ts: 1621648020, 11 | solar_rad: 493.6, 12 | state_code: '07', 13 | city_name: 'Melbourne', 14 | wind_spd: 2.24, 15 | wind_cdir_full: 'north-northwest', 16 | wind_cdir: 'NNW', 17 | slp: 1026, 18 | vis: 5, 19 | h_angle: -18, 20 | sunset: '07:14', 21 | dni: 774.9, 22 | dewpt: 6.7, 23 | snow: 0, 24 | uv: 5.80708, 25 | precip: 0, 26 | wind_dir: 337, 27 | sunrise: '21:19', 28 | ghi: 493.56, 29 | dhi: 93.71, 30 | aqi: 47, 31 | lat: -37.814, 32 | weather: { 33 | icon: 'c01d', 34 | code: 800, 35 | description: 'Clear sky', 36 | }, 37 | datetime: '2021-05-22:01', 38 | temp: 17.8, 39 | station: 'E5657', 40 | elev_angle: 31.61, 41 | app_temp: 17.8, 42 | }; 43 | -------------------------------------------------------------------------------- /src/mocks/forecast.ts: -------------------------------------------------------------------------------- 1 | export const mockForecast = [ 2 | { 3 | moonrise_ts: 1621657034, 4 | wind_cdir: 'E', 5 | rh: 53, 6 | pres: 1015.88, 7 | high_temp: 18.4, 8 | sunset_ts: 1621667707, 9 | ozone: 263.729, 10 | moon_phase: 0.803954, 11 | wind_gust_spd: 9.29688, 12 | snow_depth: 0, 13 | clouds: 0, 14 | ts: 1621605660, 15 | sunrise_ts: 1621631875, 16 | app_min_temp: 7.3, 17 | wind_spd: 2.82499, 18 | pop: 0, 19 | wind_cdir_full: 'east', 20 | slp: 1024.85, 21 | moon_phase_lunation: 0.34, 22 | valid_date: '2021-05-22', 23 | app_max_temp: 18.3, 24 | vis: 24.128, 25 | dewpt: 2.1, 26 | snow: 0, 27 | uv: 3.26572, 28 | weather: { 29 | icon: 'c01d', 30 | code: 800, 31 | description: 'Clear Sky', 32 | }, 33 | wind_dir: 87, 34 | max_dhi: null, 35 | clouds_hi: 0, 36 | precip: 0, 37 | low_temp: 7.3, 38 | max_temp: 18.4, 39 | moonset_ts: 1621616175, 40 | datetime: '2021-05-22', 41 | temp: 11.7, 42 | min_temp: 7.2, 43 | clouds_mid: 0, 44 | clouds_low: 0, 45 | }, 46 | { 47 | moonrise_ts: 1621745204, 48 | wind_cdir: 'ENE', 49 | rh: 59, 50 | pres: 1016.21, 51 | high_temp: 19.4, 52 | sunset_ts: 1621754071, 53 | ozone: 257.792, 54 | moon_phase: 0.892912, 55 | wind_gust_spd: 5.41016, 56 | snow_depth: 0, 57 | clouds: 36, 58 | ts: 1621692060, 59 | sunrise_ts: 1621718321, 60 | app_min_temp: 8.1, 61 | wind_spd: 1.91811, 62 | pop: 0, 63 | wind_cdir_full: 'east-northeast', 64 | slp: 1025.21, 65 | moon_phase_lunation: 0.38, 66 | valid_date: '2021-05-23', 67 | app_max_temp: 18.3, 68 | vis: 24.128, 69 | dewpt: 4.1, 70 | snow: 0, 71 | uv: 2.82405, 72 | weather: { 73 | icon: 'c02d', 74 | code: 802, 75 | description: 'Scattered clouds', 76 | }, 77 | wind_dir: 74, 78 | max_dhi: null, 79 | clouds_hi: 35, 80 | precip: 0, 81 | low_temp: 8.1, 82 | max_temp: 19.4, 83 | moonset_ts: 1621706872, 84 | datetime: '2021-05-23', 85 | temp: 12.5, 86 | min_temp: 8, 87 | clouds_mid: 4, 88 | clouds_low: 0, 89 | }, 90 | { 91 | moonrise_ts: 1621833378, 92 | wind_cdir: 'NNE', 93 | rh: 56, 94 | pres: 1014.58, 95 | high_temp: 18.8, 96 | sunset_ts: 1621840436, 97 | ozone: 249.745, 98 | moon_phase: 0.958614, 99 | wind_gust_spd: 13.9062, 100 | snow_depth: 0, 101 | clouds: 100, 102 | ts: 1621778460, 103 | sunrise_ts: 1621804767, 104 | app_min_temp: 10.4, 105 | wind_spd: 3.76634, 106 | pop: 0, 107 | wind_cdir_full: 'north-northeast', 108 | slp: 1023.6, 109 | moon_phase_lunation: 0.41, 110 | valid_date: '2021-05-24', 111 | app_max_temp: 18, 112 | vis: 24.128, 113 | dewpt: 5.5, 114 | snow: 0, 115 | uv: 1.01241, 116 | weather: { 117 | icon: 'c04d', 118 | code: 804, 119 | description: 'Overcast clouds', 120 | }, 121 | wind_dir: 18, 122 | max_dhi: null, 123 | clouds_hi: 100, 124 | precip: 0, 125 | low_temp: 10.6, 126 | max_temp: 18.8, 127 | moonset_ts: 1621797740, 128 | datetime: '2021-05-24', 129 | temp: 14.2, 130 | min_temp: 10.3, 131 | clouds_mid: 64, 132 | clouds_low: 0, 133 | }, 134 | { 135 | moonrise_ts: 1621921655, 136 | wind_cdir: 'ESE', 137 | rh: 61, 138 | pres: 1003.1, 139 | high_temp: 19.9, 140 | sunset_ts: 1621926802, 141 | ozone: 276.865, 142 | moon_phase: 0.994469, 143 | wind_gust_spd: 21, 144 | snow_depth: 0, 145 | clouds: 73, 146 | ts: 1621864860, 147 | sunrise_ts: 1621891213, 148 | app_min_temp: 11.3, 149 | wind_spd: 6.78218, 150 | pop: 90, 151 | wind_cdir_full: 'east-southeast', 152 | slp: 1012.19, 153 | moon_phase_lunation: 0.45, 154 | valid_date: '2021-05-25', 155 | app_max_temp: 19.2, 156 | vis: 22.3743, 157 | dewpt: 7.5, 158 | snow: 0, 159 | uv: 2.7642, 160 | weather: { 161 | icon: 'r01d', 162 | code: 500, 163 | description: 'Light rain', 164 | }, 165 | wind_dir: 120, 166 | max_dhi: null, 167 | clouds_hi: 37, 168 | precip: 9.25, 169 | low_temp: 11.3, 170 | max_temp: 20, 171 | moonset_ts: 1621888763, 172 | datetime: '2021-05-25', 173 | temp: 15.2, 174 | min_temp: 11.2, 175 | clouds_mid: 59, 176 | clouds_low: 23, 177 | }, 178 | { 179 | moonrise_ts: 1622010149, 180 | wind_cdir: 'NW', 181 | rh: 65, 182 | pres: 1005.16, 183 | high_temp: 14.7, 184 | sunset_ts: 1622013170, 185 | ozone: 296.947, 186 | moon_phase: 0.996797, 187 | wind_gust_spd: 18.4062, 188 | snow_depth: 0, 189 | clouds: 69, 190 | ts: 1621951260, 191 | sunrise_ts: 1621977657, 192 | app_min_temp: 9.2, 193 | wind_spd: 4.40421, 194 | pop: 85, 195 | wind_cdir_full: 'northwest', 196 | slp: 1014.37, 197 | moon_phase_lunation: 0.48, 198 | valid_date: '2021-05-26', 199 | app_max_temp: 14.7, 200 | vis: 20.1874, 201 | dewpt: 5.6, 202 | snow: 0, 203 | uv: 2.85519, 204 | weather: { 205 | icon: 'r01d', 206 | code: 500, 207 | description: 'Light rain', 208 | }, 209 | wind_dir: 308, 210 | max_dhi: null, 211 | clouds_hi: 0, 212 | precip: 5.3125, 213 | low_temp: 7.3, 214 | max_temp: 14.7, 215 | moonset_ts: 1621979825, 216 | datetime: '2021-05-26', 217 | temp: 12.1, 218 | min_temp: 8.1, 219 | clouds_mid: 20, 220 | clouds_low: 59, 221 | }, 222 | { 223 | moonrise_ts: 1622098982, 224 | wind_cdir: 'W', 225 | rh: 73, 226 | pres: 1010.75, 227 | high_temp: 8.7, 228 | sunset_ts: 1622099540, 229 | ozone: 316.844, 230 | moon_phase: 0.965899, 231 | wind_gust_spd: 14.8125, 232 | snow_depth: 0, 233 | clouds: 78, 234 | ts: 1622037660, 235 | sunrise_ts: 1622064101, 236 | app_min_temp: 7.3, 237 | wind_spd: 4.49757, 238 | pop: 75, 239 | wind_cdir_full: 'west', 240 | slp: 1019.88, 241 | moon_phase_lunation: 0.51, 242 | valid_date: '2021-05-27', 243 | app_max_temp: 11, 244 | vis: 21.468, 245 | dewpt: 4.5, 246 | snow: 0, 247 | uv: 0.999603, 248 | weather: { 249 | icon: 'r04d', 250 | code: 520, 251 | description: 'Light shower rain', 252 | }, 253 | wind_dir: 275, 254 | max_dhi: null, 255 | clouds_hi: 0, 256 | precip: 3.4375, 257 | low_temp: 5.9, 258 | max_temp: 12.4, 259 | moonset_ts: 1622070693, 260 | datetime: '2021-05-27', 261 | temp: 9.1, 262 | min_temp: 7, 263 | clouds_mid: 25, 264 | clouds_low: 70, 265 | }, 266 | { 267 | moonrise_ts: 1622188274, 268 | wind_cdir: 'WSW', 269 | rh: 79, 270 | pres: 1007.56, 271 | high_temp: 10.7, 272 | sunset_ts: 1622185912, 273 | ozone: 336, 274 | moon_phase: 0.90602, 275 | wind_gust_spd: 13.9453, 276 | snow_depth: 0, 277 | clouds: 85, 278 | ts: 1622124060, 279 | sunrise_ts: 1622150544, 280 | app_min_temp: 2.5, 281 | wind_spd: 5.64068, 282 | pop: 95, 283 | wind_cdir_full: 'west-southwest', 284 | slp: 1016.94, 285 | moon_phase_lunation: 0.55, 286 | valid_date: '2021-05-28', 287 | app_max_temp: 8.7, 288 | vis: 18.971, 289 | dewpt: 3.8, 290 | snow: 0, 291 | uv: 1.09671, 292 | weather: { 293 | icon: 'r02d', 294 | code: 501, 295 | description: 'Moderate rain', 296 | }, 297 | wind_dir: 240, 298 | max_dhi: null, 299 | clouds_hi: 0, 300 | precip: 14.0625, 301 | low_temp: 6.5, 302 | max_temp: 9.1, 303 | moonset_ts: 1622161102, 304 | datetime: '2021-05-28', 305 | temp: 7.2, 306 | min_temp: 5.7, 307 | clouds_mid: 27, 308 | clouds_low: 85, 309 | }, 310 | { 311 | moonrise_ts: 1622278079, 312 | wind_cdir: 'SSW', 313 | rh: 78, 314 | pres: 1000.06, 315 | high_temp: 10.3, 316 | sunset_ts: 1622272285, 317 | ozone: 346.812, 318 | moon_phase: 0.90602, 319 | wind_gust_spd: 18.5938, 320 | snow_depth: 0, 321 | clouds: 79, 322 | ts: 1622210460, 323 | sunrise_ts: 1622236987, 324 | app_min_temp: 3.2, 325 | wind_spd: 6.28586, 326 | pop: 95, 327 | wind_cdir_full: 'south-southwest', 328 | slp: 1009.31, 329 | moon_phase_lunation: 0.58, 330 | valid_date: '2021-05-29', 331 | app_max_temp: 10.7, 332 | vis: 10.477, 333 | dewpt: 4.4, 334 | snow: 0, 335 | uv: 1.46998, 336 | weather: { 337 | icon: 'r03d', 338 | code: 502, 339 | description: 'Heavy rain', 340 | }, 341 | wind_dir: 213, 342 | max_dhi: null, 343 | clouds_hi: 0, 344 | precip: 23.75, 345 | low_temp: 6.1, 346 | max_temp: 10.7, 347 | moonset_ts: 1622247502, 348 | datetime: '2021-05-29', 349 | temp: 8, 350 | min_temp: 5.6, 351 | clouds_mid: 63, 352 | clouds_low: 72, 353 | }, 354 | { 355 | moonrise_ts: 1622368324, 356 | wind_cdir: 'SW', 357 | rh: 84, 358 | pres: 1001.12, 359 | high_temp: 11.2, 360 | sunset_ts: 1622358661, 361 | ozone: 331.469, 362 | moon_phase: 0.824035, 363 | wind_gust_spd: 14.4062, 364 | snow_depth: 0, 365 | clouds: 87, 366 | ts: 1622296860, 367 | sunrise_ts: 1622323428, 368 | app_min_temp: 2.7, 369 | wind_spd: 4.89853, 370 | pop: 90, 371 | wind_cdir_full: 'southwest', 372 | slp: 1010.44, 373 | moon_phase_lunation: 0.61, 374 | valid_date: '2021-05-30', 375 | app_max_temp: 10.3, 376 | vis: 21.145, 377 | dewpt: 5.8, 378 | snow: 0, 379 | uv: 0.986037, 380 | weather: { 381 | icon: 'r01d', 382 | code: 500, 383 | description: 'Light rain', 384 | }, 385 | wind_dir: 234, 386 | max_dhi: null, 387 | clouds_hi: 0, 388 | precip: 7.9375, 389 | low_temp: 7.4, 390 | max_temp: 10.5, 391 | moonset_ts: 1622337287, 392 | datetime: '2021-05-30', 393 | temp: 8.3, 394 | min_temp: 5.8, 395 | clouds_mid: 16, 396 | clouds_low: 87, 397 | }, 398 | { 399 | moonrise_ts: 1622458811, 400 | wind_cdir: 'SW', 401 | rh: 85, 402 | pres: 1007.75, 403 | high_temp: 11.8, 404 | sunset_ts: 1622445037, 405 | ozone: 302.25, 406 | moon_phase: 0.7276, 407 | wind_gust_spd: 8.71094, 408 | snow_depth: 0, 409 | clouds: 82, 410 | ts: 1622383260, 411 | sunrise_ts: 1622409869, 412 | app_min_temp: 7.4, 413 | wind_spd: 3.02691, 414 | pop: 65, 415 | wind_cdir_full: 'southwest', 416 | slp: 1016.92, 417 | moon_phase_lunation: 0.65, 418 | valid_date: '2021-05-31', 419 | app_max_temp: 11.2, 420 | vis: 23.9333, 421 | dewpt: 7.1, 422 | snow: 0, 423 | uv: 1.31611, 424 | weather: { 425 | icon: 'c04d', 426 | code: 804, 427 | description: 'Overcast clouds', 428 | }, 429 | wind_dir: 235, 430 | max_dhi: null, 431 | clouds_hi: 1, 432 | precip: 1.875, 433 | low_temp: 6.5, 434 | max_temp: 11.7, 435 | moonset_ts: 1622426452, 436 | datetime: '2021-05-31', 437 | temp: 9.5, 438 | min_temp: 7.4, 439 | clouds_mid: 2, 440 | clouds_low: 82, 441 | }, 442 | { 443 | moonrise_ts: 1622549315, 444 | wind_cdir: 'SW', 445 | rh: 82, 446 | pres: 1009.5, 447 | high_temp: 12.9, 448 | sunset_ts: 1622531416, 449 | ozone: 297.5, 450 | moon_phase: 0.623755, 451 | wind_gust_spd: 7.32812, 452 | snow_depth: 0, 453 | clouds: 78, 454 | ts: 1622469660, 455 | sunrise_ts: 1622496309, 456 | app_min_temp: 8.2, 457 | wind_spd: 2.85108, 458 | pop: 65, 459 | wind_cdir_full: 'southwest', 460 | slp: 1018.75, 461 | moon_phase_lunation: 0.68, 462 | valid_date: '2021-06-01', 463 | app_max_temp: 10.3, 464 | vis: 23.8, 465 | dewpt: 6.2, 466 | snow: 0, 467 | uv: 0.489089, 468 | weather: { 469 | icon: 'c04d', 470 | code: 804, 471 | description: 'Overcast clouds', 472 | }, 473 | wind_dir: 219, 474 | max_dhi: null, 475 | clouds_hi: 0, 476 | precip: 1.875, 477 | low_temp: 6.1, 478 | max_temp: 11.3, 479 | moonset_ts: 1622515109, 480 | datetime: '2021-06-01', 481 | temp: 9.3, 482 | min_temp: 8.2, 483 | clouds_mid: 0, 484 | clouds_low: 78, 485 | }, 486 | { 487 | moonrise_ts: 1622639685, 488 | wind_cdir: 'SSW', 489 | rh: 76, 490 | pres: 1008.25, 491 | high_temp: 11.8, 492 | sunset_ts: 1622617796, 493 | ozone: 301.5, 494 | moon_phase: 0.518282, 495 | wind_gust_spd: 7.94922, 496 | snow_depth: 0, 497 | clouds: 78, 498 | ts: 1622556060, 499 | sunrise_ts: 1622582748, 500 | app_min_temp: 9.1, 501 | wind_spd: 3.52182, 502 | pop: 65, 503 | wind_cdir_full: 'south-southwest', 504 | slp: 1017.25, 505 | moon_phase_lunation: 0.72, 506 | valid_date: '2021-06-02', 507 | app_max_temp: 9.9, 508 | vis: 24.128, 509 | dewpt: 5.5, 510 | snow: 0, 511 | uv: 0.963865, 512 | weather: { 513 | icon: 'c04d', 514 | code: 804, 515 | description: 'Overcast clouds', 516 | }, 517 | wind_dir: 211, 518 | max_dhi: null, 519 | clouds_hi: 0, 520 | precip: 1.875, 521 | low_temp: 8.9, 522 | max_temp: 11, 523 | moonset_ts: 1622603397, 524 | datetime: '2021-06-02', 525 | temp: 9.5, 526 | min_temp: 6.4, 527 | clouds_mid: 0, 528 | clouds_low: 78, 529 | }, 530 | { 531 | moonrise_ts: 1622643470, 532 | wind_cdir: 'SE', 533 | rh: 82, 534 | pres: 1008.75, 535 | high_temp: 9.9, 536 | sunset_ts: 1622704179, 537 | ozone: 308.375, 538 | moon_phase: 0.41564, 539 | wind_gust_spd: 2.29883, 540 | snow_depth: 0, 541 | clouds: 32, 542 | ts: 1622642460, 543 | sunrise_ts: 1622669186, 544 | app_min_temp: 3.2, 545 | wind_spd: 1.20535, 546 | pop: 15, 547 | wind_cdir_full: 'southeast', 548 | slp: 1018, 549 | moon_phase_lunation: 0.75, 550 | valid_date: '2021-06-03', 551 | app_max_temp: 11.8, 552 | vis: 23.768, 553 | dewpt: 6.2, 554 | snow: 0, 555 | uv: 0.92489, 556 | weather: { 557 | icon: 'c02d', 558 | code: 802, 559 | description: 'Scattered clouds', 560 | }, 561 | wind_dir: 126, 562 | max_dhi: null, 563 | clouds_hi: 0, 564 | precip: 0.1875, 565 | low_temp: 8.2, 566 | max_temp: 12.8, 567 | moonset_ts: 1622691440, 568 | datetime: '2021-06-03', 569 | temp: 9.2, 570 | min_temp: 6.5, 571 | clouds_mid: 0, 572 | clouds_low: 32, 573 | }, 574 | { 575 | moonrise_ts: 1622733490, 576 | wind_cdir: 'SSE', 577 | rh: 72, 578 | pres: 1007.25, 579 | high_temp: 13.8, 580 | sunset_ts: 1622790563, 581 | ozone: 300.625, 582 | moon_phase: 0.319201, 583 | wind_gust_spd: 4.40625, 584 | snow_depth: 0, 585 | clouds: 74, 586 | ts: 1622728860, 587 | sunrise_ts: 1622755623, 588 | app_min_temp: 8.2, 589 | wind_spd: 1.57896, 590 | pop: 15, 591 | wind_cdir_full: 'south-southeast', 592 | slp: 1016.5, 593 | moon_phase_lunation: 0.78, 594 | valid_date: '2021-06-04', 595 | app_max_temp: 12.9, 596 | vis: 24.128, 597 | dewpt: 5.3, 598 | snow: 0, 599 | uv: 0.742727, 600 | weather: { 601 | icon: 'c04d', 602 | code: 804, 603 | description: 'Overcast clouds', 604 | }, 605 | wind_dir: 156, 606 | max_dhi: null, 607 | clouds_hi: 74, 608 | precip: 0.1875, 609 | low_temp: 6, 610 | max_temp: 13.8, 611 | moonset_ts: 1622779339, 612 | datetime: '2021-06-04', 613 | temp: 10.6, 614 | min_temp: 6, 615 | clouds_mid: 2, 616 | clouds_low: 11, 617 | }, 618 | { 619 | moonrise_ts: 1622823393, 620 | wind_cdir: 'ENE', 621 | rh: 70, 622 | pres: 1006, 623 | high_temp: 12.3, 624 | sunset_ts: 1622876948, 625 | ozone: 323.25, 626 | moon_phase: 0.231624, 627 | wind_gust_spd: 5.51953, 628 | snow_depth: 0, 629 | clouds: 88, 630 | ts: 1622815260, 631 | sunrise_ts: 1622842058, 632 | app_min_temp: 2.7, 633 | wind_spd: 1.26971, 634 | pop: 0, 635 | wind_cdir_full: 'east-northeast', 636 | slp: 1015.25, 637 | moon_phase_lunation: 0.82, 638 | valid_date: '2021-06-05', 639 | app_max_temp: 11.8, 640 | vis: 24.128, 641 | dewpt: 3.6, 642 | snow: 0, 643 | uv: 0.517097, 644 | weather: { 645 | icon: 'c04d', 646 | code: 804, 647 | description: 'Overcast clouds', 648 | }, 649 | wind_dir: 77, 650 | max_dhi: null, 651 | clouds_hi: 53, 652 | precip: 0, 653 | low_temp: 8.9, 654 | max_temp: 12.3, 655 | moonset_ts: 1622867178, 656 | datetime: '2021-06-05', 657 | temp: 8.9, 658 | min_temp: 6.1, 659 | clouds_mid: 64, 660 | clouds_low: 48, 661 | }, 662 | { 663 | moonrise_ts: 1622913240, 664 | wind_cdir: 'W', 665 | rh: 71, 666 | pres: 1008.25, 667 | high_temp: 10.8, 668 | sunset_ts: 1622963336, 669 | ozone: 322.625, 670 | moon_phase: 0.15517, 671 | wind_gust_spd: 8.42188, 672 | snow_depth: 0, 673 | clouds: 100, 674 | ts: 1622901660, 675 | sunrise_ts: 1622928493, 676 | app_min_temp: 8.9, 677 | wind_spd: 2.85677, 678 | pop: 45, 679 | wind_cdir_full: 'west', 680 | slp: 1017.5, 681 | moon_phase_lunation: 0.85, 682 | valid_date: '2021-06-06', 683 | app_max_temp: 9.9, 684 | vis: 24.128, 685 | dewpt: 4.4, 686 | snow: 0, 687 | uv: 0.482147, 688 | weather: { 689 | icon: 'c04d', 690 | code: 804, 691 | description: 'Overcast clouds', 692 | }, 693 | wind_dir: 262, 694 | max_dhi: null, 695 | clouds_hi: 2, 696 | precip: 0.8125, 697 | low_temp: 8.8, 698 | max_temp: 10.8, 699 | moonset_ts: 1622955029, 700 | datetime: '2021-06-06', 701 | temp: 9.4, 702 | min_temp: 8.8, 703 | clouds_mid: 4, 704 | clouds_low: 100, 705 | }, 706 | ]; 707 | -------------------------------------------------------------------------------- /src/mocks/renderWithStore.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from 'react-redux'; 2 | import { IStore } from '../common/interfaces/IStore'; 3 | import { mockInitialStore } from './store'; 4 | import configureStore from 'redux-mock-store'; 5 | import { render } from '@testing-library/react'; 6 | 7 | const mockStore = configureStore(); 8 | 9 | export const renderWithStore = ( 10 | component: JSX.Element, 11 | state: IStore = mockInitialStore, 12 | ) => render({component}); 13 | -------------------------------------------------------------------------------- /src/mocks/store.ts: -------------------------------------------------------------------------------- 1 | import { IStore } from '../common/interfaces/IStore'; 2 | 3 | export const mockInitialStore: IStore = { 4 | current: { 5 | loading: false, 6 | data: null, 7 | error: null, 8 | }, 9 | forecast: { 10 | loading: false, 11 | data: [], 12 | error: null, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/mocks/weather.ts: -------------------------------------------------------------------------------- 1 | export const mockSingleWeather = { 2 | moonrise_ts: 1612001662, 3 | wind_cdir: 'SW', 4 | rh: 69, 5 | pres: 1003.0625, 6 | high_temp: 21.8, 7 | sunset_ts: 1611999259, 8 | ozone: 264.33334, 9 | moon_phase: 0.945262, 10 | wind_gust_spd: 7.8984375, 11 | snow_depth: 0, 12 | clouds: 93, 13 | ts: 1611925260, 14 | sunrise_ts: 1611948696, 15 | app_min_temp: 16.6, 16 | wind_spd: 3.762676, 17 | pop: 60, 18 | wind_cdir_full: 'southwest', 19 | slp: 1011.4375, 20 | moon_phase_lunation: 0.57, 21 | valid_date: '2021-01-30', 22 | app_max_temp: 21.4, 23 | vis: 22.897333, 24 | dewpt: 12.8, 25 | snow: 0, 26 | uv: 3.390544, 27 | weather: { 28 | icon: 'c04d', 29 | code: 804, 30 | description: 'Overcast clouds', 31 | }, 32 | wind_dir: 232, 33 | max_dhi: 178, 34 | clouds_hi: 0, 35 | precip: 4.9375, 36 | low_temp: 15.6, 37 | max_temp: 22.1, 38 | moonset_ts: 1611956552, 39 | datetime: '2021-01-30', 40 | temp: 18.8, 41 | min_temp: 16.2, 42 | clouds_mid: 15, 43 | clouds_low: 93, 44 | }; 45 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/services/WeatherbitApp.test.ts: -------------------------------------------------------------------------------- 1 | import { initRequestHeader } from './WeatherbitApp'; 2 | 3 | describe('WeatherbitApp', () => { 4 | it('should return correct config value', () => { 5 | process.env.REACT_APP_API_KEY = 'abc'; 6 | const config = initRequestHeader({ 7 | baseURL: '', 8 | params: { 9 | key: '', 10 | }, 11 | }); 12 | expect(config.baseURL).toEqual('https://api.weatherbit.io/v2.0'); 13 | expect(config.params.key).toEqual('abc'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/services/WeatherbitApp.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from 'axios'; 2 | import { WHEATHER_BASE_URL } from '../common/config/constants'; 3 | 4 | const WeatherbitApp = axios.create(); 5 | 6 | export const initRequestHeader = (config: AxiosRequestConfig) => { 7 | config.baseURL = WHEATHER_BASE_URL; 8 | config.params.key = process.env.REACT_APP_API_KEY; 9 | return config; 10 | }; 11 | 12 | WeatherbitApp.interceptors.request.use(initRequestHeader); 13 | 14 | export default WeatherbitApp; 15 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; 3 | import forecastReducer from './slices/forecast.slice'; 4 | import currentReducer from './slices/current.slice'; 5 | 6 | export const store = configureStore({ 7 | reducer: { 8 | current: currentReducer, 9 | forecast: forecastReducer, 10 | }, 11 | }); 12 | 13 | export type RootState = ReturnType; 14 | export type AppDispatch = typeof store.dispatch; 15 | 16 | export const useAppDispatch = () => useDispatch(); 17 | export const useAppSelector: TypedUseSelectorHook = useSelector; 18 | -------------------------------------------------------------------------------- /src/store/slices/current.slice.test.ts: -------------------------------------------------------------------------------- 1 | import WeatherbitApp from '../../services/WeatherbitApp'; 2 | import { mockCurrent } from '../../mocks/current'; 3 | import reducer, { fetchCurrentWeather } from './current.slice'; 4 | import axios from 'axios'; 5 | import { configureStore } from '@reduxjs/toolkit'; 6 | 7 | jest.mock('../../services/WeatherbitApp.ts'); 8 | const mockedWeatherbitApp = WeatherbitApp as jest.Mocked; 9 | describe('current weather slice', () => { 10 | const initialState = { 11 | loading: false, 12 | data: null, 13 | error: null, 14 | }; 15 | const mockResponse = { 16 | data: { 17 | data: [mockCurrent], 18 | }, 19 | }; 20 | const mockStore = configureStore({ 21 | reducer: { 22 | current: reducer, 23 | }, 24 | }); 25 | it('fetch current success', async () => { 26 | mockedWeatherbitApp.get.mockResolvedValue(mockResponse); 27 | await mockStore.dispatch(fetchCurrentWeather('Melbourne, AU')); 28 | expect(mockedWeatherbitApp.get).toBeCalledWith('/current', { 29 | params: { city: 'Melbourne, AU' }, 30 | }); 31 | const state = mockStore.getState(); 32 | expect(state.current).toEqual({ 33 | data: mockCurrent, 34 | error: null, 35 | loading: false, 36 | }); 37 | }); 38 | it('fetch current failed with 404', async () => { 39 | mockedWeatherbitApp.get.mockResolvedValue({ data: [] }); 40 | await mockStore.dispatch(fetchCurrentWeather('Melbourne, AU')); 41 | expect(mockedWeatherbitApp.get).toBeCalledWith('/current', { 42 | params: { city: 'Melbourne, AU' }, 43 | }); 44 | const state = mockStore.getState(); 45 | expect(state.current).toEqual({ 46 | data: null, 47 | error: { 48 | code: 404, 49 | message: 'Weather data not found', 50 | }, 51 | loading: false, 52 | }); 53 | }); 54 | it('fetch current failed with other errors', async () => { 55 | mockedWeatherbitApp.get.mockRejectedValue({ 56 | response: { 57 | status: 500, 58 | }, 59 | message: 'unknown error', 60 | }); 61 | await mockStore.dispatch(fetchCurrentWeather('Melbourne, AU')); 62 | expect(mockedWeatherbitApp.get).toBeCalledWith('/current', { 63 | params: { city: 'Melbourne, AU' }, 64 | }); 65 | const state = mockStore.getState(); 66 | expect(state.current).toEqual({ 67 | data: null, 68 | error: { 69 | code: 500, 70 | message: 'unknown error', 71 | }, 72 | loading: false, 73 | }); 74 | }); 75 | it('set loading true when start fetching current', () => { 76 | const action = { type: fetchCurrentWeather.pending.type }; 77 | const state = reducer(initialState, action); 78 | expect(state).toEqual({ 79 | loading: true, 80 | data: null, 81 | error: null, 82 | }); 83 | }); 84 | it('set data when fetching current successfully', () => { 85 | const action = { 86 | type: fetchCurrentWeather.fulfilled.type, 87 | payload: mockCurrent, 88 | }; 89 | const state = reducer(initialState, action); 90 | expect(state).toEqual({ 91 | loading: false, 92 | data: mockCurrent, 93 | error: null, 94 | }); 95 | }); 96 | it('set known error when fetching current failed', () => { 97 | const action = { 98 | type: fetchCurrentWeather.rejected.type, 99 | payload: { 100 | code: 404, 101 | message: 'not found', 102 | }, 103 | }; 104 | const state = reducer(initialState, action); 105 | expect(state).toEqual({ 106 | loading: false, 107 | data: null, 108 | error: { 109 | code: 404, 110 | message: 'not found', 111 | }, 112 | }); 113 | }); 114 | it('set unknown error when fetching current failed', () => { 115 | const action = { 116 | type: fetchCurrentWeather.rejected.type, 117 | }; 118 | const state = reducer(initialState, action); 119 | expect(state).toEqual({ 120 | loading: false, 121 | data: null, 122 | error: { 123 | code: 500, 124 | message: 'unknown error', 125 | }, 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /src/store/slices/current.slice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; 2 | import { ICurrentState } from '../../common/interfaces/ICurrentState'; 3 | import ICurrentWeather from '../../common/interfaces/ICurrentWeather'; 4 | import IError from '../../common/interfaces/IError'; 5 | import IGetCurrentWeatherResponse from '../../common/interfaces/IGetCurrentWeatherResponse'; 6 | import WeatherbitApp from '../../services/WeatherbitApp'; 7 | 8 | const initialState: ICurrentState = { 9 | loading: false, 10 | data: null, 11 | error: null, 12 | }; 13 | 14 | export const fetchCurrentWeather = createAsyncThunk< 15 | ICurrentWeather, 16 | string, 17 | { 18 | rejectValue: IError; 19 | } 20 | >('current/fetchByCity', async (city: string, { rejectWithValue }) => { 21 | try { 22 | const res = await WeatherbitApp.get( 23 | '/current', 24 | { 25 | params: { city }, 26 | }, 27 | ); 28 | const { data } = res; 29 | const { data: weatherList } = data; 30 | if (weatherList && weatherList.length > 0) { 31 | return weatherList[0]; 32 | } else { 33 | return rejectWithValue({ 34 | code: 404, 35 | message: 'Weather data not found', 36 | }); 37 | } 38 | } catch (error) { 39 | return rejectWithValue({ 40 | code: error.response.status, 41 | message: error.message, 42 | }); 43 | } 44 | }); 45 | 46 | export const currentSlice = createSlice({ 47 | name: 'current', 48 | initialState, 49 | reducers: {}, 50 | extraReducers: (builder) => { 51 | builder.addCase(fetchCurrentWeather.pending, (state) => { 52 | state.loading = true; 53 | state.data = null; 54 | state.error = null; 55 | }); 56 | builder.addCase(fetchCurrentWeather.fulfilled, (state, action) => { 57 | state.loading = false; 58 | state.data = action.payload; 59 | state.error = null; 60 | }); 61 | builder.addCase(fetchCurrentWeather.rejected, (state, action) => { 62 | state.loading = false; 63 | state.data = null; 64 | if (action.payload) { 65 | state.error = action.payload; 66 | } else { 67 | state.error = { code: 500, message: 'unknown error' }; 68 | } 69 | }); 70 | }, 71 | }); 72 | 73 | export default currentSlice.reducer; 74 | -------------------------------------------------------------------------------- /src/store/slices/forecast.slice.test.ts: -------------------------------------------------------------------------------- 1 | import WeatherbitApp from '../../services/WeatherbitApp'; 2 | import axios from 'axios'; 3 | import { configureStore } from '@reduxjs/toolkit'; 4 | import reducer, { fetchForecastWeather } from './forecast.slice'; 5 | import { mockForecast } from '../../mocks/forecast'; 6 | 7 | jest.mock('../../services/WeatherbitApp.ts'); 8 | const mockedWeatherbitApp = WeatherbitApp as jest.Mocked; 9 | 10 | describe('forecast weather slice', () => { 11 | const initialState = { 12 | loading: false, 13 | data: [], 14 | error: null, 15 | }; 16 | const mockResponse = { 17 | data: { 18 | data: mockForecast, 19 | }, 20 | }; 21 | const mockStore = configureStore({ 22 | reducer: { 23 | forecast: reducer, 24 | }, 25 | }); 26 | it('fetch forecast success', async () => { 27 | mockedWeatherbitApp.get.mockResolvedValue(mockResponse); 28 | await mockStore.dispatch(fetchForecastWeather('Melbourne, AU')); 29 | expect(mockedWeatherbitApp.get).toBeCalledWith('/forecast/daily', { 30 | params: { city: 'Melbourne, AU' }, 31 | }); 32 | const state = mockStore.getState(); 33 | expect(state.forecast).toEqual({ 34 | data: mockForecast, 35 | error: null, 36 | loading: false, 37 | }); 38 | }); 39 | it('fetch forecast failed with 404', async () => { 40 | mockedWeatherbitApp.get.mockResolvedValue({ data: [] }); 41 | await mockStore.dispatch(fetchForecastWeather('Melbourne, AU')); 42 | expect(mockedWeatherbitApp.get).toBeCalledWith('/forecast/daily', { 43 | params: { city: 'Melbourne, AU' }, 44 | }); 45 | const state = mockStore.getState(); 46 | expect(state.forecast).toEqual({ 47 | data: [], 48 | error: { 49 | code: 404, 50 | message: 'Weather data not found', 51 | }, 52 | loading: false, 53 | }); 54 | }); 55 | it('fetch forecast failed with other errors', async () => { 56 | mockedWeatherbitApp.get.mockRejectedValue({ 57 | response: { 58 | status: 500, 59 | }, 60 | message: 'unknown error', 61 | }); 62 | await mockStore.dispatch(fetchForecastWeather('Melbourne, AU')); 63 | expect(mockedWeatherbitApp.get).toBeCalledWith('/forecast/daily', { 64 | params: { city: 'Melbourne, AU' }, 65 | }); 66 | const state = mockStore.getState(); 67 | expect(state.forecast).toEqual({ 68 | data: [], 69 | error: { 70 | code: 500, 71 | message: 'unknown error', 72 | }, 73 | loading: false, 74 | }); 75 | }); 76 | it('set loading true when start fetching forecast', () => { 77 | const action = { type: fetchForecastWeather.pending.type }; 78 | const state = reducer(initialState, action); 79 | expect(state).toEqual({ 80 | loading: true, 81 | data: [], 82 | error: null, 83 | }); 84 | }); 85 | it('set data when fetching forecast successfully', () => { 86 | const action = { 87 | type: fetchForecastWeather.fulfilled.type, 88 | payload: mockForecast, 89 | }; 90 | const state = reducer(initialState, action); 91 | expect(state).toEqual({ 92 | loading: false, 93 | data: mockForecast, 94 | error: null, 95 | }); 96 | }); 97 | it('set known error when fetching forecast failed', () => { 98 | const action = { 99 | type: fetchForecastWeather.rejected.type, 100 | payload: { 101 | code: 404, 102 | message: 'not found', 103 | }, 104 | }; 105 | const state = reducer(initialState, action); 106 | expect(state).toEqual({ 107 | loading: false, 108 | data: [], 109 | error: { 110 | code: 404, 111 | message: 'not found', 112 | }, 113 | }); 114 | }); 115 | it('set unknown error when fetching forecast failed', () => { 116 | const action = { 117 | type: fetchForecastWeather.rejected.type, 118 | }; 119 | const state = reducer(initialState, action); 120 | expect(state).toEqual({ 121 | loading: false, 122 | data: [], 123 | error: { 124 | code: 500, 125 | message: 'unknown error', 126 | }, 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/store/slices/forecast.slice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; 2 | import IError from '../../common/interfaces/IError'; 3 | import IForecast from '../../common/interfaces/IForecast'; 4 | import { IForecastState } from '../../common/interfaces/IForecastState'; 5 | import IGetForecastResponse from '../../common/interfaces/IGetForecastResponse'; 6 | import WeatherbitApp from '../../services/WeatherbitApp'; 7 | 8 | const initialState: IForecastState = { 9 | loading: false, 10 | data: [], 11 | error: null, 12 | }; 13 | 14 | export const fetchForecastWeather = createAsyncThunk< 15 | IForecast[], 16 | string, 17 | { 18 | rejectValue: IError; 19 | } 20 | >('forecast/fetchByCity', async (city: string, { rejectWithValue }) => { 21 | try { 22 | const res = await WeatherbitApp.get( 23 | '/forecast/daily', 24 | { 25 | params: { city }, 26 | }, 27 | ); 28 | const { data } = res; 29 | const { data: forecastList } = data; 30 | if (forecastList && forecastList.length > 0) { 31 | return forecastList; 32 | } else { 33 | return rejectWithValue({ 34 | code: 404, 35 | message: 'Weather data not found', 36 | }); 37 | } 38 | } catch (error) { 39 | return rejectWithValue({ 40 | code: error.response.status, 41 | message: error.message, 42 | }); 43 | } 44 | }); 45 | 46 | export const forecastSlice = createSlice({ 47 | name: 'forecast', 48 | initialState, 49 | reducers: {}, 50 | extraReducers: (builder) => { 51 | builder.addCase(fetchForecastWeather.pending, (state) => { 52 | state.loading = true; 53 | state.data = []; 54 | state.error = null; 55 | }); 56 | builder.addCase(fetchForecastWeather.fulfilled, (state, action) => { 57 | state.loading = false; 58 | state.data = action.payload; 59 | state.error = null; 60 | }); 61 | builder.addCase(fetchForecastWeather.rejected, (state, action) => { 62 | state.loading = false; 63 | state.data = []; 64 | if (action.payload) { 65 | state.error = action.payload; 66 | } else { 67 | state.error = { code: 500, message: 'unknown error' }; 68 | } 69 | }); 70 | }, 71 | }); 72 | 73 | export default forecastSlice.reducer; 74 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | --------------------------------------------------------------------------------