├── .editorconfig ├── .env.development ├── .env.production ├── .env.test ├── .eslintrc.json ├── .gitignore ├── .huskyrc.json ├── .npmrc ├── .travis.yml ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html └── src ├── App.js ├── Routes.js ├── __tests__ ├── App.test.js ├── Routes.test.js └── __snapshots__ │ └── Routes.test.js.snap ├── actions ├── __tests__ │ ├── layoutActions.test.js │ ├── searchActions.test.js │ └── searchParamsActions.test.js ├── layoutActions.js ├── searchActions.js └── searchParamsActions.js ├── components ├── searchForm │ ├── SearchForm.js │ ├── __tests__ │ │ ├── SearchForm.test.js │ │ └── __snapshots__ │ │ │ └── SearchForm.test.js.snap │ └── searchForm.css ├── searchItem │ ├── SearchItem.js │ ├── __tests__ │ │ ├── SearchItem.test.js │ │ └── __snapshots__ │ │ │ └── SearchItem.test.js.snap │ └── searchItem.css ├── searchPage │ ├── SearchPage.js │ └── __tests__ │ │ ├── SearchPage.test.js │ │ └── __snapshots__ │ │ └── SearchPage.test.js.snap ├── searchResults │ ├── SearchResults.js │ └── __tests__ │ │ ├── SearchResults.test.js │ │ └── __snapshots__ │ │ └── SearchResults.test.js.snap ├── searchSummary │ ├── SearchSummary.js │ └── __tests__ │ │ ├── SearchSummary.test.js │ │ └── __snapshots__ │ │ └── SearchSummary.test.js.snap ├── searchViewTabs │ ├── SearchViewTabs.js │ ├── __tests__ │ │ ├── SearchViewTabs.test.js │ │ └── __snapshots__ │ │ │ └── SearchViewTabs.test.js.snap │ └── searchViewTabs.css └── shared │ ├── header │ ├── Header.js │ ├── __tests__ │ │ ├── Header.test.js │ │ └── __snapshots__ │ │ │ └── Header.test.js.snap │ └── header.css │ ├── infiniteScroll │ ├── InfiniteScroll.js │ └── __tests__ │ │ ├── InfiniteScroll.test.js │ │ └── __snapshots__ │ │ └── InfiniteScroll.test.js.snap │ ├── layout │ ├── Layout.js │ └── __tests__ │ │ ├── Layout.test.js │ │ └── __snapshots__ │ │ └── Layout.test.js.snap │ ├── masonryGrid │ ├── MasonryGrid.js │ └── __tests__ │ │ ├── MasonryGrid.test.js │ │ └── __snapshots__ │ │ └── MasonryGrid.test.js.snap │ └── spinner │ ├── Spinner.js │ ├── __tests__ │ ├── Spinner.test.js │ └── __snapshots__ │ │ └── Spinner.test.js.snap │ └── spinner.css ├── config.js ├── containers ├── layoutContainer │ ├── LayoutContainer.js │ └── __tests__ │ │ ├── LayoutContainer.test.js │ │ └── __snapshots__ │ │ └── LayoutContainer.test.js.snap ├── searchFormContainer │ ├── SearchFormContainer.js │ └── __tests__ │ │ ├── SearchFormContainer.test.js │ │ └── __snapshots__ │ │ └── SearchFormContainer.test.js.snap ├── searchResultsContainer │ ├── SearchResultsContainer.js │ └── __tests__ │ │ ├── SearchResultsContainer.test.js │ │ └── __snapshots__ │ │ └── SearchResultsContainer.test.js.snap ├── searchSummaryContainer │ ├── SearchSummaryContainer.js │ └── __tests__ │ │ ├── SearchSummaryContainer.test.js │ │ └── __snapshots__ │ │ └── SearchSummaryContainer.test.js.snap └── searchViewTabsContainer │ ├── SearchViewTabsContainer.js │ └── __tests__ │ ├── SearchViewTabsContainer.test.js │ └── __snapshots__ │ └── SearchViewTabsContainer.test.js.snap ├── index.js ├── mocks └── searchResults.json ├── reducers ├── __tests__ │ ├── layoutReducer.test.js │ ├── rootReducer.test.js │ ├── searchParamsReducer.test.js │ └── searchResultsReducer.test.js ├── layoutReducer.js ├── rootReducer.js ├── searchParamsReducer.js └── searchResultsReducer.js ├── services ├── GiphyService.js ├── LayoutService.js ├── RequestService.js ├── RouterService.js ├── __mocks__ │ ├── GiphyService.js │ ├── RequestService.js │ └── RouterService.js └── __tests__ │ ├── GiphyService.test.js │ ├── LayoutService.test.js │ ├── RequestService.test.js │ └── RouterService.test.js ├── setupTests.js ├── store.js └── types ├── giphyTypes.js └── reduxTypes.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | # Base URL of the App. It allows putting the APP into subfolders on the server. 2 | REACT_APP_BASE_URL=/ 3 | 4 | # GIPHY API key. 5 | REACT_APP_GIPHY_API_KEY=CdRKiCMbTnt9CkZTZ0lGukSczk6iT4Z6 6 | 7 | # GIPHY API Host. 8 | REACT_APP_GIPHY_API_HOST=https://api.giphy.com 9 | 10 | # HTTP request timeout in milliseconds. 11 | HTTP_REQUEST_TIMEOUT=1000 12 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # Base URL of the App. It allows putting the APP into sub-folders on the server. 2 | REACT_APP_BASE_URL=/giphygram 3 | 4 | # GIPHY API key. 5 | REACT_APP_GIPHY_API_KEY=CdRKiCMbTnt9CkZTZ0lGukSczk6iT4Z6 6 | 7 | # GIPHY API Host. 8 | REACT_APP_GIPHY_API_HOST=https://api.giphy.com 9 | 10 | # HTTP request timeout in milliseconds. 11 | HTTP_REQUEST_TIMEOUT=1000 12 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # Base URL of the App. It allows putting the APP into subfolders on the server. 2 | REACT_APP_BASE_URL=/ 3 | 4 | # GIPHY API key. 5 | REACT_APP_GIPHY_API_KEY=CdRKiCMbTnt9CkZTZ0lGukSczk6iT4Z6 6 | 7 | # GIPHY API Host. 8 | REACT_APP_GIPHY_API_HOST=https://api.giphy.com 9 | 10 | # HTTP request timeout in milliseconds. 11 | HTTP_REQUEST_TIMEOUT=1000 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb", "react-app"], 3 | "rules": { 4 | "import/prefer-default-export": "off", 5 | "no-underscore-dangle": "off", 6 | "react/jsx-filename-extension": ["off", {"extensions": [".js", ".jsx"]}], 7 | "react/prefer-stateless-function": ["off", { "ignorePureComponents": true}], 8 | "react/forbid-prop-types": ["error", {"forbid": ["any"]}] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.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 | # Editors. 15 | .idea 16 | .vscode 17 | 18 | # Debugging and logging. 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | # Misc. 24 | .DS_Store 25 | .env.local 26 | .env.development.local 27 | .env.test.local 28 | .env.production.local 29 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-push": "npm run lint && npm test -- --coverage" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | language: node_js 4 | node_js: 5 | - "11.10" 6 | cache: 7 | directories: 8 | - node_modules 9 | install: 10 | - npm install -g codecov 11 | - npm install 12 | script: 13 | - npm run ci 14 | - npm run build 15 | - codecov 16 | notifications: 17 | email: false 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Oleksii Trekhleb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GiphyGram App 2 | 3 | [![Build Status](https://travis-ci.org/trekhleb/giphygram.svg?branch=master)](https://travis-ci.org/trekhleb/giphygram) 4 | [![codecov](https://codecov.io/gh/trekhleb/giphygram/branch/master/graph/badge.svg)](https://codecov.io/gh/trekhleb/giphygram) 5 | 6 | > This project is a front-end React application that serves a sole purpose of searching GIF images on [GIHPY.com](https://giphy.com/) using GIPHY [Search API](https://developers.giphy.com/docs/#search-endpoint). The project is implemented using **React** (as a main UI library), **Redux** (for state management), **React Router** (to establish a possibility to extend the application with new internal routes and pages) and **Bootstrap 4** (as a main styling framework). 7 | 8 | ## Launching the Project 9 | 10 | [▶︎ Launch Demo Right in Your Browser](https://trekhleb.github.io/giphygram/) (powered by GitHub Pages) 11 | 12 | If you want to launch this project locally please clone/checkout this project to your local folder and then you can run: 13 | 14 | ```bash 15 | npm start 16 | ``` 17 | 18 | This command runs the app in the development mode. Open [http://localhost:3000](http://localhost:3000) to view it in the browser locally. 19 | 20 | The page will reload if you make edits. You will also see any _lint errors_ in the console. 21 | 22 | ## Running Tests 23 | 24 | To launch project tests you need to run: 25 | 26 | ```bash 27 | npm test 28 | ``` 29 | 30 | This command launches the test runner in the interactive watch mode. 31 | 32 | ## Building the Project 33 | 34 | To create a production ready version of the project you may run: 35 | 36 | ```bash 37 | npm run build 38 | ``` 39 | 40 | This command builds the app for production to the `build` folder. It correctly bundles React in production mode and optimizes the build for the best performance. 41 | 42 | The build is minified and the filenames include the hashes. The app is ready to be deployed! 43 | 44 | ## Deploying the Project 45 | 46 | For demo purpose the deployment of this project is done using [GitHub pages](https://pages.github.com/) and [gh-pages](https://www.npmjs.com/package/gh-pages) npm module in particular. You may deploy the project by making sure that you have `gh-pages` branch in your fork on GitHub and by running: 47 | 48 | ```bash 49 | npm run deploy 50 | ``` 51 | 52 | This command will create a production build of the project and will commit and push the contents of `build` folder to the `gh-pages` branch. Once this branch is set up as a target branch for GitHub pages you'll be able to see project demo similar to the [existing one](https://trekhleb.github.io/giphygram/). 53 | 54 | ## Project Continuous Integration 55 | 56 | Current project is integrated with [Travis](https://travis-ci.org/trekhleb/giphygram) and [Codecov](https://codecov.io/gh/trekhleb/giphygram) services. Travis service launches project build and tests for every new commit and pull request to make sure that things are not broken. Codecov service reports code coverage percentage to see how reliable and stable the process of development is. 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "giphygram", 3 | "version": "0.0.1", 4 | "private": false, 5 | "homepage": "https://trekhleb.github.io/giphygram", 6 | "dependencies": { 7 | "axios": "^0.18.0", 8 | "open-iconic": "^1.1.1", 9 | "prop-types": "^15.7.2", 10 | "react": "^16.8.4", 11 | "react-dom": "^16.8.4", 12 | "react-redux": "^6.0.1", 13 | "react-router-dom": "^5.0.0", 14 | "react-scripts": "^2.1.8", 15 | "redux": "^4.0.1", 16 | "redux-promise-middleware": "^6.1.0", 17 | "redux-thunk": "^2.3.0" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "lint": "eslint ./src --ext .js", 22 | "test": "react-scripts test", 23 | "ci": "npm run lint && npm run test -- --coverage", 24 | "build": "react-scripts build", 25 | "predeploy": "npm run ci && npm run build", 26 | "deploy": "gh-pages -d build", 27 | "eject": "react-scripts eject" 28 | }, 29 | "browserslist": [ 30 | ">0.2%", 31 | "not dead", 32 | "not ie <= 11", 33 | "not op_mini all" 34 | ], 35 | "devDependencies": { 36 | "axios-mock-adapter": "^1.16.0", 37 | "enzyme": "^3.9.0", 38 | "enzyme-adapter-react-16": "^1.11.2", 39 | "eslint-config-airbnb": "^17.1.0", 40 | "gh-pages": "^2.0.1", 41 | "husky": "^1.3.1", 42 | "react-test-renderer": "^16.8.4" 43 | }, 44 | "jest": { 45 | "collectCoverageFrom": [ 46 | "src/**/*.{js,jsx,ts,tsx}", 47 | "!/node_modules/", 48 | "!/src/index.js" 49 | ] 50 | }, 51 | "engines": { 52 | "node": "~11.10" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trekhleb/giphygram/d1341ca634a8d59ab0d3a79619c5701bfc202976/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 17 | 18 | GiphyGram 19 | 20 | 21 | 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import { Provider } from 'react-redux'; 4 | import RoutesConnected from './Routes'; 5 | import { Header } from './components/shared/header/Header'; 6 | import { storePropType } from './types/reduxTypes'; 7 | import { APP_BASE_URL } from './config'; 8 | import 'open-iconic/font/css/open-iconic-bootstrap.min.css'; 9 | import LayoutContainerConnected from './containers/layoutContainer/LayoutContainer'; 10 | 11 | export class App extends React.Component { 12 | static propTypes = { 13 | store: storePropType.isRequired, 14 | }; 15 | 16 | render() { 17 | const { store } = this.props; 18 | 19 | return ( 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { Route, Switch, withRouter } from 'react-router-dom'; 5 | import { SearchPage } from './components/searchPage/SearchPage'; 6 | import { RouterService } from './services/RouterService'; 7 | import { updateSearchQuery } from './actions/searchParamsActions'; 8 | import { search } from './actions/searchActions'; 9 | 10 | export class Routes extends React.Component { 11 | static propTypes = { 12 | routerService: PropTypes.instanceOf(RouterService).isRequired, 13 | updateSearchQuery: PropTypes.func.isRequired, 14 | search: PropTypes.func.isRequired, 15 | }; 16 | 17 | componentDidMount() { 18 | const { 19 | routerService, 20 | updateSearchQuery: searchQueryFromLocationCallback, 21 | search: searchCallback, 22 | } = this.props; 23 | 24 | // Check if search query has been submitted through the URL. 25 | // In case if search query is in URL we need to launch the search. 26 | const searchQueryFromLocation = routerService.getSearchQuery(); 27 | if (searchQueryFromLocation) { 28 | // Update search form parameters. 29 | searchQueryFromLocationCallback(searchQueryFromLocation); 30 | // Launch the search and populate the state with search results. 31 | searchCallback({ query: searchQueryFromLocation }); 32 | } 33 | } 34 | 35 | render() { 36 | // Currently we have only one route. But the next step of the App development might be to create 37 | // a dedicated pages for each GIF with additional details. Or to display most trending GIFs 38 | // on the /home and search results on the /search page. 39 | return ( 40 | 41 | 42 | 43 | ); 44 | } 45 | } 46 | 47 | const mapStateToProps = (state, props) => ({ 48 | routerService: new RouterService(props.history, props.location), 49 | }); 50 | 51 | const mapDispatchToProps = { 52 | updateSearchQuery, 53 | search, 54 | }; 55 | 56 | export default withRouter( 57 | connect( 58 | mapStateToProps, 59 | mapDispatchToProps, 60 | )(Routes), 61 | ); 62 | -------------------------------------------------------------------------------- /src/__tests__/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { store } from '../store'; 4 | import { App } from '../App'; 5 | 6 | describe('App', () => { 7 | it('should be rendered without crashing', () => { 8 | mount(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/__tests__/Routes.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | import { Routes } from '../Routes'; 5 | import { RouterService } from '../services/RouterService'; 6 | 7 | jest.mock('../services/RouterService'); 8 | jest.mock('../components/searchPage/SearchPage', () => ({ 9 | SearchPage: () => 'SearchPage', 10 | })); 11 | 12 | describe('Routes', () => { 13 | it('should not launch the search when search param is empty in URL', () => { 14 | const searchMock = jest.fn(); 15 | const updateSearchQueryMock = jest.fn(); 16 | const routerService = new RouterService(null, null); 17 | 18 | const component = renderer.create(( 19 | 20 | 25 | 26 | )); 27 | 28 | expect(searchMock).not.toHaveBeenCalled(); 29 | expect(updateSearchQueryMock).not.toHaveBeenCalled(); 30 | expect(component.toJSON()).toMatchSnapshot(); 31 | }); 32 | 33 | it('should launch the search when search param is not empty in URL', () => { 34 | const searchQuery = 'kitten'; 35 | const searchMock = jest.fn(); 36 | const updateSearchQueryMock = jest.fn(); 37 | const routerService = new RouterService(null, null); 38 | routerService.getSearchQuery.mockImplementation(() => searchQuery); 39 | 40 | const component = renderer.create(( 41 | 42 | 47 | 48 | )); 49 | 50 | expect(searchMock).toHaveBeenCalledTimes(1); 51 | expect(searchMock).toHaveBeenCalledWith({ query: searchQuery }); 52 | 53 | expect(updateSearchQueryMock).toHaveBeenCalledTimes(1); 54 | expect(updateSearchQueryMock).toHaveBeenLastCalledWith(searchQuery); 55 | 56 | expect(component.toJSON()).toMatchSnapshot(); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/Routes.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Routes should launch the search when search param is not empty in URL 1`] = `"SearchPage"`; 4 | 5 | exports[`Routes should not launch the search when search param is empty in URL 1`] = `"SearchPage"`; 6 | -------------------------------------------------------------------------------- /src/actions/__tests__/layoutActions.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | LAYOUT_ACTION_TYPES, 3 | layoutResize, 4 | setColumnsNum, 5 | } from '../layoutActions'; 6 | 7 | describe('layoutActions', () => { 8 | it('should generate layoutResize', () => { 9 | const size = 'sm'; 10 | const action = layoutResize(size); 11 | expect(action).toEqual({ 12 | type: LAYOUT_ACTION_TYPES.RESIZE, 13 | payload: size, 14 | }); 15 | }); 16 | 17 | it('should generate setColumnsNum', () => { 18 | const columns = 3; 19 | const action = setColumnsNum(columns); 20 | expect(action).toEqual({ 21 | type: LAYOUT_ACTION_TYPES.SET_COLUMNS_NUM, 22 | payload: columns, 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/actions/__tests__/searchActions.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | SEARCH_ACTION_TYPES, 3 | search, 4 | searchMore, 5 | searchReset, 6 | } from '../searchActions'; 7 | import searchResultsMock from '../../mocks/searchResults'; 8 | import { SEARCH_PARAMS_ACTION_TYPES } from '../searchParamsActions'; 9 | import { getSearchResultsFromState } from '../../reducers/searchResultsReducer'; 10 | import { getSearchParamsFromState } from '../../reducers/searchParamsReducer'; 11 | 12 | jest.mock('../../services/GiphyService'); 13 | jest.mock('../../reducers/searchParamsReducer'); 14 | jest.mock('../../reducers/searchResultsReducer'); 15 | 16 | describe('searchActions', () => { 17 | beforeEach(() => { 18 | getSearchResultsFromState.mockImplementation(() => ({ 19 | data: [], 20 | pagination: { 21 | total_count: 100, 22 | count: 5, 23 | offset: 0, 24 | }, 25 | isLoading: false, 26 | isFetchingMore: false, 27 | })); 28 | 29 | getSearchParamsFromState.mockImplementation(() => ({ 30 | query: 'kittens', 31 | offset: 0, 32 | })); 33 | }); 34 | 35 | afterEach(() => { 36 | jest.clearAllMocks(); 37 | }); 38 | 39 | it('should generate search action', () => { 40 | const searchParams = { 41 | query: 'kitten', 42 | }; 43 | const { payload, type } = search(searchParams); 44 | 45 | expect(type).toBe(SEARCH_ACTION_TYPES.SEARCH); 46 | expect(payload).toBeDefined(); 47 | 48 | return expect(payload).resolves.toBe(searchResultsMock); 49 | }); 50 | 51 | it('should generate search reset action', () => { 52 | const { type, payload } = searchReset(); 53 | expect(type).toBe(SEARCH_ACTION_TYPES.SEARCH_RESET); 54 | expect(payload).toBeNull(); 55 | }); 56 | 57 | it('should generate search more action', () => { 58 | const dispatchMock = jest.fn(); 59 | const getStateMock = jest.fn(); 60 | 61 | // Do search more action. 62 | const batchSize = 50; 63 | searchMore(batchSize)(dispatchMock, getStateMock); 64 | 65 | // We expect that searchMore should dispatch two actions. 66 | expect(dispatchMock).toHaveBeenCalledTimes(2); 67 | expect(dispatchMock.mock.calls[0][0]).toEqual({ 68 | type: SEARCH_PARAMS_ACTION_TYPES.UPDATE_SEARCH_OFFSET, 69 | payload: batchSize, 70 | }); 71 | expect(dispatchMock.mock.calls[1][0].type).toBe(SEARCH_ACTION_TYPES.SEARCH_MORE); 72 | 73 | return expect(dispatchMock.mock.calls[1][0].payload).resolves.toBe(searchResultsMock); 74 | }); 75 | 76 | it('should not generate search more action if there are no additional items on server', () => { 77 | const dispatchMock = jest.fn(); 78 | const getStateMock = jest.fn(); 79 | 80 | // Do search more action. 81 | // According to the mock there is only 100 items on the server. 82 | // Let's try to fetch items starting from offset 200. 83 | const batchSize = 200; 84 | searchMore(batchSize)(dispatchMock, getStateMock); 85 | 86 | // We expect that searchMore should not dispatch any actions. 87 | expect(dispatchMock).not.toHaveBeenCalled(); 88 | }); 89 | 90 | it('should not fetch more results if they are already loading', () => { 91 | getSearchResultsFromState.mockImplementation(() => ({ 92 | data: [], 93 | pagination: { 94 | total_count: 100, 95 | count: 5, 96 | offset: 0, 97 | }, 98 | isLoading: false, 99 | isFetchingMore: true, 100 | })); 101 | 102 | const dispatchMock = jest.fn(); 103 | const getStateMock = jest.fn(); 104 | 105 | // Do search more action. 106 | const batchSize = 50; 107 | searchMore(batchSize)(dispatchMock, getStateMock); 108 | 109 | // We expect that searchMore should not dispatch any actions. 110 | expect(dispatchMock).not.toHaveBeenCalled(); 111 | }); 112 | 113 | it('should not fetch more results if initial search is loading', () => { 114 | getSearchResultsFromState.mockImplementation(() => ({ 115 | data: [], 116 | pagination: { 117 | total_count: 100, 118 | count: 5, 119 | offset: 0, 120 | }, 121 | isLoading: true, 122 | isFetchingMore: false, 123 | })); 124 | 125 | const dispatchMock = jest.fn(); 126 | const getStateMock = jest.fn(); 127 | 128 | // Do search more action. 129 | const batchSize = 50; 130 | searchMore(batchSize)(dispatchMock, getStateMock); 131 | 132 | // We expect that searchMore should not dispatch any actions. 133 | expect(dispatchMock).not.toHaveBeenCalled(); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/actions/__tests__/searchParamsActions.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | SEARCH_PARAMS_ACTION_TYPES, 3 | updateSearchOffset, 4 | updateSearchQuery, 5 | } from '../searchParamsActions'; 6 | 7 | describe('searchParamsActions', () => { 8 | it('should generate updateSearchQuery', () => { 9 | const searchQuery = 'kitten'; 10 | const action = updateSearchQuery(searchQuery); 11 | expect(action).toEqual({ 12 | type: SEARCH_PARAMS_ACTION_TYPES.UPDATE_SEARCH_QUERY, 13 | payload: searchQuery, 14 | }); 15 | }); 16 | 17 | it('should generate updateSearchOffset', () => { 18 | const searchOffset = 50; 19 | const action = updateSearchOffset(searchOffset); 20 | expect(action).toEqual({ 21 | type: SEARCH_PARAMS_ACTION_TYPES.UPDATE_SEARCH_OFFSET, 22 | payload: searchOffset, 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/actions/layoutActions.js: -------------------------------------------------------------------------------- 1 | export const LAYOUT_ACTION_TYPES = { 2 | RESIZE: 'RESIZE', 3 | SET_COLUMNS_NUM: 'SET_COLUMNS_NUM', 4 | }; 5 | 6 | /** 7 | * Resize layout automatically depending on the current window size. 8 | * @param {string} layoutSize 9 | */ 10 | export function layoutResize(layoutSize) { 11 | return { 12 | type: LAYOUT_ACTION_TYPES.RESIZE, 13 | payload: layoutSize, 14 | }; 15 | } 16 | 17 | /** 18 | * Setup the number of layout columns manually. 19 | * @param {number} columnsNum 20 | */ 21 | export function setColumnsNum(columnsNum) { 22 | return { 23 | type: LAYOUT_ACTION_TYPES.SET_COLUMNS_NUM, 24 | payload: columnsNum, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/actions/searchActions.js: -------------------------------------------------------------------------------- 1 | import { GiphyService } from '../services/GiphyService'; 2 | import { updateSearchOffset } from './searchParamsActions'; 3 | import { SEARCH_BATCH_SIZE } from '../config'; 4 | import { getSearchParamsFromState } from '../reducers/searchParamsReducer'; 5 | import { getSearchResultsFromState } from '../reducers/searchResultsReducer'; 6 | 7 | export const SEARCH_ACTION_TYPES = { 8 | SEARCH: 'SEARCH', 9 | SEARCH_MORE: 'SEARCH_MORE', 10 | SEARCH_RESET: 'SEARCH_RESET', 11 | }; 12 | 13 | /** 14 | * Search on GIPHY. 15 | * @param {object} searchParams 16 | */ 17 | export function search(searchParams) { 18 | return { 19 | type: SEARCH_ACTION_TYPES.SEARCH, 20 | payload: GiphyService.search(searchParams), 21 | }; 22 | } 23 | 24 | // Reset search parameters. 25 | export function searchReset() { 26 | return { 27 | type: SEARCH_ACTION_TYPES.SEARCH_RESET, 28 | payload: null, 29 | }; 30 | } 31 | 32 | /** 33 | * Fetch more search results from GIPHY. 34 | * @param {number} batchSize 35 | */ 36 | export function searchMore(batchSize = SEARCH_BATCH_SIZE) { 37 | return (dispatch, getState) => { 38 | const state = getState(); 39 | 40 | // Fetch total number of results. 41 | const { pagination = {}, isFetchingMore, isLoading } = getSearchResultsFromState(state); 42 | 43 | const totalCount = pagination.total_count || 0; 44 | 45 | // Don't fetch anything if fetching is in progress right now. 46 | if (isFetchingMore || isLoading) { 47 | return null; 48 | } 49 | 50 | // Fetch current offset from search params. 51 | const searchParams = getSearchParamsFromState(state); 52 | const offset = searchParams.offset + batchSize; 53 | searchParams.offset = offset; 54 | 55 | // Check whether we want to fetch more results that actually exists on the server. 56 | if (offset >= totalCount) { 57 | // Nothing to fetch. 58 | return null; 59 | } 60 | 61 | // Update search offset in the store. 62 | dispatch(updateSearchOffset(offset)); 63 | 64 | // Fetch new search results and put them to the store. 65 | dispatch({ 66 | type: SEARCH_ACTION_TYPES.SEARCH_MORE, 67 | payload: GiphyService.search(searchParams), 68 | }); 69 | 70 | return null; 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /src/actions/searchParamsActions.js: -------------------------------------------------------------------------------- 1 | export const SEARCH_PARAMS_ACTION_TYPES = { 2 | UPDATE_SEARCH_QUERY: 'UPDATE_SEARCH_QUERY', 3 | UPDATE_SEARCH_OFFSET: 'UPDATE_SEARCH_OFFSET', 4 | }; 5 | 6 | /** 7 | * Update the value of search query. 8 | * @param {string} searchQuery 9 | */ 10 | export function updateSearchQuery(searchQuery) { 11 | return { 12 | type: SEARCH_PARAMS_ACTION_TYPES.UPDATE_SEARCH_QUERY, 13 | payload: searchQuery, 14 | }; 15 | } 16 | 17 | /** 18 | * Update current search offset. 19 | * @param {number} offset 20 | */ 21 | export function updateSearchOffset(offset) { 22 | return { 23 | type: SEARCH_PARAMS_ACTION_TYPES.UPDATE_SEARCH_OFFSET, 24 | payload: offset, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/searchForm/SearchForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './searchForm.css'; 4 | 5 | const inputMaxLength = 512; 6 | 7 | export class SearchForm extends React.Component { 8 | static propTypes = { 9 | query: PropTypes.string, 10 | onSearchSubmit: PropTypes.func, 11 | onSearchUpdate: PropTypes.func, 12 | onSearchReset: PropTypes.func, 13 | }; 14 | 15 | static defaultProps = { 16 | query: '', 17 | onSearchSubmit: () => {}, 18 | onSearchUpdate: () => {}, 19 | onSearchReset: () => {}, 20 | }; 21 | 22 | onQueryChange = (event) => { 23 | const { onSearchUpdate } = this.props; 24 | const query = event.target.value; 25 | onSearchUpdate(query); 26 | }; 27 | 28 | onSearchSubmit = (event) => { 29 | event.preventDefault(); 30 | const { onSearchSubmit, query } = this.props; 31 | // Don't fire onSearchSubmit callback when search query is empty. 32 | if (query) { 33 | onSearchSubmit(query); 34 | } 35 | }; 36 | 37 | onSearchReset = (event) => { 38 | event.preventDefault(); 39 | const { onSearchReset } = this.props; 40 | onSearchReset(); 41 | }; 42 | 43 | render() { 44 | const { query } = this.props; 45 | 46 | const resetElement = query && query.length ? ( 47 |
48 | 56 |
57 | ) : null; 58 | 59 | return ( 60 |
61 |
62 | 73 | 74 | {resetElement} 75 | 76 |
77 | 85 |
86 |
87 |
88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/components/searchForm/__tests__/SearchForm.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { SearchForm } from '../SearchForm'; 4 | 5 | describe('SearchForm', () => { 6 | it('should be rendered correctly with empty query', () => { 7 | const tree = renderer 8 | .create() 9 | .toJSON(); 10 | expect(tree).toMatchSnapshot(); 11 | }); 12 | 13 | it('should be rendered correctly with non-empty query', () => { 14 | const tree = renderer 15 | .create() 16 | .toJSON(); 17 | expect(tree).toMatchSnapshot(); 18 | }); 19 | 20 | it('should fire onSearchSubmit form callback', () => { 21 | // Mock search form parameters. 22 | const searchQuery = 'kittens'; 23 | const onSearchSubmit = jest.fn(); 24 | 25 | // Create test component instance. 26 | const testComponentInstance = renderer.create(( 27 | 28 | )).root; 29 | 30 | // Try to find submit button inside the form. 31 | const submitButtonInstance = testComponentInstance.findByProps({ 32 | type: 'submit', 33 | }); 34 | expect(submitButtonInstance).toBeDefined(); 35 | 36 | // Since we're not going to test the button component itself 37 | // we may just simulate its onClick event manually. 38 | const eventMock = { preventDefault: jest.fn() }; 39 | submitButtonInstance.props.onClick(eventMock); 40 | 41 | expect(onSearchSubmit).toHaveBeenCalledTimes(1); 42 | expect(onSearchSubmit).toHaveBeenCalledWith(searchQuery); 43 | }); 44 | 45 | it('should not fire onSearchSubmit form callback when request is empty', () => { 46 | // Mock search form parameters. 47 | const searchQuery = ''; 48 | const onSearchSubmit = jest.fn(); 49 | 50 | // Create test component instance. 51 | const testComponentInstance = renderer.create(( 52 | 53 | )).root; 54 | 55 | // Try to find submit button inside the form. 56 | const submitButtonInstance = testComponentInstance.findByProps({ 57 | type: 'submit', 58 | }); 59 | expect(submitButtonInstance).toBeDefined(); 60 | 61 | // Since we're not going to test the button component itself 62 | // we may just simulate its onClick event manually. 63 | const eventMock = { preventDefault: jest.fn() }; 64 | submitButtonInstance.props.onClick(eventMock); 65 | 66 | expect(onSearchSubmit).not.toHaveBeenCalled(); 67 | }); 68 | 69 | it('should fire onSearchReset form callback', () => { 70 | // Mock search form parameters. 71 | const searchQuery = 'kittens'; 72 | const onSearchReset = jest.fn(); 73 | 74 | // Create test component instance. 75 | const testComponentInstance = renderer.create(( 76 | 77 | )).root; 78 | 79 | // Try to find reset button inside the form. 80 | const resetButtonInstance = testComponentInstance.findByProps({ 81 | type: 'button', 82 | }); 83 | expect(resetButtonInstance).toBeDefined(); 84 | 85 | const eventMock = { preventDefault: jest.fn() }; 86 | resetButtonInstance.props.onClick(eventMock); 87 | 88 | expect(onSearchReset).toHaveBeenCalledTimes(1); 89 | }); 90 | 91 | it('should fire onChange form callback', () => { 92 | // Mock search form parameters. 93 | const initialSearchQuery = 'kittens'; 94 | const updatedSearchQuery = 'dogs'; 95 | const onSearchUpdate = jest.fn(); 96 | 97 | // Create test component instance. 98 | const testComponentInstance = renderer.create(( 99 | 100 | )).root; 101 | 102 | // Try to find search input inside the form. 103 | const searchInputInstance = testComponentInstance.findByProps({ 104 | type: 'search', 105 | }); 106 | expect(searchInputInstance).toBeDefined(); 107 | 108 | const eventMock = { 109 | preventDefault: jest.fn(), 110 | target: { 111 | value: updatedSearchQuery, 112 | }, 113 | }; 114 | searchInputInstance.props.onChange(eventMock); 115 | 116 | expect(onSearchUpdate).toHaveBeenCalledTimes(1); 117 | expect(onSearchUpdate).toHaveBeenLastCalledWith(updatedSearchQuery); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/components/searchForm/__tests__/__snapshots__/SearchForm.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchForm should be rendered correctly with empty query 1`] = ` 4 |
8 |
11 | 22 |
25 | 35 |
36 |
37 |
38 | `; 39 | 40 | exports[`SearchForm should be rendered correctly with non-empty query 1`] = ` 41 |
45 |
48 | 59 |
62 | 72 |
73 |
76 | 86 |
87 |
88 |
89 | `; 90 | -------------------------------------------------------------------------------- /src/components/searchForm/searchForm.css: -------------------------------------------------------------------------------- 1 | .search-input:focus, 2 | .search-submit:focus { 3 | border-color: #cccccc; 4 | box-shadow: 0 0 0 0.15rem rgba(200, 200, 200, .25) 5 | } 6 | 7 | .search-reset { 8 | border-top: 1px solid #ced4da; 9 | border-bottom: 1px solid #ced4da; 10 | border-left: 1px solid #ced4da; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/searchItem/SearchItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { gifEntityPropType } from '../../types/giphyTypes'; 3 | import './searchItem.css'; 4 | 5 | export class SearchItem extends React.Component { 6 | static propTypes = { 7 | item: gifEntityPropType.isRequired, 8 | }; 9 | 10 | render() { 11 | const { item } = this.props; 12 | 13 | return ( 14 |
15 | 16 | {item.title} 23 | 24 |
25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/searchItem/__tests__/SearchItem.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { SearchItem } from '../SearchItem'; 4 | import searchResultsMock from '../../../mocks/searchResults'; 5 | 6 | describe('SearchItem', () => { 7 | it('should be rendered correctly', () => { 8 | const gifEntity = searchResultsMock.data[0]; 9 | const tree = renderer 10 | .create() 11 | .toJSON(); 12 | expect(tree).toMatchSnapshot(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/searchItem/__tests__/__snapshots__/SearchItem.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchItem should be rendered correctly 1`] = ` 4 |
7 | 10 | k GIF 17 | 18 |
19 | `; 20 | -------------------------------------------------------------------------------- /src/components/searchItem/searchItem.css: -------------------------------------------------------------------------------- 1 | .search-item { 2 | box-shadow: 0 0 0 rgba(0, 0, 0, 0); 3 | transition: box-shadow 200ms; 4 | } 5 | 6 | .search-item:hover { 7 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); 8 | } 9 | -------------------------------------------------------------------------------- /src/components/searchPage/SearchPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SearchResultsContainerConnected from '../../containers/searchResultsContainer/SearchResultsContainer'; 3 | import SearchSummaryContainerConnected from '../../containers/searchSummaryContainer/SearchSummaryContainer'; 4 | import SearchViewTabsContainerConnected from '../../containers/searchViewTabsContainer/SearchViewTabsContainer'; 5 | 6 | export class SearchPage extends React.Component { 7 | render() { 8 | return ( 9 | 10 |
11 |
12 | 13 |
14 |
15 | 16 |
17 |
18 | 19 |
20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/searchPage/__tests__/SearchPage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { SearchPage } from '../SearchPage'; 4 | 5 | jest.mock( 6 | '../../../containers/searchResultsContainer/SearchResultsContainer', 7 | () => 'SearchResultsContainer', 8 | ); 9 | 10 | jest.mock( 11 | '../../../containers/searchSummaryContainer/SearchSummaryContainer', 12 | () => 'SearchSummaryContainer', 13 | ); 14 | 15 | jest.mock( 16 | '../../../containers/searchViewTabsContainer/SearchViewTabsContainer', 17 | () => 'SearchViewTabsContainer', 18 | ); 19 | 20 | describe('SearchPage', () => { 21 | it('should be rendered correctly', () => { 22 | const tree = renderer 23 | .create() 24 | .toJSON(); 25 | 26 | expect(tree).toMatchSnapshot(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/searchPage/__tests__/__snapshots__/SearchPage.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchPage should be rendered correctly 1`] = ` 4 | Array [ 5 |
8 |
11 | 12 |
13 |
16 | 17 |
18 |
, 19 | , 20 | ] 21 | `; 22 | -------------------------------------------------------------------------------- /src/components/searchResults/SearchResults.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { SearchItem } from '../searchItem/SearchItem'; 4 | import { Spinner } from '../shared/spinner/Spinner'; 5 | import { MasonryGrid } from '../shared/masonryGrid/MasonryGrid'; 6 | import { DEFAULT_COLUMNS_NUM, SUPPORTED_COLUMNS_NUMS } from '../../services/LayoutService'; 7 | 8 | export class SearchResults extends React.Component { 9 | static propTypes = { 10 | searchItems: PropTypes.arrayOf(PropTypes.object), 11 | isLoading: PropTypes.bool, 12 | columnsNum: PropTypes.oneOf(SUPPORTED_COLUMNS_NUMS), 13 | }; 14 | 15 | static defaultProps = { 16 | searchItems: [], 17 | isLoading: false, 18 | columnsNum: DEFAULT_COLUMNS_NUM, 19 | }; 20 | 21 | renderSearchItem = searchItem => ( 22 | 23 | ); 24 | 25 | render() { 26 | const { searchItems, isLoading, columnsNum } = this.props; 27 | 28 | if (isLoading) { 29 | return ; 30 | } 31 | 32 | const gridItems = searchItems.map(searchItem => ({ 33 | height: parseInt(searchItem.images.fixed_width.height, 10), 34 | content: searchItem, 35 | })); 36 | 37 | return ( 38 |
39 | 44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/searchResults/__tests__/SearchResults.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { SearchResults } from '../SearchResults'; 4 | import searchResultsMock from '../../../mocks/searchResults'; 5 | 6 | jest.mock('../../searchItem/SearchItem', () => ({ 7 | SearchItem: 'SearchItem', 8 | })); 9 | 10 | jest.mock('../../shared/spinner/Spinner', () => ({ 11 | Spinner: 'Spinner', 12 | })); 13 | 14 | jest.mock('../../shared/masonryGrid/MasonryGrid', () => ({ 15 | MasonryGrid: 'MasonryGrid', 16 | })); 17 | 18 | describe('SearchResults', () => { 19 | it('should be rendered correctly with empty results array', () => { 20 | const tree = renderer 21 | .create() 22 | .toJSON(); 23 | expect(tree).toMatchSnapshot(); 24 | }); 25 | 26 | it('should be rendered correctly when it is not loading', () => { 27 | const gifs = searchResultsMock.data; 28 | const tree = renderer 29 | .create() 30 | .toJSON(); 31 | expect(tree).toMatchSnapshot(); 32 | }); 33 | 34 | it('should be rendered correctly when it is loading', () => { 35 | const gifs = searchResultsMock.data; 36 | const tree = renderer 37 | .create() 38 | .toJSON(); 39 | expect(tree).toMatchSnapshot(); 40 | }); 41 | 42 | it('should be rendered correctly when column numbers specified', () => { 43 | const gifs = searchResultsMock.data; 44 | const tree = renderer 45 | .create() 46 | .toJSON(); 47 | expect(tree).toMatchSnapshot(); 48 | }); 49 | 50 | it('should render search item correctly', () => { 51 | const gifs = searchResultsMock.data; 52 | 53 | const componentInstance = renderer 54 | .create() 55 | .getInstance(); 56 | 57 | const searchItemTree = renderer 58 | .create(componentInstance.renderSearchItem(gifs[0])) 59 | .toJSON(); 60 | 61 | expect(searchItemTree).toMatchSnapshot(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/components/searchSummary/SearchSummary.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export class SearchSummary extends React.Component { 5 | static propTypes = { 6 | total: PropTypes.number, 7 | }; 8 | 9 | static defaultProps = { 10 | total: null, 11 | }; 12 | 13 | render() { 14 | const { total } = this.props; 15 | 16 | if (total === null) { 17 | return null; 18 | } 19 | 20 | return ( 21 |
22 | 23 | Total results: 24 |   25 | 26 | {total} 27 | 28 | 29 |
30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/searchSummary/__tests__/SearchSummary.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { SearchSummary } from '../SearchSummary'; 4 | 5 | describe('SearchSummary', () => { 6 | it('should be rendered correctly', () => { 7 | const tree = renderer 8 | .create() 9 | .toJSON(); 10 | 11 | expect(tree).toMatchSnapshot(); 12 | }); 13 | 14 | it('should not be rendered if there are no search items', () => { 15 | const tree = renderer 16 | .create() 17 | .toJSON(); 18 | 19 | expect(tree).toMatchSnapshot(); 20 | }); 21 | 22 | it('should be rendered if nothing was found', () => { 23 | const tree = renderer 24 | .create() 25 | .toJSON(); 26 | 27 | expect(tree).toMatchSnapshot(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/searchSummary/__tests__/__snapshots__/SearchSummary.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchSummary should be rendered correctly 1`] = ` 4 |
7 | 8 | Total results:   9 | 12 | 42 13 | 14 | 15 |
16 | `; 17 | 18 | exports[`SearchSummary should be rendered if nothing was found 1`] = ` 19 |
22 | 23 | Total results:   24 | 27 | 0 28 | 29 | 30 |
31 | `; 32 | 33 | exports[`SearchSummary should not be rendered if there are no search items 1`] = `null`; 34 | -------------------------------------------------------------------------------- /src/components/searchViewTabs/SearchViewTabs.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { SUPPORTED_COLUMNS_NUMS } from '../../services/LayoutService'; 4 | import './searchViewTabs.css'; 5 | 6 | const columnButtons = [ 7 | { columns: 1, title: 'Show in 1 column', icon: 'oi oi-list' }, 8 | { columns: 3, title: 'Show in 3 columns', icon: 'oi oi-grid-three-up' }, 9 | { columns: 4, title: 'Show in 4 columns', icon: 'oi oi-grid-four-up' }, 10 | ]; 11 | 12 | export class SearchViewTabs extends React.Component { 13 | static propTypes = { 14 | columnsNum: PropTypes.oneOf(SUPPORTED_COLUMNS_NUMS).isRequired, 15 | onColumnsNumChange: PropTypes.func.isRequired, 16 | }; 17 | 18 | onButtonClick = (columnsNum) => { 19 | const { onColumnsNumChange } = this.props; 20 | onColumnsNumChange(columnsNum); 21 | }; 22 | 23 | render() { 24 | const { columnsNum } = this.props; 25 | 26 | const buttons = columnButtons.map((columnButton) => { 27 | const buttonClass = columnButton.columns === columnsNum 28 | ? 'btn btn-dark tab-selector-button' 29 | : 'btn btn-light tab-selector-button'; 30 | 31 | return ( 32 | 41 | ); 42 | }); 43 | 44 | return ( 45 |
46 | {buttons} 47 |
48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/components/searchViewTabs/__tests__/SearchViewTabs.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { SearchViewTabs } from '../SearchViewTabs'; 4 | 5 | describe('SearchViewTabs', () => { 6 | it('should be rendered correctly by default', () => { 7 | const onColumnsNumChange = jest.fn(); 8 | 9 | const tree = renderer 10 | .create(( 11 | 15 | )) 16 | .toJSON(); 17 | 18 | expect(tree).toMatchSnapshot(); 19 | }); 20 | 21 | it('should fire onColumnsNumbChange callback', () => { 22 | const onColumnsNumChange = jest.fn(); 23 | 24 | const component = renderer 25 | .create(( 26 | 30 | )).root; 31 | 32 | const buttons = component.findAllByType('button'); 33 | 34 | expect(onColumnsNumChange).not.toHaveBeenCalled(); 35 | expect(buttons).toBeDefined(); 36 | 37 | // Imitate button clicks. 38 | buttons[0].props.onClick(); 39 | buttons[1].props.onClick(); 40 | buttons[2].props.onClick(); 41 | 42 | expect(onColumnsNumChange).toHaveBeenCalledTimes(3); 43 | expect(onColumnsNumChange.mock.calls[0][0]).toBe(1); 44 | expect(onColumnsNumChange.mock.calls[1][0]).toBe(3); 45 | expect(onColumnsNumChange.mock.calls[2][0]).toBe(4); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/searchViewTabs/__tests__/__snapshots__/SearchViewTabs.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchViewTabs should be rendered correctly by default 1`] = ` 4 |
7 | 17 | 27 | 37 |
38 | `; 39 | -------------------------------------------------------------------------------- /src/components/searchViewTabs/searchViewTabs.css: -------------------------------------------------------------------------------- 1 | .tab-selector-button:focus { 2 | border-color: #cccccc; 3 | box-shadow: 0 0 0 0.15rem rgba(200, 200, 200, .25) 4 | } 5 | -------------------------------------------------------------------------------- /src/components/shared/header/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, withRouter } from 'react-router-dom'; 3 | import SearchFormContainerConnected from '../../../containers/searchFormContainer/SearchFormContainer'; 4 | import './header.css'; 5 | 6 | class HeaderRaw extends React.Component { 7 | render() { 8 | return ( 9 |
10 |

11 | 12 | GiphyGram 13 | 14 |

15 |
16 | 17 |
18 |
19 | ); 20 | } 21 | } 22 | 23 | export const Header = withRouter(HeaderRaw); 24 | -------------------------------------------------------------------------------- /src/components/shared/header/__tests__/Header.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MemoryRouter } from 'react-router-dom'; 3 | import renderer from 'react-test-renderer'; 4 | import { Header } from '../Header'; 5 | 6 | jest.mock( 7 | '../../../../containers/searchFormContainer/SearchFormContainer', 8 | () => 'SearchFormContainerConnected', 9 | ); 10 | 11 | describe('Header', () => { 12 | it('should be rendered correctly', () => { 13 | const component = renderer.create(( 14 | 15 |
16 | 17 | )); 18 | const tree = component.toJSON(); 19 | expect(tree).toMatchSnapshot(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/shared/header/__tests__/__snapshots__/Header.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Header should be rendered correctly 1`] = ` 4 |
5 |

8 | 13 | GiphyGram 14 | 15 |

16 |
19 | 20 |
21 |
22 | `; 23 | -------------------------------------------------------------------------------- /src/components/shared/header/header.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | font-family: 'Gloria Hallelujah', cursive; 3 | } 4 | 5 | .logo a { 6 | text-decoration: none; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/shared/infiniteScroll/InfiniteScroll.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const DEFAULT_ACTIVATION_DISTANCE = 50; 5 | 6 | export class InfiniteScroll extends React.Component { 7 | static propTypes = { 8 | children: PropTypes.oneOfType([ 9 | PropTypes.arrayOf(PropTypes.node), 10 | PropTypes.node, 11 | ]).isRequired, 12 | onFetchMore: PropTypes.func, 13 | activationDistance: PropTypes.number, 14 | }; 15 | 16 | static defaultProps = { 17 | onFetchMore: () => {}, 18 | activationDistance: DEFAULT_ACTIVATION_DISTANCE, 19 | }; 20 | 21 | componentDidMount() { 22 | window.addEventListener('scroll', this.onScroll, false); 23 | } 24 | 25 | componentWillUnmount() { 26 | window.removeEventListener('scroll', this.onScroll, false); 27 | } 28 | 29 | onScroll = () => { 30 | const { activationDistance, onFetchMore } = this.props; 31 | 32 | const documentHeight = document.body.offsetHeight; 33 | const scrollHeight = window.innerHeight + window.scrollY; 34 | 35 | if ((scrollHeight + activationDistance) >= documentHeight) { 36 | onFetchMore(); 37 | } 38 | }; 39 | 40 | render() { 41 | const { children } = this.props; 42 | 43 | return ( 44 |
45 | {children} 46 |
47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/shared/infiniteScroll/__tests__/InfiniteScroll.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { InfiniteScroll } from '../InfiniteScroll'; 4 | 5 | // Create a spy on window.addEventListener() and removeEventListener() functions. 6 | global.addEventListener = jest.fn(); 7 | global.removeEventListener = jest.fn(); 8 | 9 | describe('InfiniteScroll', () => { 10 | // We need scrollCallback to imitate onScroll window event. 11 | let onScrollCallback; 12 | const onScrollCallbackRemove = jest.fn(); 13 | 14 | beforeEach(() => { 15 | // Mock window.addEventListener implementation to be able to fire onScroll callbacks. 16 | global.addEventListener.mockImplementation((eventName, callback, useCapture) => { 17 | onScrollCallback = callback; 18 | }); 19 | 20 | // Mock window.addEventListener implementation to be able to fire onScroll callbacks. 21 | global.removeEventListener.mockImplementation((eventName, callback, useCapture) => { 22 | if (eventName === 'scroll') { 23 | onScrollCallbackRemove(); 24 | } 25 | }); 26 | }); 27 | 28 | afterEach(() => { 29 | jest.clearAllMocks(); 30 | }); 31 | 32 | it('should render one child component correctly', () => { 33 | const tree = renderer 34 | .create(( 35 | 36 |
Child #1
37 |
38 | )) 39 | .toJSON(); 40 | 41 | expect(tree).toMatchSnapshot(); 42 | }); 43 | 44 | it('should render many children components correctly', () => { 45 | const tree = renderer 46 | .create(( 47 | 48 |
Child #1
49 |
Child #2
50 |
51 | )) 52 | .toJSON(); 53 | 54 | expect(tree).toMatchSnapshot(); 55 | }); 56 | 57 | it('should fire onFetchMore callback', () => { 58 | // Mock onFetchMore function. 59 | const onFetchMore = jest.fn(); 60 | 61 | // Let's render component. 62 | renderer.create(( 63 | 64 |
Child
65 |
66 | )); 67 | 68 | // Check callbacks after component is rendered. 69 | expect(onScrollCallback).toBeDefined(); 70 | expect(onFetchMore).not.toHaveBeenCalled(); 71 | 72 | // Imitate window onScroll event. 73 | onScrollCallback(); 74 | expect(onFetchMore).toHaveBeenCalledTimes(1); 75 | 76 | // Try to reduce inner height to prevent onScroll callback from calling. 77 | // @TODO: Try to set up real values for document.body.offsetHeight. 78 | // document.body.offsetHeight = 42; 79 | global.innerHeight = -1; 80 | global.scrollY = 0; 81 | 82 | onScrollCallback(); 83 | expect(onFetchMore).toHaveBeenCalledTimes(1); 84 | }); 85 | 86 | it('should remove onScroll listener', () => { 87 | const testComponent = renderer.create(( 88 | 89 |
Child
90 |
91 | )); 92 | 93 | expect(global.addEventListener).toHaveBeenCalled(); 94 | expect(onScrollCallbackRemove).not.toHaveBeenCalled(); 95 | 96 | testComponent.unmount(); 97 | 98 | expect(onScrollCallbackRemove).toHaveBeenCalled(); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/components/shared/infiniteScroll/__tests__/__snapshots__/InfiniteScroll.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`InfiniteScroll should render many children components correctly 1`] = ` 4 |
5 |
6 | Child #1 7 |
8 |
9 | Child #2 10 |
11 |
12 | `; 13 | 14 | exports[`InfiniteScroll should render one child component correctly 1`] = ` 15 |
16 |
17 | Child #1 18 |
19 |
20 | `; 21 | -------------------------------------------------------------------------------- /src/components/shared/layout/Layout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { SCREEN_SIZES } from '../../../services/LayoutService'; 4 | 5 | // Closure callback that fires when media has been changed. 6 | const onMediaQueryChangeCallback = (screenSize, callback) => (e) => { 7 | if (e.matches) { 8 | callback(screenSize.id); 9 | } 10 | }; 11 | 12 | export class Layout extends React.Component { 13 | static propTypes = { 14 | children: PropTypes.oneOfType([ 15 | PropTypes.arrayOf(PropTypes.node), 16 | PropTypes.node, 17 | ]).isRequired, 18 | onMediaQueryChange: PropTypes.func, 19 | onMediaQueryInit: PropTypes.func, 20 | }; 21 | 22 | static defaultProps = { 23 | onMediaQueryChange: () => {}, 24 | onMediaQueryInit: () => {}, 25 | }; 26 | 27 | constructor(props) { 28 | super(props); 29 | 30 | // List of all listeners to media query changes. 31 | this.mediaQueries = {}; 32 | } 33 | 34 | componentDidMount() { 35 | const { onMediaQueryChange, onMediaQueryInit } = this.props; 36 | let initialScreenSize = null; 37 | 38 | Object 39 | .values(SCREEN_SIZES) 40 | .forEach((screenSize) => { 41 | // Try to match current media query. 42 | this.mediaQueries[screenSize.id] = window.matchMedia(screenSize.mediaQuery); 43 | if (this.mediaQueries[screenSize.id].matches) { 44 | initialScreenSize = screenSize; 45 | } 46 | 47 | // Subscribe to media query changes. 48 | this.mediaQueries[screenSize.id].addListener( 49 | onMediaQueryChangeCallback(screenSize, onMediaQueryChange), 50 | ); 51 | }); 52 | 53 | // Setup initial media query match. 54 | onMediaQueryInit(initialScreenSize.id); 55 | } 56 | 57 | // @TODO: Remove media query listeners on component unmount. 58 | // componentWillUnmount() { 59 | // Object.values(this.mediaQueries).forEach(mediaQuery => mediaQuery.removeListener()); 60 | // } 61 | 62 | render() { 63 | const { children } = this.props; 64 | 65 | return ( 66 |
67 |
68 |
69 | {children} 70 |
71 |
72 |
73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/components/shared/layout/__tests__/Layout.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { Layout } from '../Layout'; 4 | import { SCREEN_SIZES } from '../../../../services/LayoutService'; 5 | 6 | describe('Layout', () => { 7 | it('should render one child component inside the layout', () => { 8 | const tree = renderer 9 | .create(( 10 | 11 |
One and only child
12 |
13 | )) 14 | .toJSON(); 15 | 16 | expect(tree).toMatchSnapshot(); 17 | }); 18 | 19 | it('should render more than one child component inside the layout', () => { 20 | const tree = renderer 21 | .create(( 22 | 23 |
First child
24 |
Second child
25 |
26 | )) 27 | .toJSON(); 28 | 29 | expect(tree).toMatchSnapshot(); 30 | }); 31 | 32 | it('should fire onMediaQueryInit callback', () => { 33 | const onMediaQueryInit = jest.fn(); 34 | 35 | renderer.create(( 36 | 37 |
One and only child
38 |
39 | )); 40 | 41 | expect(onMediaQueryInit).toHaveBeenCalledTimes(1); 42 | expect(onMediaQueryInit).toHaveBeenCalledWith('xs'); 43 | }); 44 | 45 | it('should fire onMediaQueryChange callback when media query matches', () => { 46 | // Mock window.matchMedia() method. 47 | // Normally when we add listener it doesn't fire up. 48 | // But here we're fire the listener just after the subscription 49 | // for testing purpose only. 50 | global.matchMedia = jest.fn().mockImplementation(query => ({ 51 | matches: true, 52 | media: query, 53 | onchange: null, 54 | addListener: callback => callback({ matches: true }), 55 | removeListener: jest.fn(), 56 | })); 57 | 58 | const onMediaQueryChange = jest.fn(); 59 | const onMediaQueryInit = jest.fn(); 60 | 61 | renderer.create(( 62 | 66 |
One and only child
67 |
68 | )); 69 | 70 | expect(onMediaQueryInit).toHaveBeenCalledTimes(1); 71 | expect(onMediaQueryInit).toHaveBeenCalledWith('xs'); 72 | 73 | expect(onMediaQueryChange).toHaveBeenCalledTimes(Object.keys(SCREEN_SIZES).length); 74 | expect(onMediaQueryChange.mock.calls[0][0]).toEqual(SCREEN_SIZES.xl.id); 75 | expect(onMediaQueryChange.mock.calls[1][0]).toEqual(SCREEN_SIZES.lg.id); 76 | expect(onMediaQueryChange.mock.calls[2][0]).toEqual(SCREEN_SIZES.md.id); 77 | expect(onMediaQueryChange.mock.calls[3][0]).toEqual(SCREEN_SIZES.sm.id); 78 | expect(onMediaQueryChange.mock.calls[4][0]).toEqual(SCREEN_SIZES.xs.id); 79 | }); 80 | 81 | it('should not fire onMediaQueryChange callback when media query does not match', () => { 82 | // Mock window.matchMedia() method. 83 | // Normally when we add listener it doesn't fire up. 84 | // But here we're fire the listener just after the subscription 85 | // for testing purpose only. 86 | global.matchMedia = jest.fn().mockImplementation(query => ({ 87 | matches: true, 88 | media: query, 89 | onchange: null, 90 | addListener: callback => callback({ matches: false }), 91 | removeListener: jest.fn(), 92 | })); 93 | 94 | const onMediaQueryChange = jest.fn(); 95 | const onMediaQueryInit = jest.fn(); 96 | 97 | renderer.create(( 98 | 102 |
One and only child
103 |
104 | )); 105 | 106 | expect(onMediaQueryInit).toHaveBeenCalledTimes(1); 107 | expect(onMediaQueryInit).toHaveBeenCalledWith('xs'); 108 | 109 | expect(onMediaQueryChange).not.toHaveBeenCalled(); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/components/shared/layout/__tests__/__snapshots__/Layout.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Layout should render more than one child component inside the layout 1`] = ` 4 |
7 |
10 |
13 |
14 | First child 15 |
16 |
17 | Second child 18 |
19 |
20 |
21 |
22 | `; 23 | 24 | exports[`Layout should render one child component inside the layout 1`] = ` 25 |
28 |
31 |
34 |
35 | One and only child 36 |
37 |
38 |
39 |
40 | `; 41 | -------------------------------------------------------------------------------- /src/components/shared/masonryGrid/MasonryGrid.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { SUPPORTED_COLUMNS_NUMS, TOTAL_NUMBER_OF_LAYOUT_COLUMNS } from '../../../services/LayoutService'; 4 | 5 | export class MasonryGrid extends React.Component { 6 | static propTypes = { 7 | renderItem: PropTypes.func.isRequired, 8 | columnsNum: PropTypes.oneOf(SUPPORTED_COLUMNS_NUMS).isRequired, 9 | items: PropTypes.arrayOf(PropTypes.shape({ 10 | height: PropTypes.number, 11 | content: PropTypes.object, 12 | })), 13 | }; 14 | 15 | static defaultProps = { 16 | items: [], 17 | }; 18 | 19 | render() { 20 | const { items, columnsNum, renderItem } = this.props; 21 | 22 | // Array that will store the height of each column. 23 | // It is used to keep column heights similar. 24 | const columnsSizes = new Array(columnsNum).fill(0); 25 | 26 | // Array that will store items that are split by columns according to their heights. 27 | const itemsPartitions = new Array(columnsNum) 28 | .fill(null) 29 | .map(() => []); 30 | 31 | // Fill partitions with items. 32 | items.forEach((item) => { 33 | // Get next smallest column to place the icon into. 34 | const itemColumnIndex = columnsSizes.indexOf(Math.min(...columnsSizes)); 35 | // Increase the size of the column by item height. 36 | columnsSizes[itemColumnIndex] += item.height; 37 | // Put item to the proper partition. 38 | itemsPartitions[itemColumnIndex].push(item.content); 39 | }); 40 | 41 | // Generate columns with search items inside. 42 | const columns = itemsPartitions.map((partition, partitionIndex) => { 43 | /* eslint-disable react/no-array-index-key */ 44 | 45 | // Calculate Bootstrap class depending on the columns num. 46 | const columnClass = `col-${TOTAL_NUMBER_OF_LAYOUT_COLUMNS / columnsNum}`; 47 | 48 | // Generate column entities. 49 | const columnElements = partition.map( 50 | (item, index) => renderItem(item, index), 51 | ); 52 | 53 | // Wrap column entities into column wrapper. 54 | return ( 55 |
56 | {columnElements} 57 |
58 | ); 59 | }); 60 | 61 | return ( 62 |
63 | {columns} 64 |
65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/components/shared/masonryGrid/__tests__/MasonryGrid.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { MasonryGrid } from '../MasonryGrid'; 4 | 5 | const itemsMock = [ 6 | { 7 | height: 100, 8 | content: { id: 1, name: 'Item #1' }, 9 | }, 10 | { 11 | height: 200, 12 | content: { id: 2, name: 'Item #2' }, 13 | }, 14 | { 15 | height: 150, 16 | content: { id: 3, name: 'Item #3' }, 17 | }, 18 | { 19 | height: 250, 20 | content: { id: 4, name: 'Item #4' }, 21 | }, 22 | { 23 | height: 300, 24 | content: { id: 5, name: 'Item #5' }, 25 | }, 26 | ]; 27 | 28 | const renderItemMock = item =>
{item.name}
; 29 | 30 | describe('MasonryGrid', () => { 31 | it('should be rendered correctly with 1 column', () => { 32 | const tree = renderer 33 | .create(( 34 | 39 | )) 40 | .toJSON(); 41 | 42 | expect(tree).toMatchSnapshot(); 43 | }); 44 | 45 | it('should be rendered correctly with 3 columns', () => { 46 | const tree = renderer 47 | .create(( 48 | 53 | )) 54 | .toJSON(); 55 | 56 | expect(tree).toMatchSnapshot(); 57 | }); 58 | 59 | it('should be rendered correctly with 4 columns', () => { 60 | const tree = renderer 61 | .create(( 62 | 67 | )) 68 | .toJSON(); 69 | 70 | expect(tree).toMatchSnapshot(); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/components/shared/masonryGrid/__tests__/__snapshots__/MasonryGrid.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`MasonryGrid should be rendered correctly with 1 column 1`] = ` 4 |
7 |
10 |
11 | Item #1 12 |
13 |
14 | Item #2 15 |
16 |
17 | Item #3 18 |
19 |
20 | Item #4 21 |
22 |
23 | Item #5 24 |
25 |
26 |
27 | `; 28 | 29 | exports[`MasonryGrid should be rendered correctly with 3 columns 1`] = ` 30 |
33 |
36 |
37 | Item #1 38 |
39 |
40 | Item #4 41 |
42 |
43 |
46 |
47 | Item #2 48 |
49 |
50 |
53 |
54 | Item #3 55 |
56 |
57 | Item #5 58 |
59 |
60 |
61 | `; 62 | 63 | exports[`MasonryGrid should be rendered correctly with 4 columns 1`] = ` 64 |
67 |
70 |
71 | Item #1 72 |
73 |
74 | Item #5 75 |
76 |
77 |
80 |
81 | Item #2 82 |
83 |
84 |
87 |
88 | Item #3 89 |
90 |
91 |
94 |
95 | Item #4 96 |
97 |
98 |
99 | `; 100 | -------------------------------------------------------------------------------- /src/components/shared/spinner/Spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './spinner.css'; 3 | 4 | export class Spinner extends React.Component { 5 | render() { 6 | return ( 7 |
8 |
9 |
10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/shared/spinner/__tests__/Spinner.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { Spinner } from '../Spinner'; 4 | 5 | describe('Spinner', () => { 6 | it('should be rendered correctly', () => { 7 | const tree = renderer 8 | .create() 9 | .toJSON(); 10 | expect(tree).toMatchSnapshot(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/shared/spinner/__tests__/__snapshots__/Spinner.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Spinner should be rendered correctly 1`] = ` 4 |
7 |
10 |
11 | `; 12 | -------------------------------------------------------------------------------- /src/components/shared/spinner/spinner.css: -------------------------------------------------------------------------------- 1 | .spinner-container { 2 | margin-top: 50px; 3 | margin-bottom: 30px; 4 | } 5 | 6 | .spinner { 7 | display: inline-block; 8 | width: 64px; 9 | height: 64px; 10 | } 11 | 12 | .spinner:after { 13 | content: " "; 14 | display: block; 15 | width: 46px; 16 | height: 46px; 17 | margin: 1px; 18 | border-radius: 50%; 19 | border: 5px solid #343a40; 20 | border-color: #343a40 transparent #343a40 transparent; 21 | animation: spinner 1.2s linear infinite; 22 | } 23 | 24 | @keyframes spinner { 25 | 0% { 26 | transform: rotate(0deg); 27 | } 28 | 100% { 29 | transform: rotate(360deg); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | // Shortcut for environment variables container. 2 | const env = { ...process.env }; 3 | 4 | // Base URL of the App. It allows putting the APP into sub-folders on the server. 5 | export const APP_BASE_URL = env.REACT_APP_BASE_URL; 6 | 7 | // GIPHY API key. 8 | export const GIPHY_API_KEY = env.REACT_APP_GIPHY_API_KEY; 9 | 10 | // GIPHY API Host. 11 | export const GIPHY_API_HOST = env.REACT_APP_GIPHY_API_HOST; 12 | 13 | // HTTP request timeout in milliseconds. 14 | export const REQUEST_TIMEOUT = env.HTTP_REQUEST_TIMEOUT; 15 | 16 | // How many Gif images we want to request per each HTTP request. 17 | export const SEARCH_BATCH_SIZE = 30; 18 | -------------------------------------------------------------------------------- /src/containers/layoutContainer/LayoutContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { Layout } from '../../components/shared/layout/Layout'; 5 | import { layoutResize } from '../../actions/layoutActions'; 6 | 7 | export class LayoutContainer extends React.Component { 8 | static propTypes = { 9 | children: PropTypes.oneOfType([ 10 | PropTypes.arrayOf(PropTypes.node), 11 | PropTypes.node, 12 | ]).isRequired, 13 | layoutResize: PropTypes.func.isRequired, 14 | }; 15 | 16 | onMediaQueryChange = (screenSize) => { 17 | const { layoutResize: layoutResizeCallback } = this.props; 18 | layoutResizeCallback(screenSize); 19 | }; 20 | 21 | render() { 22 | const { children } = this.props; 23 | 24 | return ( 25 | 29 | {children} 30 | 31 | ); 32 | } 33 | } 34 | 35 | const mapDispatchToProps = { 36 | layoutResize, 37 | }; 38 | 39 | export default connect( 40 | null, 41 | mapDispatchToProps, 42 | )(LayoutContainer); 43 | -------------------------------------------------------------------------------- /src/containers/layoutContainer/__tests__/LayoutContainer.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { LayoutContainer } from '../LayoutContainer'; 4 | 5 | jest.mock('../../../components/shared/layout/Layout', () => ({ 6 | Layout: 'Layout', 7 | })); 8 | 9 | describe('LayoutContainer', () => { 10 | it('should be rendered correctly by default with one child', () => { 11 | const layoutResize = jest.fn(); 12 | 13 | const tree = renderer 14 | .create(( 15 | 16 |
Child
17 |
18 | )) 19 | .toJSON(); 20 | 21 | expect(tree).toMatchSnapshot(); 22 | }); 23 | 24 | it('should be rendered correctly by default with several children', () => { 25 | const layoutResize = jest.fn(); 26 | 27 | const tree = renderer 28 | .create(( 29 | 30 |
Child #1
31 |
Child #2
32 |
33 | )) 34 | .toJSON(); 35 | 36 | expect(tree).toMatchSnapshot(); 37 | }); 38 | 39 | it('should fire layoutResize callback', () => { 40 | const layoutResize = jest.fn(); 41 | 42 | const layoutContainerComponent = renderer 43 | .create(( 44 | 45 |
Child #1
46 |
Child #2
47 |
48 | )).root; 49 | 50 | expect(layoutResize).not.toHaveBeenCalled(); 51 | 52 | const layoutComponent = layoutContainerComponent.findByType('Layout'); 53 | expect(layoutComponent).toBeDefined(); 54 | 55 | // Call onResize event. 56 | layoutComponent.props.onMediaQueryChange('sm'); 57 | 58 | expect(layoutResize).toHaveBeenCalledTimes(1); 59 | expect(layoutResize).toHaveBeenLastCalledWith('sm'); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/containers/layoutContainer/__tests__/__snapshots__/LayoutContainer.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`LayoutContainer should be rendered correctly by default with one child 1`] = ` 4 | 8 |
9 | Child 10 |
11 |
12 | `; 13 | 14 | exports[`LayoutContainer should be rendered correctly by default with several children 1`] = ` 15 | 19 |
20 | Child #1 21 |
22 |
23 | Child #2 24 |
25 |
26 | `; 27 | -------------------------------------------------------------------------------- /src/containers/searchFormContainer/SearchFormContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { withRouter } from 'react-router-dom'; 5 | import { SearchForm } from '../../components/searchForm/SearchForm'; 6 | import { search, searchReset } from '../../actions/searchActions'; 7 | import { updateSearchQuery } from '../../actions/searchParamsActions'; 8 | import { getSearchParamsFromState } from '../../reducers/searchParamsReducer'; 9 | import { RouterService } from '../../services/RouterService'; 10 | 11 | export class SearchFormContainer extends React.Component { 12 | static propTypes = { 13 | routerService: PropTypes.instanceOf(RouterService).isRequired, 14 | search: PropTypes.func.isRequired, 15 | searchReset: PropTypes.func.isRequired, 16 | updateSearchQuery: PropTypes.func.isRequired, 17 | query: PropTypes.string, 18 | }; 19 | 20 | static defaultProps = { 21 | query: '', 22 | }; 23 | 24 | onSearchSubmit = (query) => { 25 | const { search: searchCallback, routerService } = this.props; 26 | 27 | // Update store. 28 | searchCallback({ query }); 29 | 30 | // Update URL. 31 | routerService.pushSearchQuery(query); 32 | }; 33 | 34 | onSearchUpdate = (query) => { 35 | const { updateSearchQuery: updateSearchQueryCallback } = this.props; 36 | 37 | // Update store. 38 | updateSearchQueryCallback(query); 39 | }; 40 | 41 | onSearchReset = () => { 42 | const { searchReset: searchResetCallback, routerService } = this.props; 43 | 44 | // Update store. 45 | searchResetCallback(); 46 | 47 | // Update URL. 48 | routerService.pushSearchQuery(); 49 | }; 50 | 51 | render() { 52 | const { query } = this.props; 53 | 54 | return ( 55 | 61 | ); 62 | } 63 | } 64 | 65 | const mapStateToProps = (state, props) => ({ 66 | routerService: new RouterService(props.history, props.location), 67 | query: getSearchParamsFromState(state).query, 68 | }); 69 | 70 | const mapDispatchToProps = { 71 | search, 72 | searchReset, 73 | updateSearchQuery, 74 | }; 75 | 76 | export default withRouter(connect( 77 | mapStateToProps, 78 | mapDispatchToProps, 79 | )(SearchFormContainer)); 80 | -------------------------------------------------------------------------------- /src/containers/searchFormContainer/__tests__/SearchFormContainer.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { SearchFormContainer } from '../SearchFormContainer'; 4 | import { RouterService } from '../../../services/RouterService'; 5 | 6 | jest.mock('../../../actions/searchActions'); 7 | jest.mock('../../../actions/searchParamsActions'); 8 | jest.mock('../../../reducers/searchParamsReducer'); 9 | jest.mock('../../../services/RouterService'); 10 | jest.mock('../../../components/searchForm/SearchForm', () => ({ 11 | SearchForm: 'SearchForm', 12 | })); 13 | 14 | describe('SearchFormContainer', () => { 15 | it('should be rendered correctly', () => { 16 | const tree = renderer 17 | .create(( 18 | 25 | )) 26 | .toJSON(); 27 | 28 | expect(tree).toMatchSnapshot(); 29 | }); 30 | 31 | it('should fire callbacks', () => { 32 | const onSearch = jest.fn(); 33 | const onSearchReset = jest.fn(); 34 | const onQueryUpdate = jest.fn(); 35 | 36 | const testComponentInstance = renderer 37 | .create(( 38 | 45 | )).root; 46 | 47 | const searchFormInstance = testComponentInstance.findByType('SearchForm'); 48 | 49 | const searchQuery = 'cats'; 50 | const searchUpdatedQuery = 'dogs'; 51 | 52 | searchFormInstance.props.onSearchSubmit(searchQuery); 53 | searchFormInstance.props.onSearchUpdate(searchUpdatedQuery); 54 | searchFormInstance.props.onSearchReset(); 55 | 56 | expect(onSearch).toHaveBeenCalledTimes(1); 57 | expect(onSearch).toHaveBeenLastCalledWith({ query: searchQuery }); 58 | 59 | expect(onQueryUpdate).toHaveBeenCalledTimes(1); 60 | expect(onQueryUpdate).toHaveBeenLastCalledWith(searchUpdatedQuery); 61 | 62 | expect(onSearchReset).toHaveBeenCalledTimes(1); 63 | 64 | expect(searchFormInstance).toBeDefined(); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/containers/searchFormContainer/__tests__/__snapshots__/SearchFormContainer.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchFormContainer should be rendered correctly 1`] = ` 4 | 10 | `; 11 | -------------------------------------------------------------------------------- /src/containers/searchResultsContainer/SearchResultsContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { SearchResults } from '../../components/searchResults/SearchResults'; 5 | import { InfiniteScroll } from '../../components/shared/infiniteScroll/InfiniteScroll'; 6 | import { Spinner } from '../../components/shared/spinner/Spinner'; 7 | import { searchMore } from '../../actions/searchActions'; 8 | import { getSearchResultsFromState } from '../../reducers/searchResultsReducer'; 9 | import { getLayoutFromState } from '../../reducers/layoutReducer'; 10 | import { DEFAULT_COLUMNS_NUM, LayoutService, SUPPORTED_COLUMNS_NUMS } from '../../services/LayoutService'; 11 | 12 | export class SearchResultsContainer extends React.Component { 13 | static propTypes = { 14 | searchItems: PropTypes.arrayOf(PropTypes.object), 15 | isLoading: PropTypes.bool, 16 | isFetchingMore: PropTypes.bool, 17 | searchMore: PropTypes.func.isRequired, 18 | columnsNum: PropTypes.oneOf(SUPPORTED_COLUMNS_NUMS), 19 | }; 20 | 21 | static defaultProps = { 22 | searchItems: [], 23 | isLoading: false, 24 | isFetchingMore: false, 25 | columnsNum: DEFAULT_COLUMNS_NUM, 26 | }; 27 | 28 | onFetchMore = () => { 29 | const { isFetchingMore, isLoading, searchMore: searchMoreCallback } = this.props; 30 | 31 | if (!isFetchingMore && !isLoading) { 32 | searchMoreCallback(); 33 | } 34 | }; 35 | 36 | render() { 37 | const { 38 | searchItems, isLoading, isFetchingMore, columnsNum, 39 | } = this.props; 40 | 41 | const fetchMoreSpinner = isFetchingMore ? : null; 42 | 43 | return ( 44 | 45 | 50 | {fetchMoreSpinner} 51 | 52 | ); 53 | } 54 | } 55 | 56 | const mapStateToProps = (state) => { 57 | const searchResults = getSearchResultsFromState(state); 58 | const layout = getLayoutFromState(state); 59 | 60 | // Decide how many columns to display based on responsive width and on user selection. 61 | const responsiveColumnsNum = LayoutService.screenSizeToColumns(layout.size); 62 | const columnsNum = LayoutService.choseColumnsNum( 63 | responsiveColumnsNum, 64 | layout.manualColumnsNum, 65 | ); 66 | 67 | return { 68 | searchItems: searchResults.data || [], 69 | isLoading: searchResults.isLoading || false, 70 | isFetchingMore: searchResults.isFetchingMore || false, 71 | columnsNum, 72 | }; 73 | }; 74 | 75 | const mapDispatchToProps = { 76 | searchMore, 77 | }; 78 | 79 | export default connect( 80 | mapStateToProps, 81 | mapDispatchToProps, 82 | )(SearchResultsContainer); 83 | -------------------------------------------------------------------------------- /src/containers/searchResultsContainer/__tests__/SearchResultsContainer.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { SearchResultsContainer } from '../SearchResultsContainer'; 4 | 5 | jest.mock( 6 | '../../../components/shared/infiniteScroll/InfiniteScroll', 7 | () => ({ 8 | InfiniteScroll: 'InfiniteScroll', 9 | }), 10 | ); 11 | 12 | jest.mock( 13 | '../../../components/searchResults/SearchResults', 14 | () => ({ 15 | SearchResults: 'SearchResults', 16 | }), 17 | ); 18 | 19 | describe('SearchResultsContainer', () => { 20 | it('should be rendered correctly', () => { 21 | const searchMore = jest.fn(); 22 | 23 | const testElement = renderer.create(( 24 | 25 | )); 26 | 27 | expect(testElement.toJSON()).toMatchSnapshot(); 28 | expect(searchMore).not.toHaveBeenCalled(); 29 | }); 30 | 31 | it('should be rendered correctly with custom columns num', () => { 32 | const searchMore = jest.fn(); 33 | 34 | const testElement = renderer.create(( 35 | 36 | )); 37 | 38 | expect(testElement.toJSON()).toMatchSnapshot(); 39 | expect(searchMore).not.toHaveBeenCalled(); 40 | }); 41 | 42 | it('should fire searchMore callback', () => { 43 | const searchMore = jest.fn(); 44 | 45 | const componentInstance = renderer.create(( 46 | 47 | )).root; 48 | 49 | expect(searchMore).not.toHaveBeenCalled(); 50 | 51 | const infiniteScroller = componentInstance.findByType('InfiniteScroll'); 52 | expect(infiniteScroller).toBeDefined(); 53 | 54 | infiniteScroller.props.onFetchMore(); 55 | expect(searchMore).toHaveBeenCalledTimes(1); 56 | }); 57 | 58 | it('should not fire searchMore callback in case if loading is in progress', () => { 59 | const searchMore = jest.fn(); 60 | 61 | const componentInstance = renderer.create(( 62 | 63 | )).root; 64 | 65 | expect(searchMore).not.toHaveBeenCalled(); 66 | 67 | const infiniteScroller = componentInstance.findByType('InfiniteScroll'); 68 | expect(infiniteScroller).toBeDefined(); 69 | 70 | infiniteScroller.props.onFetchMore(); 71 | expect(searchMore).not.toHaveBeenCalled(); 72 | }); 73 | 74 | it('should not fire searchMore callback in case if fetching more is in progress', () => { 75 | const searchMore = jest.fn(); 76 | 77 | const componentInstance = renderer.create(( 78 | 79 | )).root; 80 | 81 | expect(searchMore).not.toHaveBeenCalled(); 82 | 83 | const infiniteScroller = componentInstance.findByType('InfiniteScroll'); 84 | expect(infiniteScroller).toBeDefined(); 85 | 86 | infiniteScroller.props.onFetchMore(); 87 | expect(searchMore).not.toHaveBeenCalled(); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/containers/searchResultsContainer/__tests__/__snapshots__/SearchResultsContainer.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchResultsContainer should be rendered correctly 1`] = ` 4 | 7 | 12 | 13 | `; 14 | 15 | exports[`SearchResultsContainer should be rendered correctly with custom columns num 1`] = ` 16 | 19 | 24 | 25 | `; 26 | -------------------------------------------------------------------------------- /src/containers/searchSummaryContainer/SearchSummaryContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { SearchSummary } from '../../components/searchSummary/SearchSummary'; 5 | import { getSearchResultsFromState } from '../../reducers/searchResultsReducer'; 6 | 7 | export class SearchSummaryContainer extends React.Component { 8 | static propTypes = { 9 | total: PropTypes.number, 10 | }; 11 | 12 | static defaultProps = { 13 | total: null, 14 | }; 15 | 16 | render() { 17 | const { total } = this.props; 18 | 19 | return ( 20 | 21 | ); 22 | } 23 | } 24 | 25 | const mapStateToProps = (state) => { 26 | const searchResults = getSearchResultsFromState(state); 27 | let totalCount = null; 28 | 29 | if ( 30 | searchResults.isLoading === false 31 | && searchResults.pagination 32 | && Object.prototype.hasOwnProperty.call(searchResults.pagination, 'total_count') 33 | ) { 34 | totalCount = searchResults.pagination.total_count; 35 | } 36 | 37 | return { 38 | total: totalCount, 39 | }; 40 | }; 41 | 42 | export default connect(mapStateToProps)(SearchSummaryContainer); 43 | -------------------------------------------------------------------------------- /src/containers/searchSummaryContainer/__tests__/SearchSummaryContainer.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import SearchSummaryContainerConnected, { SearchSummaryContainer } from '../SearchSummaryContainer'; 4 | 5 | jest.mock( 6 | '../../../components/searchSummary/SearchSummary', 7 | () => ({ 8 | SearchSummary: 'SearchSummary', 9 | }), 10 | ); 11 | 12 | jest.mock('react-redux', () => ({ 13 | connect: mapStateToProps => (Component) => { 14 | const state = { 15 | searchResults: { 16 | data: [], 17 | pagination: { 18 | total_count: 100, 19 | }, 20 | meta: {}, 21 | isLoading: false, 22 | isFetchingMore: false, 23 | error: null, 24 | }, 25 | }; 26 | const propsFromState = mapStateToProps(state); 27 | return () => ; 28 | }, 29 | })); 30 | 31 | describe('SearchSummaryContainer', () => { 32 | it('should be rendered correctly by default', () => { 33 | const tree = renderer 34 | .create() 35 | .toJSON(); 36 | 37 | expect(tree).toMatchSnapshot(); 38 | }); 39 | 40 | it('should be rendered and connected correctly by default', () => { 41 | const tree = renderer 42 | .create(( 43 | 44 | )) 45 | .toJSON(); 46 | 47 | expect(tree).toMatchSnapshot(); 48 | }); 49 | 50 | it('should be rendered correctly with specified total parameter', () => { 51 | const tree = renderer 52 | .create() 53 | .toJSON(); 54 | 55 | expect(tree).toMatchSnapshot(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/containers/searchSummaryContainer/__tests__/__snapshots__/SearchSummaryContainer.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchSummaryContainer should be rendered and connected correctly by default 1`] = ` 4 | 7 | `; 8 | 9 | exports[`SearchSummaryContainer should be rendered correctly by default 1`] = ` 10 | 13 | `; 14 | 15 | exports[`SearchSummaryContainer should be rendered correctly with specified total parameter 1`] = ` 16 | 19 | `; 20 | -------------------------------------------------------------------------------- /src/containers/searchViewTabsContainer/SearchViewTabsContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { SearchViewTabs } from '../../components/searchViewTabs/SearchViewTabs'; 5 | import { getLayoutFromState } from '../../reducers/layoutReducer'; 6 | import { DEFAULT_COLUMNS_NUM, LayoutService, SUPPORTED_COLUMNS_NUMS } from '../../services/LayoutService'; 7 | import { getSearchResultsFromState } from '../../reducers/searchResultsReducer'; 8 | import { setColumnsNum } from '../../actions/layoutActions'; 9 | 10 | export class SearchViewTabsContainer extends React.Component { 11 | static propTypes = { 12 | columnsNum: PropTypes.oneOf(SUPPORTED_COLUMNS_NUMS), 13 | isHidden: PropTypes.bool.isRequired, 14 | setColumnsNum: PropTypes.func.isRequired, 15 | }; 16 | 17 | static defaultProps = { 18 | columnsNum: DEFAULT_COLUMNS_NUM, 19 | }; 20 | 21 | onColumnsNumChange = (columnsNum) => { 22 | const { setColumnsNum: columnsNumCallback } = this.props; 23 | columnsNumCallback(columnsNum); 24 | }; 25 | 26 | render() { 27 | const { columnsNum, isHidden } = this.props; 28 | 29 | if (!columnsNum || isHidden) { 30 | return null; 31 | } 32 | 33 | return ( 34 | 35 | ); 36 | } 37 | } 38 | 39 | const mapStateToProps = (state) => { 40 | // Extract data from state. 41 | const layout = getLayoutFromState(state); 42 | const searchResults = getSearchResultsFromState(state); 43 | 44 | // Calculate how many columns we need to display now according to window size. 45 | const responsiveColumnsNum = LayoutService.screenSizeToColumns(layout.size); 46 | 47 | // Check whether user has already selected preferred columns number. 48 | // Manual selection has higher priority that automatic one. 49 | const columnsNum = LayoutService.choseColumnsNum( 50 | responsiveColumnsNum, 51 | layout.manualColumnsNum, 52 | ); 53 | 54 | // Decide whether we need to show layout buttons or not depending on search results. 55 | let isHidden = true; 56 | if ( 57 | searchResults.isLoading === false 58 | && searchResults.pagination 59 | && Object.prototype.hasOwnProperty.call(searchResults.pagination, 'total_count') 60 | && searchResults.pagination.total_count 61 | ) { 62 | isHidden = false; 63 | } 64 | 65 | return { 66 | columnsNum, 67 | isHidden, 68 | }; 69 | }; 70 | 71 | const mapDispatchToProps = { 72 | setColumnsNum, 73 | }; 74 | 75 | export default connect( 76 | mapStateToProps, 77 | mapDispatchToProps, 78 | )(SearchViewTabsContainer); 79 | -------------------------------------------------------------------------------- /src/containers/searchViewTabsContainer/__tests__/SearchViewTabsContainer.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import SearchViewTabsContainerConnected, { SearchViewTabsContainer } from '../SearchViewTabsContainer'; 4 | 5 | jest.mock('../../../components/searchViewTabs/SearchViewTabs', () => ({ 6 | SearchViewTabs: 'SearchViewTabs', 7 | })); 8 | 9 | jest.mock('react-redux', () => ({ 10 | connect: mapStateToProps => (Component) => { 11 | const state = { 12 | layout: { 13 | size: 'sm', 14 | }, 15 | searchResults: { 16 | data: [], 17 | pagination: { 18 | total_count: 100, 19 | }, 20 | meta: {}, 21 | isLoading: false, 22 | isFetchingMore: false, 23 | error: null, 24 | }, 25 | }; 26 | const propsFromState = mapStateToProps(state); 27 | return props => ; 28 | }, 29 | })); 30 | 31 | describe('SearchViewTabsContainer', () => { 32 | it('should be rendered correctly by default', () => { 33 | const setColumnsNum = jest.fn(); 34 | 35 | const tree = renderer 36 | .create(( 37 | 41 | )) 42 | .toJSON(); 43 | 44 | expect(tree).toMatchSnapshot(); 45 | }); 46 | 47 | it('should be rendered correctly with specified columns number', () => { 48 | const setColumnsNum = jest.fn(); 49 | 50 | const tree = renderer 51 | .create(( 52 | 57 | )) 58 | .toJSON(); 59 | 60 | expect(tree).toMatchSnapshot(); 61 | }); 62 | 63 | it('should be hidden is it is required by properties', () => { 64 | const setColumnsNum = jest.fn(); 65 | 66 | const tree = renderer 67 | .create(( 68 | 72 | )) 73 | .toJSON(); 74 | 75 | expect(tree).toMatchSnapshot(); 76 | }); 77 | 78 | it('should be rendered and connected correctly by default', () => { 79 | const setColumnsNum = jest.fn(); 80 | 81 | const tree = renderer 82 | .create(( 83 | 86 | )) 87 | .toJSON(); 88 | 89 | expect(tree).toMatchSnapshot(); 90 | }); 91 | 92 | it('should fire onColumnsNumChange callback', () => { 93 | const setColumnsNum = jest.fn(); 94 | 95 | const searchViewTabsContainerComponent = renderer 96 | .create(( 97 | 101 | )).root; 102 | 103 | const searchViewTabsComponent = searchViewTabsContainerComponent.findByType('SearchViewTabs'); 104 | 105 | expect(searchViewTabsComponent).toBeDefined(); 106 | 107 | // Emulate onColumnsNumChange event. 108 | searchViewTabsComponent.props.onColumnsNumChange(4); 109 | 110 | expect(setColumnsNum).toHaveBeenCalledTimes(1); 111 | expect(setColumnsNum).toHaveBeenCalledWith(4); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/containers/searchViewTabsContainer/__tests__/__snapshots__/SearchViewTabsContainer.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchViewTabsContainer should be hidden is it is required by properties 1`] = `null`; 4 | 5 | exports[`SearchViewTabsContainer should be rendered and connected correctly by default 1`] = ` 6 | 10 | `; 11 | 12 | exports[`SearchViewTabsContainer should be rendered correctly by default 1`] = ` 13 | 17 | `; 18 | 19 | exports[`SearchViewTabsContainer should be rendered correctly with specified columns number 1`] = ` 20 | 24 | `; 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { App } from './App'; 4 | import { store } from './store'; 5 | 6 | // Get DOM container for React application. 7 | const rootContainer = document.getElementById('root'); 8 | 9 | // Render the application. 10 | ReactDOM.render( 11 | , 12 | rootContainer, 13 | ); 14 | -------------------------------------------------------------------------------- /src/mocks/searchResults.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "gif", 5 | "id": "3o6UBlHJQT19wSgJQk", 6 | "slug": "k-dancing-3o6UBlHJQT19wSgJQk", 7 | "url": "https://giphy.com/gifs/k-dancing-3o6UBlHJQT19wSgJQk", 8 | "bitly_gif_url": "https://gph.is/1ZiPWOg", 9 | "bitly_url": "https://gph.is/1ZiPWOg", 10 | "embed_url": "https://giphy.com/embed/3o6UBlHJQT19wSgJQk", 11 | "username": "", 12 | "source": "", 13 | "rating": "g", 14 | "content_url": "", 15 | "source_tld": "", 16 | "source_post_url": "", 17 | "is_sticker": 0, 18 | "import_datetime": "2016-01-12 20:15:44", 19 | "trending_datetime": "0000-00-00 00:00:00", 20 | "images": { 21 | "fixed_height_still": { 22 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200_s.gif", 23 | "width": "267", 24 | "height": "200" 25 | }, 26 | "original_still": { 27 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy_s.gif", 28 | "width": "320", 29 | "height": "240" 30 | }, 31 | "fixed_width": { 32 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200w.gif", 33 | "width": "200", 34 | "height": "150", 35 | "size": "1028723", 36 | "mp4": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200w.mp4", 37 | "mp4_size": "33202", 38 | "webp": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200w.webp", 39 | "webp_size": "273852" 40 | }, 41 | "fixed_height_small_still": { 42 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/100_s.gif", 43 | "width": "133", 44 | "height": "100" 45 | }, 46 | "fixed_height_downsampled": { 47 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200_d.gif", 48 | "width": "267", 49 | "height": "200", 50 | "size": "163498", 51 | "webp": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200_d.webp", 52 | "webp_size": "63158" 53 | }, 54 | "preview": { 55 | "width": "256", 56 | "height": "192", 57 | "mp4": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy-preview.mp4", 58 | "mp4_size": "23776" 59 | }, 60 | "fixed_height_small": { 61 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/100.gif", 62 | "width": "133", 63 | "height": "100", 64 | "size": "516054", 65 | "mp4": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/100.mp4", 66 | "mp4_size": "20844", 67 | "webp": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/100.webp", 68 | "webp_size": "158166" 69 | }, 70 | "downsized_still": { 71 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy-downsized_s.gif", 72 | "width": "250", 73 | "height": "187", 74 | "size": "20803" 75 | }, 76 | "downsized": { 77 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy-downsized.gif", 78 | "width": "250", 79 | "height": "187", 80 | "size": "1551019" 81 | }, 82 | "downsized_large": { 83 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy.gif", 84 | "width": "320", 85 | "height": "240", 86 | "size": "2512686" 87 | }, 88 | "fixed_width_small_still": { 89 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/100w_s.gif", 90 | "width": "100", 91 | "height": "75" 92 | }, 93 | "preview_webp": { 94 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy-preview.webp", 95 | "width": "245", 96 | "height": "184", 97 | "size": "49824" 98 | }, 99 | "fixed_width_still": { 100 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200w_s.gif", 101 | "width": "200", 102 | "height": "150" 103 | }, 104 | "fixed_width_small": { 105 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/100w.gif", 106 | "width": "100", 107 | "height": "75", 108 | "size": "339068", 109 | "mp4": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/100w.mp4", 110 | "mp4_size": "14167", 111 | "webp": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/100w.webp", 112 | "webp_size": "108598" 113 | }, 114 | "downsized_small": { 115 | "width": "320", 116 | "height": "240", 117 | "mp4": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy-downsized-small.mp4", 118 | "mp4_size": "85141" 119 | }, 120 | "fixed_width_downsampled": { 121 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200w_d.gif", 122 | "width": "200", 123 | "height": "150", 124 | "size": "93903", 125 | "webp": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200w_d.webp", 126 | "webp_size": "39922" 127 | }, 128 | "downsized_medium": { 129 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy.gif", 130 | "width": "320", 131 | "height": "240", 132 | "size": "2512686" 133 | }, 134 | "original": { 135 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy.gif", 136 | "width": "320", 137 | "height": "240", 138 | "size": "2512686", 139 | "frames": "64", 140 | "mp4": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy.mp4", 141 | "mp4_size": "125289", 142 | "webp": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy.webp", 143 | "webp_size": "621576" 144 | }, 145 | "fixed_height": { 146 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200.gif", 147 | "width": "267", 148 | "height": "200", 149 | "size": "1763793", 150 | "mp4": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200.mp4", 151 | "mp4_size": "48732", 152 | "webp": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/200.webp", 153 | "webp_size": "416708" 154 | }, 155 | "looping": { 156 | "mp4": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy-loop.mp4", 157 | "mp4_size": "852512" 158 | }, 159 | "original_mp4": { 160 | "width": "480", 161 | "height": "360", 162 | "mp4": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy.mp4", 163 | "mp4_size": "125289" 164 | }, 165 | "preview_gif": { 166 | "url": "https://media0.giphy.com/media/3o6UBlHJQT19wSgJQk/giphy-preview.gif", 167 | "width": "132", 168 | "height": "99", 169 | "size": "48051" 170 | }, 171 | "480w_still": { 172 | "url": "https://media4.giphy.com/media/3o6UBlHJQT19wSgJQk/480w_s.jpg", 173 | "width": "480", 174 | "height": "360" 175 | } 176 | }, 177 | "title": "k GIF", 178 | "_score": 298.40634, 179 | "analytics": { 180 | "onload": { 181 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=3o6UBlHJQT19wSgJQk&action_type=SEEN" 182 | }, 183 | "onclick": { 184 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=3o6UBlHJQT19wSgJQk&action_type=CLICK" 185 | }, 186 | "onsent": { 187 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=3o6UBlHJQT19wSgJQk&action_type=SENT" 188 | } 189 | } 190 | }, 191 | { 192 | "type": "gif", 193 | "id": "RLCSAx4DQEJb2", 194 | "slug": "k-don-RLCSAx4DQEJb2", 195 | "url": "https://giphy.com/gifs/k-don-RLCSAx4DQEJb2", 196 | "bitly_gif_url": "https://gph.is/1ZYR9g1", 197 | "bitly_url": "https://gph.is/1ZYR9g1", 198 | "embed_url": "https://giphy.com/embed/RLCSAx4DQEJb2", 199 | "username": "", 200 | "source": "https://www.reddit.com/r/K_gifs/comments/3rg30r/don_k/", 201 | "rating": "g", 202 | "content_url": "", 203 | "source_tld": "www.reddit.com", 204 | "source_post_url": "https://www.reddit.com/r/K_gifs/comments/3rg30r/don_k/", 205 | "is_sticker": 0, 206 | "import_datetime": "2016-01-12 20:53:57", 207 | "trending_datetime": "0000-00-00 00:00:00", 208 | "images": { 209 | "fixed_height_still": { 210 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200_s.gif", 211 | "width": "356", 212 | "height": "200" 213 | }, 214 | "original_still": { 215 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy_s.gif", 216 | "width": "512", 217 | "height": "288" 218 | }, 219 | "fixed_width": { 220 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200w.gif", 221 | "width": "200", 222 | "height": "113", 223 | "size": "1608695", 224 | "mp4": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200w.mp4", 225 | "mp4_size": "19139", 226 | "webp": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200w.webp", 227 | "webp_size": "348174" 228 | }, 229 | "fixed_height_small_still": { 230 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/100_s.gif", 231 | "width": "178", 232 | "height": "100" 233 | }, 234 | "fixed_height_downsampled": { 235 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200_d.gif", 236 | "width": "356", 237 | "height": "200", 238 | "size": "255681", 239 | "webp": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200_d.webp", 240 | "webp_size": "28480" 241 | }, 242 | "preview": { 243 | "width": "512", 244 | "height": "288", 245 | "mp4": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy-preview.mp4", 246 | "mp4_size": "30164" 247 | }, 248 | "fixed_height_small": { 249 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/100.gif", 250 | "width": "178", 251 | "height": "100", 252 | "size": "1403753", 253 | "mp4": "https://media2.giphy.com/media/RLCSAx4DQEJb2/100.mp4", 254 | "mp4_size": "19023", 255 | "webp": "https://media2.giphy.com/media/RLCSAx4DQEJb2/100.webp", 256 | "webp_size": "295778" 257 | }, 258 | "downsized_still": { 259 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy-downsized_s.gif", 260 | "width": "250", 261 | "height": "140", 262 | "size": "28302" 263 | }, 264 | "downsized": { 265 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy-downsized.gif", 266 | "width": "250", 267 | "height": "140", 268 | "size": "1021046" 269 | }, 270 | "downsized_large": { 271 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy-downsized-large.gif", 272 | "width": "512", 273 | "height": "288", 274 | "size": "6511744" 275 | }, 276 | "fixed_width_small_still": { 277 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/100w_s.gif", 278 | "width": "100", 279 | "height": "56" 280 | }, 281 | "preview_webp": { 282 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy-preview.webp", 283 | "width": "318", 284 | "height": "179", 285 | "size": "48772" 286 | }, 287 | "fixed_width_still": { 288 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200w_s.gif", 289 | "width": "200", 290 | "height": "113" 291 | }, 292 | "fixed_width_small": { 293 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/100w.gif", 294 | "width": "100", 295 | "height": "56", 296 | "size": "421229", 297 | "mp4": "https://media2.giphy.com/media/RLCSAx4DQEJb2/100w.mp4", 298 | "mp4_size": "10590", 299 | "webp": "https://media2.giphy.com/media/RLCSAx4DQEJb2/100w.webp", 300 | "webp_size": "139152" 301 | }, 302 | "downsized_small": { 303 | "width": "512", 304 | "height": "288", 305 | "mp4": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy-downsized-small.mp4", 306 | "mp4_size": "86923" 307 | }, 308 | "fixed_width_downsampled": { 309 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200w_d.gif", 310 | "width": "200", 311 | "height": "113", 312 | "size": "83765", 313 | "webp": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200w_d.webp", 314 | "webp_size": "13406" 315 | }, 316 | "downsized_medium": { 317 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy-downsized-medium.gif", 318 | "width": "409", 319 | "height": "230", 320 | "size": "3871669" 321 | }, 322 | "original": { 323 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy.gif", 324 | "width": "512", 325 | "height": "288", 326 | "size": "11597306", 327 | "frames": "170", 328 | "mp4": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy.mp4", 329 | "mp4_size": "55056", 330 | "webp": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy.webp", 331 | "webp_size": "1311256" 332 | }, 333 | "fixed_height": { 334 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200.gif", 335 | "width": "356", 336 | "height": "200", 337 | "size": "5449703", 338 | "mp4": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200.mp4", 339 | "mp4_size": "42596", 340 | "webp": "https://media2.giphy.com/media/RLCSAx4DQEJb2/200.webp", 341 | "webp_size": "744010" 342 | }, 343 | "looping": { 344 | "mp4": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy-loop.mp4", 345 | "mp4_size": "159822" 346 | }, 347 | "original_mp4": { 348 | "width": "480", 349 | "height": "270", 350 | "mp4": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy.mp4", 351 | "mp4_size": "55056" 352 | }, 353 | "preview_gif": { 354 | "url": "https://media2.giphy.com/media/RLCSAx4DQEJb2/giphy-preview.gif", 355 | "width": "139", 356 | "height": "78", 357 | "size": "44428" 358 | }, 359 | "480w_still": { 360 | "url": "https://media0.giphy.com/media/RLCSAx4DQEJb2/480w_s.jpg", 361 | "width": "480", 362 | "height": "270" 363 | } 364 | }, 365 | "title": "k GIF", 366 | "_score": 285.9092, 367 | "analytics": { 368 | "onload": { 369 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=RLCSAx4DQEJb2&action_type=SEEN" 370 | }, 371 | "onclick": { 372 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=RLCSAx4DQEJb2&action_type=CLICK" 373 | }, 374 | "onsent": { 375 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=RLCSAx4DQEJb2&action_type=SENT" 376 | } 377 | } 378 | }, 379 | { 380 | "type": "gif", 381 | "id": "e63jUdmBlfmec", 382 | "slug": "k-e63jUdmBlfmec", 383 | "url": "https://giphy.com/gifs/k-e63jUdmBlfmec", 384 | "bitly_gif_url": "https://gph.is/1ZYRbVc", 385 | "bitly_url": "https://gph.is/1ZYRbVc", 386 | "embed_url": "https://giphy.com/embed/e63jUdmBlfmec", 387 | "username": "", 388 | "source": "https://www.reddit.com/r/K_gifs/comments/3831sp/it_eksploded/", 389 | "rating": "g", 390 | "content_url": "", 391 | "source_tld": "www.reddit.com", 392 | "source_post_url": "https://www.reddit.com/r/K_gifs/comments/3831sp/it_eksploded/", 393 | "is_sticker": 0, 394 | "import_datetime": "2016-01-12 20:54:08", 395 | "trending_datetime": "0000-00-00 00:00:00", 396 | "images": { 397 | "fixed_height_still": { 398 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/200_s.gif", 399 | "width": "248", 400 | "height": "200" 401 | }, 402 | "original_still": { 403 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy_s.gif", 404 | "width": "320", 405 | "height": "258" 406 | }, 407 | "fixed_width": { 408 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/200w.gif", 409 | "width": "200", 410 | "height": "161", 411 | "size": "2213033", 412 | "mp4": "https://media3.giphy.com/media/e63jUdmBlfmec/200w.mp4", 413 | "mp4_size": "51315", 414 | "webp": "https://media3.giphy.com/media/e63jUdmBlfmec/200w.webp", 415 | "webp_size": "778462" 416 | }, 417 | "fixed_height_small_still": { 418 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/100_s.gif", 419 | "width": "124", 420 | "height": "100" 421 | }, 422 | "fixed_height_downsampled": { 423 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/200_d.gif", 424 | "width": "248", 425 | "height": "200", 426 | "size": "127673", 427 | "webp": "https://media3.giphy.com/media/e63jUdmBlfmec/200_d.webp", 428 | "webp_size": "36662" 429 | }, 430 | "preview": { 431 | "width": "320", 432 | "height": "258", 433 | "mp4": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy-preview.mp4", 434 | "mp4_size": "27198" 435 | }, 436 | "fixed_height_small": { 437 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/100.gif", 438 | "width": "124", 439 | "height": "100", 440 | "size": "967796", 441 | "mp4": "https://media3.giphy.com/media/e63jUdmBlfmec/100.mp4", 442 | "mp4_size": "24324", 443 | "webp": "https://media3.giphy.com/media/e63jUdmBlfmec/100.webp", 444 | "webp_size": "404842" 445 | }, 446 | "downsized_still": { 447 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy-downsized_s.gif", 448 | "width": "250", 449 | "height": "201", 450 | "size": "23859" 451 | }, 452 | "downsized": { 453 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy-downsized.gif", 454 | "width": "250", 455 | "height": "201", 456 | "size": "1179164" 457 | }, 458 | "downsized_large": { 459 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy.gif", 460 | "width": "320", 461 | "height": "258", 462 | "size": "5999328" 463 | }, 464 | "fixed_width_small_still": { 465 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/100w_s.gif", 466 | "width": "100", 467 | "height": "81" 468 | }, 469 | "preview_webp": { 470 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy-preview.webp", 471 | "width": "228", 472 | "height": "184", 473 | "size": "48450" 474 | }, 475 | "fixed_width_still": { 476 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/200w_s.gif", 477 | "width": "200", 478 | "height": "161" 479 | }, 480 | "fixed_width_small": { 481 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/100w.gif", 482 | "width": "100", 483 | "height": "81", 484 | "size": "648179", 485 | "mp4": "https://media3.giphy.com/media/e63jUdmBlfmec/100w.mp4", 486 | "mp4_size": "27353", 487 | "webp": "https://media3.giphy.com/media/e63jUdmBlfmec/100w.webp", 488 | "webp_size": "300596" 489 | }, 490 | "downsized_small": { 491 | "width": "320", 492 | "height": "258", 493 | "mp4": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy-downsized-small.mp4", 494 | "mp4_size": "110309" 495 | }, 496 | "fixed_width_downsampled": { 497 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/200w_d.gif", 498 | "width": "200", 499 | "height": "161", 500 | "size": "81345", 501 | "webp": "https://media3.giphy.com/media/e63jUdmBlfmec/200w_d.webp", 502 | "webp_size": "27734" 503 | }, 504 | "downsized_medium": { 505 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy-downsized-medium.gif", 506 | "width": "320", 507 | "height": "258", 508 | "size": "2864521" 509 | }, 510 | "original": { 511 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy.gif", 512 | "width": "320", 513 | "height": "258", 514 | "size": "5999328", 515 | "frames": "168", 516 | "mp4": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy.mp4", 517 | "mp4_size": "149290", 518 | "webp": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy.webp", 519 | "webp_size": "1581878" 520 | }, 521 | "fixed_height": { 522 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/200.gif", 523 | "width": "248", 524 | "height": "200", 525 | "size": "3484959", 526 | "mp4": "https://media3.giphy.com/media/e63jUdmBlfmec/200.mp4", 527 | "mp4_size": "51073", 528 | "webp": "https://media3.giphy.com/media/e63jUdmBlfmec/200.webp", 529 | "webp_size": "1033918" 530 | }, 531 | "looping": { 532 | "mp4": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy-loop.mp4", 533 | "mp4_size": "304086" 534 | }, 535 | "original_mp4": { 536 | "width": "480", 537 | "height": "386", 538 | "mp4": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy.mp4", 539 | "mp4_size": "149290" 540 | }, 541 | "preview_gif": { 542 | "url": "https://media3.giphy.com/media/e63jUdmBlfmec/giphy-preview.gif", 543 | "width": "122", 544 | "height": "98", 545 | "size": "47857" 546 | }, 547 | "480w_still": { 548 | "url": "https://media1.giphy.com/media/e63jUdmBlfmec/480w_s.jpg", 549 | "width": "480", 550 | "height": "387" 551 | } 552 | }, 553 | "title": "k GIF", 554 | "_score": 282.85458, 555 | "analytics": { 556 | "onload": { 557 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=e63jUdmBlfmec&action_type=SEEN" 558 | }, 559 | "onclick": { 560 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=e63jUdmBlfmec&action_type=CLICK" 561 | }, 562 | "onsent": { 563 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=e63jUdmBlfmec&action_type=SENT" 564 | } 565 | } 566 | }, 567 | { 568 | "type": "gif", 569 | "id": "behTrfrYhb3iw", 570 | "slug": "k-kindergarten-behTrfrYhb3iw", 571 | "url": "https://giphy.com/gifs/k-kindergarten-behTrfrYhb3iw", 572 | "bitly_gif_url": "https://gph.is/1JIMtqu", 573 | "bitly_url": "https://gph.is/1JIMtqu", 574 | "embed_url": "https://giphy.com/embed/behTrfrYhb3iw", 575 | "username": "", 576 | "source": "https://www.reddit.com/r/K_gifs/comments/2eub3p/kindergarten_kop/", 577 | "rating": "g", 578 | "content_url": "", 579 | "source_tld": "www.reddit.com", 580 | "source_post_url": "https://www.reddit.com/r/K_gifs/comments/2eub3p/kindergarten_kop/", 581 | "is_sticker": 0, 582 | "import_datetime": "2016-01-12 20:57:32", 583 | "trending_datetime": "0000-00-00 00:00:00", 584 | "images": { 585 | "fixed_height_still": { 586 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/200_s.gif", 587 | "width": "369", 588 | "height": "200" 589 | }, 590 | "original_still": { 591 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy_s.gif", 592 | "width": "700", 593 | "height": "379" 594 | }, 595 | "fixed_width": { 596 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/200w.gif", 597 | "width": "200", 598 | "height": "108", 599 | "size": "646368", 600 | "mp4": "https://media3.giphy.com/media/behTrfrYhb3iw/200w.mp4", 601 | "mp4_size": "33116", 602 | "webp": "https://media3.giphy.com/media/behTrfrYhb3iw/200w.webp", 603 | "webp_size": "362994" 604 | }, 605 | "fixed_height_small_still": { 606 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/100_s.gif", 607 | "width": "185", 608 | "height": "100" 609 | }, 610 | "fixed_height_downsampled": { 611 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/200_d.gif", 612 | "width": "369", 613 | "height": "200", 614 | "size": "263649", 615 | "webp": "https://media3.giphy.com/media/behTrfrYhb3iw/200_d.webp", 616 | "webp_size": "87124" 617 | }, 618 | "preview": { 619 | "width": "392", 620 | "height": "210", 621 | "mp4": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy-preview.mp4", 622 | "mp4_size": "33695" 623 | }, 624 | "fixed_height_small": { 625 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/100.gif", 626 | "width": "185", 627 | "height": "100", 628 | "size": "600352", 629 | "mp4": "https://media3.giphy.com/media/behTrfrYhb3iw/100.mp4", 630 | "mp4_size": "30272", 631 | "webp": "https://media3.giphy.com/media/behTrfrYhb3iw/100.webp", 632 | "webp_size": "330018" 633 | }, 634 | "downsized_still": { 635 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy-downsized_s.gif", 636 | "width": "250", 637 | "height": "135", 638 | "size": "25096" 639 | }, 640 | "downsized": { 641 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy-downsized.gif", 642 | "width": "250", 643 | "height": "135", 644 | "size": "1129202" 645 | }, 646 | "downsized_large": { 647 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy-downsized-large.gif", 648 | "width": "560", 649 | "height": "303", 650 | "size": "5643470" 651 | }, 652 | "fixed_width_small_still": { 653 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/100w_s.gif", 654 | "width": "100", 655 | "height": "54" 656 | }, 657 | "preview_webp": { 658 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy-preview.webp", 659 | "width": "181", 660 | "height": "98", 661 | "size": "48988" 662 | }, 663 | "fixed_width_still": { 664 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/200w_s.gif", 665 | "width": "200", 666 | "height": "108" 667 | }, 668 | "fixed_width_small": { 669 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/100w.gif", 670 | "width": "100", 671 | "height": "54", 672 | "size": "183023", 673 | "mp4": "https://media3.giphy.com/media/behTrfrYhb3iw/100w.mp4", 674 | "mp4_size": "14318", 675 | "webp": "https://media3.giphy.com/media/behTrfrYhb3iw/100w.webp", 676 | "webp_size": "139120" 677 | }, 678 | "downsized_small": { 679 | "width": "629", 680 | "height": "340", 681 | "mp4": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy-downsized-small.mp4", 682 | "mp4_size": "175065" 683 | }, 684 | "fixed_width_downsampled": { 685 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/200w_d.gif", 686 | "width": "200", 687 | "height": "108", 688 | "size": "88164", 689 | "webp": "https://media3.giphy.com/media/behTrfrYhb3iw/200w_d.webp", 690 | "webp_size": "35400" 691 | }, 692 | "downsized_medium": { 693 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy-downsized-medium.gif", 694 | "width": "512", 695 | "height": "277", 696 | "size": "4663463" 697 | }, 698 | "original": { 699 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy.gif", 700 | "width": "700", 701 | "height": "379", 702 | "size": "9161516", 703 | "frames": "61", 704 | "mp4": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy.mp4", 705 | "mp4_size": "107548", 706 | "webp": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy.webp", 707 | "webp_size": "2677548" 708 | }, 709 | "fixed_height": { 710 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/200.gif", 711 | "width": "369", 712 | "height": "200", 713 | "size": "2264846", 714 | "mp4": "https://media3.giphy.com/media/behTrfrYhb3iw/200.mp4", 715 | "mp4_size": "73512", 716 | "webp": "https://media3.giphy.com/media/behTrfrYhb3iw/200.webp", 717 | "webp_size": "891620" 718 | }, 719 | "looping": { 720 | "mp4": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy-loop.mp4", 721 | "mp4_size": "697963" 722 | }, 723 | "original_mp4": { 724 | "width": "480", 725 | "height": "258", 726 | "mp4": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy.mp4", 727 | "mp4_size": "107548" 728 | }, 729 | "preview_gif": { 730 | "url": "https://media3.giphy.com/media/behTrfrYhb3iw/giphy-preview.gif", 731 | "width": "133", 732 | "height": "72", 733 | "size": "49615" 734 | }, 735 | "480w_still": { 736 | "url": "https://media0.giphy.com/media/behTrfrYhb3iw/480w_s.jpg", 737 | "width": "480", 738 | "height": "260" 739 | } 740 | }, 741 | "title": "k GIF", 742 | "_score": 280.29596, 743 | "analytics": { 744 | "onload": { 745 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=behTrfrYhb3iw&action_type=SEEN" 746 | }, 747 | "onclick": { 748 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=behTrfrYhb3iw&action_type=CLICK" 749 | }, 750 | "onsent": { 751 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=behTrfrYhb3iw&action_type=SENT" 752 | } 753 | } 754 | }, 755 | { 756 | "type": "gif", 757 | "id": "UjnhpSLrFkrhC", 758 | "slug": "k-illers-UjnhpSLrFkrhC", 759 | "url": "https://giphy.com/gifs/k-illers-UjnhpSLrFkrhC", 760 | "bitly_gif_url": "https://gph.is/1ZiUOTy", 761 | "bitly_url": "https://gph.is/1ZiUOTy", 762 | "embed_url": "https://giphy.com/embed/UjnhpSLrFkrhC", 763 | "username": "", 764 | "source": "https://www.reddit.com/r/K_gifs/comments/2upqom/the_killers/", 765 | "rating": "g", 766 | "content_url": "", 767 | "source_tld": "www.reddit.com", 768 | "source_post_url": "https://www.reddit.com/r/K_gifs/comments/2upqom/the_killers/", 769 | "is_sticker": 0, 770 | "import_datetime": "2016-01-12 20:54:44", 771 | "trending_datetime": "0000-00-00 00:00:00", 772 | "images": { 773 | "fixed_height_still": { 774 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200_s.gif", 775 | "width": "230", 776 | "height": "200" 777 | }, 778 | "original_still": { 779 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy_s.gif", 780 | "width": "358", 781 | "height": "311" 782 | }, 783 | "fixed_width": { 784 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200w.gif", 785 | "width": "200", 786 | "height": "174", 787 | "size": "882934", 788 | "mp4": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200w.mp4", 789 | "mp4_size": "66261", 790 | "webp": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200w.webp", 791 | "webp_size": "443638" 792 | }, 793 | "fixed_height_small_still": { 794 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/100_s.gif", 795 | "width": "115", 796 | "height": "100" 797 | }, 798 | "fixed_height_downsampled": { 799 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200_d.gif", 800 | "width": "230", 801 | "height": "200", 802 | "size": "160708", 803 | "webp": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200_d.webp", 804 | "webp_size": "77904" 805 | }, 806 | "preview": { 807 | "width": "230", 808 | "height": "198", 809 | "mp4": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy-preview.mp4", 810 | "mp4_size": "48324" 811 | }, 812 | "fixed_height_small": { 813 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/100.gif", 814 | "width": "115", 815 | "height": "100", 816 | "size": "342732", 817 | "mp4": "https://media0.giphy.com/media/UjnhpSLrFkrhC/100.mp4", 818 | "mp4_size": "27928", 819 | "webp": "https://media0.giphy.com/media/UjnhpSLrFkrhC/100.webp", 820 | "webp_size": "179382" 821 | }, 822 | "downsized_still": { 823 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy-downsized_s.gif", 824 | "width": "250", 825 | "height": "217", 826 | "size": "29253" 827 | }, 828 | "downsized": { 829 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy-downsized.gif", 830 | "width": "250", 831 | "height": "217", 832 | "size": "1335237" 833 | }, 834 | "downsized_large": { 835 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy.gif", 836 | "width": "358", 837 | "height": "311", 838 | "size": "2575899" 839 | }, 840 | "fixed_width_small_still": { 841 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/100w_s.gif", 842 | "width": "100", 843 | "height": "87" 844 | }, 845 | "preview_webp": { 846 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy-preview.webp", 847 | "width": "143", 848 | "height": "124", 849 | "size": "48580" 850 | }, 851 | "fixed_width_still": { 852 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200w_s.gif", 853 | "width": "200", 854 | "height": "174" 855 | }, 856 | "fixed_width_small": { 857 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/100w.gif", 858 | "width": "100", 859 | "height": "87", 860 | "size": "271044", 861 | "mp4": "https://media0.giphy.com/media/UjnhpSLrFkrhC/100w.mp4", 862 | "mp4_size": "21428", 863 | "webp": "https://media0.giphy.com/media/UjnhpSLrFkrhC/100w.webp", 864 | "webp_size": "141746" 865 | }, 866 | "downsized_small": { 867 | "width": "279", 868 | "height": "242", 869 | "mp4": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy-downsized-small.mp4", 870 | "mp4_size": "161014" 871 | }, 872 | "fixed_width_downsampled": { 873 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200w_d.gif", 874 | "width": "200", 875 | "height": "174", 876 | "size": "124626", 877 | "webp": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200w_d.webp", 878 | "webp_size": "61876" 879 | }, 880 | "downsized_medium": { 881 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy.gif", 882 | "width": "358", 883 | "height": "311", 884 | "size": "2575899" 885 | }, 886 | "original": { 887 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy.gif", 888 | "width": "358", 889 | "height": "311", 890 | "size": "2575899", 891 | "frames": "45", 892 | "mp4": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy.mp4", 893 | "mp4_size": "389307", 894 | "webp": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy.webp", 895 | "webp_size": "1249136" 896 | }, 897 | "fixed_height": { 898 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200.gif", 899 | "width": "230", 900 | "height": "200", 901 | "size": "1145002", 902 | "mp4": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200.mp4", 903 | "mp4_size": "87353", 904 | "webp": "https://media0.giphy.com/media/UjnhpSLrFkrhC/200.webp", 905 | "webp_size": "561302" 906 | }, 907 | "looping": { 908 | "mp4": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy-loop.mp4", 909 | "mp4_size": "1447634" 910 | }, 911 | "original_mp4": { 912 | "width": "480", 913 | "height": "416", 914 | "mp4": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy.mp4", 915 | "mp4_size": "389307" 916 | }, 917 | "preview_gif": { 918 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/giphy-preview.gif", 919 | "width": "98", 920 | "height": "85", 921 | "size": "48113" 922 | }, 923 | "480w_still": { 924 | "url": "https://media0.giphy.com/media/UjnhpSLrFkrhC/480w_s.jpg", 925 | "width": "480", 926 | "height": "417" 927 | } 928 | }, 929 | "title": "k GIF", 930 | "_score": 279.7735, 931 | "analytics": { 932 | "onload": { 933 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=UjnhpSLrFkrhC&action_type=SEEN" 934 | }, 935 | "onclick": { 936 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=UjnhpSLrFkrhC&action_type=CLICK" 937 | }, 938 | "onsent": { 939 | "url": "https://giphy_analytics.giphy.com/simple_analytics?response_id=5c4911e7586b446a4127532c&event_type=GIF_SEARCH&gif_id=UjnhpSLrFkrhC&action_type=SENT" 940 | } 941 | } 942 | } 943 | ], 944 | "pagination": { 945 | "total_count": 13859, 946 | "count": 5, 947 | "offset": 0 948 | }, 949 | "meta": { 950 | "status": 200, 951 | "msg": "OK", 952 | "response_id": "5c4911e7586b446a4127532c" 953 | } 954 | } 955 | -------------------------------------------------------------------------------- /src/reducers/__tests__/layoutReducer.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | layoutReducer, 3 | defaultLayoutSetting, 4 | getLayoutFromState, 5 | } from '../layoutReducer'; 6 | import { LAYOUT_ACTION_TYPES } from '../../actions/layoutActions'; 7 | 8 | describe('layoutReducer', () => { 9 | it('should generate default state correctly', () => { 10 | const state = defaultLayoutSetting; 11 | const action = { 12 | type: 'unknown', 13 | payload: null, 14 | }; 15 | expect(layoutReducer(state, action)).toEqual(defaultLayoutSetting); 16 | }); 17 | 18 | it('should extract params from state correctly', () => { 19 | const state = { 20 | layout: 42, 21 | }; 22 | expect(getLayoutFromState(state)).toBe(42); 23 | }); 24 | 25 | it('should set layout size', () => { 26 | const state = defaultLayoutSetting; 27 | const action = { 28 | type: LAYOUT_ACTION_TYPES.RESIZE, 29 | payload: 'sm', 30 | }; 31 | expect(layoutReducer(state, action).size).toEqual('sm'); 32 | }); 33 | 34 | it('should set columns numb correctly', () => { 35 | const state = defaultLayoutSetting; 36 | const action = { 37 | type: LAYOUT_ACTION_TYPES.SET_COLUMNS_NUM, 38 | payload: 4, 39 | }; 40 | expect(layoutReducer(state, action).manualColumnsNum).toBe(4); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/reducers/__tests__/rootReducer.test.js: -------------------------------------------------------------------------------- 1 | import { rootReducer } from '../rootReducer'; 2 | import { defaultSearchParams } from '../searchParamsReducer'; 3 | import { defaultSearchResults } from '../searchResultsReducer'; 4 | import { defaultLayoutSetting } from '../layoutReducer'; 5 | 6 | describe('rootReducer', () => { 7 | it('should generate default state correctly', () => { 8 | const state = {}; 9 | const action = { 10 | type: 'unknown', 11 | payload: null, 12 | }; 13 | expect(rootReducer(state, action)).toBeDefined(); 14 | expect(rootReducer(state, action).searchParams).toEqual(defaultSearchParams); 15 | expect(rootReducer(state, action).searchResults).toEqual(defaultSearchResults); 16 | expect(rootReducer(state, action).layout).toEqual(defaultLayoutSetting); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/reducers/__tests__/searchParamsReducer.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | searchParamsReducer, 3 | defaultSearchParams, 4 | getSearchParamsFromState, 5 | } from '../searchParamsReducer'; 6 | import { SEARCH_ACTION_TYPES } from '../../actions/searchActions'; 7 | import { SEARCH_PARAMS_ACTION_TYPES } from '../../actions/searchParamsActions'; 8 | 9 | describe('searchParamsReducer', () => { 10 | it('should generate default state correctly', () => { 11 | const state = defaultSearchParams; 12 | const action = { 13 | type: 'unknown', 14 | payload: null, 15 | }; 16 | expect(searchParamsReducer(state, action)).toEqual(defaultSearchParams); 17 | }); 18 | 19 | it('should extract params from state correctly', () => { 20 | const state = { 21 | searchParams: 42, 22 | }; 23 | expect(getSearchParamsFromState(state)).toBe(42); 24 | }); 25 | 26 | it('should react on reset action correctly', () => { 27 | const state = defaultSearchParams; 28 | const action = { 29 | type: SEARCH_ACTION_TYPES.SEARCH_RESET, 30 | payload: null, 31 | }; 32 | expect(searchParamsReducer(state, action)).toEqual(state); 33 | }); 34 | 35 | it('should react on update search query correctly', () => { 36 | const state = defaultSearchParams; 37 | const action = { 38 | type: SEARCH_PARAMS_ACTION_TYPES.UPDATE_SEARCH_QUERY, 39 | payload: 'kitten', 40 | }; 41 | expect(searchParamsReducer(state, action).query).toBe('kitten'); 42 | }); 43 | 44 | it('should react on update search offset correctly', () => { 45 | const state = defaultSearchParams; 46 | const action = { 47 | type: SEARCH_PARAMS_ACTION_TYPES.UPDATE_SEARCH_OFFSET, 48 | payload: 42, 49 | }; 50 | expect(searchParamsReducer(state, action).offset).toBe(42); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/reducers/__tests__/searchResultsReducer.test.js: -------------------------------------------------------------------------------- 1 | import { ActionType } from 'redux-promise-middleware'; 2 | import { 3 | searchResultsReducer, 4 | getSearchResultsFromState, 5 | defaultSearchResults, 6 | } from '../searchResultsReducer'; 7 | import { SEARCH_ACTION_TYPES } from '../../actions/searchActions'; 8 | 9 | describe('searchResultsReducer', () => { 10 | it('should generate default state correctly', () => { 11 | const state = { ...defaultSearchResults }; 12 | const action = { 13 | type: 'unknown', 14 | payload: null, 15 | }; 16 | expect(searchResultsReducer(state, action)).toEqual(defaultSearchResults); 17 | }); 18 | 19 | it('should extract params from state correctly', () => { 20 | const state = { 21 | searchResults: 42, 22 | }; 23 | expect(getSearchResultsFromState(state)).toBe(42); 24 | }); 25 | 26 | it('should react on reset action correctly', () => { 27 | const state = { ...defaultSearchResults }; 28 | const action = { 29 | type: SEARCH_ACTION_TYPES.SEARCH_RESET, 30 | payload: null, 31 | }; 32 | expect(searchResultsReducer(state, action)).toEqual(state); 33 | }); 34 | 35 | it('should react on pending search correctly', () => { 36 | const state = { ...defaultSearchResults }; 37 | const action = { 38 | type: `${SEARCH_ACTION_TYPES.SEARCH}_${ActionType.Pending}`, 39 | payload: null, 40 | }; 41 | expect(searchResultsReducer(state, action).isLoading).toBe(true); 42 | expect(searchResultsReducer(state, action).isFetchingMore).toBe(false); 43 | }); 44 | 45 | it('should react on pending search more correctly', () => { 46 | const state = { ...defaultSearchResults }; 47 | const action = { 48 | type: `${SEARCH_ACTION_TYPES.SEARCH_MORE}_${ActionType.Pending}`, 49 | payload: null, 50 | }; 51 | expect(searchResultsReducer(state, action).isFetchingMore).toBe(true); 52 | expect(searchResultsReducer(state, action).isLoading).toBe(false); 53 | }); 54 | 55 | it('should react on pending search fulfilled correctly', () => { 56 | const state = { ...defaultSearchResults }; 57 | state.data = [ 58 | 'result #1', 59 | ]; 60 | const action = { 61 | type: `${SEARCH_ACTION_TYPES.SEARCH}_${ActionType.Fulfilled}`, 62 | payload: { 63 | data: { 64 | data: [ 65 | 'result #2', 66 | ], 67 | }, 68 | }, 69 | }; 70 | expect(searchResultsReducer(state, action).data).toEqual([ 71 | 'result #2', 72 | ]); 73 | expect(searchResultsReducer(state, action).isLoading).toBe(false); 74 | expect(searchResultsReducer(state, action).isFetchingMore).toBe(false); 75 | }); 76 | 77 | it('should react on pending search more fulfilled correctly', () => { 78 | const state = { ...defaultSearchResults }; 79 | state.data = [ 80 | 'result #1', 81 | ]; 82 | const action = { 83 | type: `${SEARCH_ACTION_TYPES.SEARCH_MORE}_${ActionType.Fulfilled}`, 84 | payload: { 85 | data: { 86 | data: [ 87 | 'result #2', 88 | ], 89 | }, 90 | }, 91 | }; 92 | expect(searchResultsReducer(state, action).data).toEqual([ 93 | 'result #1', 94 | 'result #2', 95 | ]); 96 | expect(searchResultsReducer(state, action).isLoading).toBe(false); 97 | expect(searchResultsReducer(state, action).isFetchingMore).toBe(false); 98 | }); 99 | 100 | it('should react on pending search rejected correctly', () => { 101 | const state = { ...defaultSearchResults }; 102 | const action = { 103 | type: `${SEARCH_ACTION_TYPES.SEARCH}_${ActionType.Rejected}`, 104 | payload: null, 105 | }; 106 | expect(searchResultsReducer(state, action).isLoading).toBe(false); 107 | expect(searchResultsReducer(state, action).isFetchingMore).toBe(false); 108 | expect(searchResultsReducer(state, action).error).toBe(true); 109 | }); 110 | 111 | it('should react on pending search more rejected correctly', () => { 112 | const state = { ...defaultSearchResults }; 113 | const action = { 114 | type: `${SEARCH_ACTION_TYPES.SEARCH_MORE}_${ActionType.Rejected}`, 115 | payload: null, 116 | }; 117 | expect(searchResultsReducer(state, action).isLoading).toBe(false); 118 | expect(searchResultsReducer(state, action).isFetchingMore).toBe(false); 119 | expect(searchResultsReducer(state, action).error).toBe(true); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/reducers/layoutReducer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef LayoutState 3 | * @type {object} 4 | * @property {?string} size - screen size name (is being set automatically). 5 | * @property {?string} manualSize - screen size name (is being set manually). 6 | */ 7 | 8 | import { LAYOUT_ACTION_TYPES } from '../actions/layoutActions'; 9 | 10 | /** 11 | * @type {LayoutState} 12 | */ 13 | export const defaultLayoutSetting = { 14 | size: null, 15 | manualColumnsNum: null, 16 | }; 17 | 18 | // Layout reducer is responsible for storing layout parameter like screen size etc. 19 | export const layoutReducer = (state = defaultLayoutSetting, action) => { 20 | switch (action.type) { 21 | case LAYOUT_ACTION_TYPES.RESIZE: 22 | return { ...state, size: action.payload }; 23 | 24 | case LAYOUT_ACTION_TYPES.SET_COLUMNS_NUM: 25 | return { ...state, manualColumnsNum: action.payload }; 26 | 27 | default: 28 | return state; 29 | } 30 | }; 31 | 32 | /** 33 | * Extracts layout parameters from the store. 34 | * This is a helper function that allows us to change store structure easily 35 | * without changing the components. 36 | * 37 | * @param {object} state 38 | * @return {LayoutState} 39 | */ 40 | export const getLayoutFromState = state => state.layout; 41 | -------------------------------------------------------------------------------- /src/reducers/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { searchResultsReducer } from './searchResultsReducer'; 3 | import { searchParamsReducer } from './searchParamsReducer'; 4 | import { layoutReducer } from './layoutReducer'; 5 | 6 | // Root reducer will serve as a container for other reducers. 7 | export const rootReducer = combineReducers({ 8 | searchResults: searchResultsReducer, 9 | searchParams: searchParamsReducer, 10 | layout: layoutReducer, 11 | }); 12 | -------------------------------------------------------------------------------- /src/reducers/searchParamsReducer.js: -------------------------------------------------------------------------------- 1 | import { SEARCH_PARAMS_ACTION_TYPES } from '../actions/searchParamsActions'; 2 | import { SEARCH_ACTION_TYPES } from '../actions/searchActions'; 3 | import { SEARCH_BATCH_SIZE } from '../config'; 4 | 5 | /** 6 | * @typedef SearchParamsState 7 | * @type {object} 8 | * @property {string} query - search query string. 9 | * @property {number} limit - number of gifs that are being fetched per one search request. 10 | * @property {number} offset - offset of the search results. 11 | * @property {string} rating - gifs rating. 12 | * @property {string} lang - gifs language. 13 | */ 14 | 15 | /** 16 | * @type {SearchParamsState} 17 | */ 18 | export const defaultSearchParams = { 19 | query: '', 20 | limit: SEARCH_BATCH_SIZE, 21 | offset: 0, 22 | rating: 'G', 23 | lang: 'en', 24 | }; 25 | 26 | // Search params reducer is responsible for storing current search parameters (query, offset etc.) 27 | export const searchParamsReducer = (state = defaultSearchParams, action) => { 28 | switch (action.type) { 29 | case SEARCH_ACTION_TYPES.SEARCH_RESET: 30 | return { ...defaultSearchParams }; 31 | 32 | case SEARCH_PARAMS_ACTION_TYPES.UPDATE_SEARCH_QUERY: 33 | return { ...state, query: action.payload }; 34 | 35 | case SEARCH_PARAMS_ACTION_TYPES.UPDATE_SEARCH_OFFSET: 36 | return { ...state, offset: action.payload }; 37 | 38 | default: 39 | return state; 40 | } 41 | }; 42 | 43 | /** 44 | * Extracts search parameters from the store. 45 | * This is a helper function that allows us to change store structure easily 46 | * without changing the components. 47 | * 48 | * @param {object} state 49 | * @return {SearchParamsState} 50 | */ 51 | export const getSearchParamsFromState = state => state.searchParams; 52 | -------------------------------------------------------------------------------- /src/reducers/searchResultsReducer.js: -------------------------------------------------------------------------------- 1 | import { ActionType } from 'redux-promise-middleware'; 2 | import { SEARCH_ACTION_TYPES } from '../actions/searchActions'; 3 | 4 | /** 5 | * @typedef SearchResultsState 6 | * @type {object} 7 | * @property {*[]} data 8 | * @property {object} pagination 9 | * @property {object} meta 10 | * @property {boolean} isLoading 11 | * @property {boolean} isFetchingMore 12 | * @property {object} error 13 | */ 14 | 15 | /** 16 | * @type {SearchResultsState} 17 | */ 18 | export const defaultSearchResults = { 19 | data: [], 20 | pagination: {}, 21 | meta: {}, 22 | isLoading: false, 23 | isFetchingMore: false, 24 | error: null, 25 | }; 26 | 27 | // Search results reducer is responsible for storing information from Giphy API. 28 | export const searchResultsReducer = (state = defaultSearchResults, action) => { 29 | const payloadData = (action.payload && action.payload.data) || null; 30 | 31 | switch (action.type) { 32 | case SEARCH_ACTION_TYPES.SEARCH_RESET: 33 | return { ...defaultSearchResults }; 34 | 35 | case `${SEARCH_ACTION_TYPES.SEARCH}_${ActionType.Pending}`: 36 | return { 37 | ...state, 38 | isLoading: true, 39 | isFetchingMore: false, 40 | error: null, 41 | }; 42 | 43 | case `${SEARCH_ACTION_TYPES.SEARCH_MORE}_${ActionType.Pending}`: 44 | return { 45 | ...state, 46 | isLoading: false, 47 | isFetchingMore: true, 48 | error: null, 49 | }; 50 | 51 | case `${SEARCH_ACTION_TYPES.SEARCH}_${ActionType.Fulfilled}`: 52 | return { 53 | ...state, 54 | data: payloadData.data, 55 | pagination: payloadData.pagination, 56 | meta: payloadData.meta, 57 | isLoading: false, 58 | isFetchingMore: false, 59 | error: null, 60 | }; 61 | 62 | case `${SEARCH_ACTION_TYPES.SEARCH_MORE}_${ActionType.Fulfilled}`: 63 | return { 64 | ...state, 65 | data: [...state.data].concat(payloadData.data), 66 | pagination: payloadData.pagination, 67 | meta: payloadData.meta, 68 | isLoading: false, 69 | isFetchingMore: false, 70 | error: null, 71 | }; 72 | 73 | case `${SEARCH_ACTION_TYPES.SEARCH}_${ActionType.Rejected}`: 74 | return { 75 | ...defaultSearchResults, 76 | isLoading: false, 77 | isFetchingMore: false, 78 | error: true, 79 | }; 80 | 81 | case `${SEARCH_ACTION_TYPES.SEARCH_MORE}_${ActionType.Rejected}`: 82 | return { 83 | ...state, 84 | isLoading: false, 85 | isFetchingMore: false, 86 | error: true, 87 | }; 88 | 89 | default: 90 | return state; 91 | } 92 | }; 93 | 94 | /** 95 | * Extracts search results from the store. 96 | * This is a helper function that allows us to change store structure easily 97 | * without changing the components. 98 | * 99 | * @param {object} state 100 | * @return {SearchResultsState} 101 | */ 102 | export const getSearchResultsFromState = state => state.searchResults; 103 | -------------------------------------------------------------------------------- /src/services/GiphyService.js: -------------------------------------------------------------------------------- 1 | import { RequestService } from './RequestService'; 2 | import { GIPHY_API_HOST, GIPHY_API_KEY } from '../config'; 3 | import { defaultSearchParams } from '../reducers/searchParamsReducer'; 4 | 5 | // GiphyService is responsible for all integrations with Giphy API. 6 | export class GiphyService { 7 | // Search for GIFs by specific search query. 8 | static search({ 9 | query = defaultSearchParams.query, 10 | limit = defaultSearchParams.limit, 11 | offset = defaultSearchParams.offset, 12 | rating = defaultSearchParams.rating, 13 | lang = defaultSearchParams.lang, 14 | } = {}) { 15 | const searchParams = { 16 | api_key: GIPHY_API_KEY, 17 | q: query, 18 | limit, 19 | offset, 20 | rating, 21 | lang, 22 | }; 23 | 24 | return RequestService.get( 25 | `${GIPHY_API_HOST}/v1/gifs/search`, 26 | searchParams, 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/services/LayoutService.js: -------------------------------------------------------------------------------- 1 | // Set of screen sizes that app is supporting. 2 | export const SCREEN_SIZES = { 3 | xl: { id: 'xl', mediaQuery: '(min-width: 1200px)' }, 4 | lg: { id: 'lg', mediaQuery: '(max-width: 1200px) and (min-width: 960px)' }, 5 | md: { id: 'md', mediaQuery: '(max-width: 950px) and (min-width: 720px)' }, 6 | sm: { id: 'sm', mediaQuery: '(max-width: 720px) and (min-width: 540px)' }, 7 | xs: { id: 'xs', mediaQuery: '(max-width: 540px)' }, 8 | }; 9 | 10 | // Supported number of columns in 12-column templates. 11 | export const SUPPORTED_COLUMNS_NUMS = [1, 2, 3, 4, 6, 12]; 12 | 13 | export const DEFAULT_COLUMNS_NUM = 3; 14 | 15 | export const TOTAL_NUMBER_OF_LAYOUT_COLUMNS = 12; 16 | 17 | export class LayoutService { 18 | /** 19 | * Get default number of columns for 12-column template depending on screen size. 20 | * @param {string} screenSize 21 | * @return {number} 22 | */ 23 | static screenSizeToColumns(screenSize) { 24 | switch (screenSize) { 25 | case SCREEN_SIZES.xs.id: 26 | return 1; 27 | 28 | case SCREEN_SIZES.sm.id: 29 | case SCREEN_SIZES.md.id: 30 | return 3; 31 | 32 | case SCREEN_SIZES.lg.id: 33 | case SCREEN_SIZES.xl.id: 34 | return 4; 35 | 36 | default: 37 | return DEFAULT_COLUMNS_NUM; 38 | } 39 | } 40 | 41 | /** 42 | * Chooses the number of layout columns from manual and automatic one. 43 | * @param {?number} responsiveColumnsNum - number of layout columns according to window size 44 | * @param {?number} manualColumnsNum - number of layout columns according to user selection. 45 | * @return {number} 46 | */ 47 | static choseColumnsNum(responsiveColumnsNum, manualColumnsNum) { 48 | // Manual column selection has higher priority than automatic columns number 49 | // calculated based on window size. 50 | return manualColumnsNum || responsiveColumnsNum; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/services/RequestService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { REQUEST_TIMEOUT } from '../config'; 3 | 4 | // RequestService is created as a wrapper on top of axios. This is done in order to be able to 5 | // switch to new http-requests library if needed. Utilizing interfaces might be a good option here. 6 | export class RequestService { 7 | // Perform GET request with parameters. 8 | static get(url, params = {}) { 9 | return axios.get( 10 | url, 11 | { ...this.getDefaultAxiosConfig(), params }, 12 | ); 13 | } 14 | 15 | // Generate default Axios configuration for requests. 16 | // We may put here default timeouts etc. 17 | static getDefaultAxiosConfig() { 18 | return { 19 | timeout: REQUEST_TIMEOUT, 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/services/RouterService.js: -------------------------------------------------------------------------------- 1 | const SEARCH_QUERY_PARAM = 'query'; 2 | 3 | export class RouterService { 4 | constructor(history, location) { 5 | this.history = history; 6 | this.location = location; 7 | } 8 | 9 | pushSearchQuery(query = '') { 10 | this.history.push({ 11 | search: query ? `?${SEARCH_QUERY_PARAM}=${query}` : '', 12 | }); 13 | } 14 | 15 | getSearchQuery() { 16 | const searchParams = new URLSearchParams(this.location.search); 17 | return searchParams.get(SEARCH_QUERY_PARAM); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/services/__mocks__/GiphyService.js: -------------------------------------------------------------------------------- 1 | import { RequestService } from './RequestService'; 2 | import { GIPHY_API_HOST } from '../../config'; 3 | 4 | // GiphyService mock. 5 | export class GiphyService { 6 | static search(searchParams) { 7 | return RequestService.get( 8 | `${GIPHY_API_HOST}/v1/gifs/search`, 9 | searchParams, 10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/services/__mocks__/RequestService.js: -------------------------------------------------------------------------------- 1 | import searchResultsMock from '../../mocks/searchResults'; 2 | 3 | // Mock of the RequestService. 4 | export class RequestService { 5 | static get(url, params = {}) { 6 | return Promise.resolve(searchResultsMock); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/services/__mocks__/RouterService.js: -------------------------------------------------------------------------------- 1 | export class RouterService { 2 | pushSearchQuery = jest.fn(); 3 | 4 | getSearchQuery = jest.fn(); 5 | } 6 | -------------------------------------------------------------------------------- /src/services/__tests__/GiphyService.test.js: -------------------------------------------------------------------------------- 1 | import { GiphyService } from '../GiphyService'; 2 | import searchResultsMock from '../../mocks/searchResults'; 3 | 4 | jest.mock('../RequestService'); 5 | 6 | describe('GiphyService', () => { 7 | it('should perform default search request to GIPHY API', () => ( 8 | expect(GiphyService.search()).resolves.toBe(searchResultsMock) 9 | )); 10 | }); 11 | -------------------------------------------------------------------------------- /src/services/__tests__/LayoutService.test.js: -------------------------------------------------------------------------------- 1 | import { LayoutService } from '../LayoutService'; 2 | 3 | describe('LayoutService', () => { 4 | it('should pick correct number of columns for layout', () => { 5 | expect(LayoutService.choseColumnsNum(4, null)).toBe(4); 6 | expect(LayoutService.choseColumnsNum(4, 3)).toBe(3); 7 | expect(LayoutService.choseColumnsNum(null, 3)).toBe(3); 8 | }); 9 | 10 | it('should correctly convert screen size to number of columns', () => { 11 | expect(LayoutService.screenSizeToColumns('xs')).toBe(1); 12 | expect(LayoutService.screenSizeToColumns('sm')).toBe(3); 13 | expect(LayoutService.screenSizeToColumns('md')).toBe(3); 14 | expect(LayoutService.screenSizeToColumns('lg')).toBe(4); 15 | expect(LayoutService.screenSizeToColumns('xl')).toBe(4); 16 | 17 | expect(LayoutService.screenSizeToColumns('unknown')).toBe(3); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/services/__tests__/RequestService.test.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import AxiosMockAdapter from 'axios-mock-adapter'; 3 | import { RequestService } from '../RequestService'; 4 | 5 | describe('RequestService', () => { 6 | it('should perform successful GET requests', (done) => { 7 | const testUrl = 'http://localhost'; 8 | const testResponse = { testData: 'Test response data' }; 9 | 10 | const testParams = { 11 | param1: 'Param #1 value', 12 | param2: 'Param #2 value', 13 | }; 14 | const mock = new AxiosMockAdapter(axios); 15 | mock.onGet(testUrl).reply(200, testResponse); 16 | 17 | RequestService 18 | .get(testUrl, testParams) 19 | .then((response) => { 20 | // We should receive mocked response. 21 | expect(response).toBeDefined(); 22 | expect(response.data).toEqual(testResponse); 23 | }) 24 | .catch(() => { 25 | // We should not get here. 26 | expect(true).toBe(false); 27 | }) 28 | .finally(() => { 29 | done(); 30 | }); 31 | }); 32 | 33 | it('should perform not successful GET requests', (done) => { 34 | const testUrl = 'http://localhost'; 35 | const mock = new AxiosMockAdapter(axios); 36 | mock.onGet(testUrl).reply(500); 37 | 38 | RequestService 39 | .get(testUrl) 40 | .then(() => { 41 | // We should not get here. 42 | expect(true).toBe(false); 43 | }) 44 | .catch((err) => { 45 | // We should get here. 46 | expect(err).toBeDefined(); 47 | }) 48 | .finally(() => { 49 | done(); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/services/__tests__/RouterService.test.js: -------------------------------------------------------------------------------- 1 | import { RouterService } from '../RouterService'; 2 | 3 | describe('RouterService', () => { 4 | it('should push search query to URL', () => { 5 | const historyPush = jest.fn(); 6 | const history = { 7 | push: historyPush, 8 | }; 9 | const location = {}; 10 | const routerService = new RouterService(history, location); 11 | 12 | expect(historyPush).not.toHaveBeenCalled(); 13 | 14 | // Push empty query to URL. 15 | routerService.pushSearchQuery(''); 16 | expect(historyPush).toHaveBeenCalledTimes(1); 17 | expect(historyPush).toHaveBeenCalledWith({ 18 | search: '', 19 | }); 20 | 21 | routerService.pushSearchQuery('kitten'); 22 | expect(historyPush).toHaveBeenCalledTimes(2); 23 | expect(historyPush.mock.calls[1][0]).toEqual({ 24 | search: '?query=kitten', 25 | }); 26 | }); 27 | 28 | it('should extract search query from URL GET params', () => { 29 | const queryString = 'kitten'; 30 | const history = {}; 31 | const location = { 32 | search: `?query=${queryString}`, 33 | }; 34 | const routerService = new RouterService(history, location); 35 | const query = routerService.getSearchQuery(); 36 | expect(query).toBe(queryString); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { configure } from 'enzyme'; 3 | // eslint-disable-next-line import/no-extraneous-dependencies 4 | import Adapter from 'enzyme-adapter-react-16'; 5 | 6 | // Configure Enzyme. 7 | configure({ adapter: new Adapter() }); 8 | 9 | // Setup global mocks. 10 | 11 | // Mock window.matchMedia() method. 12 | global.matchMedia = global.matchMedia || jest.fn().mockImplementation(query => ({ 13 | matches: true, 14 | media: query, 15 | onchange: null, 16 | addListener: jest.fn(), 17 | removeListener: jest.fn(), 18 | })); 19 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore, compose } from 'redux'; 2 | import promise from 'redux-promise-middleware'; 3 | import thunk from 'redux-thunk'; 4 | import { rootReducer } from './reducers/rootReducer'; 5 | 6 | // Add support of browser Redux debugger. 7 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 8 | 9 | // Add middleware to be able to use promises and chains of actions. 10 | const middleware = applyMiddleware( 11 | promise, // Allows to put promise() into action.payload. 12 | thunk, // Allows to dispatch(function()) that will accept `dispatch` as a parameter. 13 | ); 14 | 15 | // Create Redux store for the application. 16 | export const store = createStore( 17 | rootReducer, 18 | composeEnhancers(middleware), 19 | ); 20 | -------------------------------------------------------------------------------- /src/types/giphyTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | // PropType for Giphy Image entity. 4 | export const gifImagePropType = PropTypes.shape({ 5 | url: PropTypes.string, 6 | width: PropTypes.string, 7 | height: PropTypes.string, 8 | size: PropTypes.string, 9 | mp4: PropTypes.string, 10 | mp4_size: PropTypes.string, 11 | webp: PropTypes.string, 12 | webp_size: PropTypes.string, 13 | }); 14 | 15 | // PropType for Giphy images set. 16 | export const gifImagesPropType = PropTypes.shape({ 17 | fixed_height_still: gifImagePropType, 18 | original_still: gifImagePropType, 19 | fixed_width: gifImagePropType, 20 | fixed_height_small_still: gifImagePropType, 21 | fixed_height_downsampled: gifImagePropType, 22 | preview: gifImagePropType, 23 | fixed_height_small: gifImagePropType, 24 | downsized_still: gifImagePropType, 25 | downsized: gifImagePropType, 26 | downsized_large: gifImagePropType, 27 | fixed_width_small_still: gifImagePropType, 28 | preview_webp: gifImagePropType, 29 | fixed_width_still: gifImagePropType, 30 | fixed_width_small: gifImagePropType, 31 | downsized_small: gifImagePropType, 32 | fixed_width_downsampled: gifImagePropType, 33 | downsized_medium: gifImagePropType, 34 | original: gifImagePropType, 35 | fixed_height: gifImagePropType, 36 | looping: gifImagePropType, 37 | original_mp4: gifImagePropType, 38 | preview_gif: gifImagePropType, 39 | }); 40 | 41 | // PropType for the Giphy search result entity. 42 | export const gifEntityPropType = PropTypes.shape({ 43 | title: PropTypes.string, 44 | _score: PropTypes.number, 45 | type: PropTypes.string, 46 | id: PropTypes.string, 47 | slug: PropTypes.string, 48 | url: PropTypes.string, 49 | bitly_gif_url: PropTypes.string, 50 | bitly_url: PropTypes.string, 51 | embed_url: PropTypes.string, 52 | username: PropTypes.string, 53 | source: PropTypes.string, 54 | rating: PropTypes.string, 55 | content_url: PropTypes.string, 56 | source_tld: PropTypes.string, 57 | source_post_url: PropTypes.string, 58 | is_sticker: PropTypes.number, 59 | import_datetime: PropTypes.string, 60 | trending_datetime: PropTypes.string, 61 | images: gifImagesPropType, 62 | }); 63 | -------------------------------------------------------------------------------- /src/types/reduxTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | // PropType for Redux store object. 4 | export const storePropType = PropTypes.shape({ 5 | dispatch: PropTypes.func.isRequired, 6 | getState: PropTypes.func.isRequired, 7 | subscribe: PropTypes.func.isRequired, 8 | replaceReducer: PropTypes.func.isRequired, 9 | }); 10 | --------------------------------------------------------------------------------