├── cypress.json ├── .eslintignore ├── .gitignore ├── .prettierignore ├── modules └── search │ ├── search-gifs │ ├── index.tsx │ └── search-gifs.tsx │ ├── search-field │ ├── index.tsx │ ├── search-field.stories.tsx │ └── search-field.tsx │ ├── search-results │ ├── index.tsx │ ├── search-results.tsx │ └── search-results.stories.tsx │ ├── api.tsx │ └── store.tsx ├── .prettierrc.js ├── .huskyrc.js ├── babel.config.js ├── cypress ├── .eslintrc.json ├── tsconfig.json ├── integration │ └── search.spec.ts ├── plugins │ └── index.js └── support │ ├── index.js │ └── commands.js ├── .lintstagedrc.js ├── jest.config.js ├── .storybook ├── addons.js ├── webpack.config.js └── config.js ├── next.config.js ├── next-starter.iml ├── pages ├── about │ ├── about.test.tsx │ ├── __snapshots__ │ │ └── about.test.tsx.snap │ └── index.tsx ├── index │ └── index.tsx ├── _app.tsx ├── _error.tsx └── _document.tsx ├── LICENSE ├── tsconfig.json ├── app ├── hooks │ └── useDebounce.tsx ├── utils │ └── deep-equal.tsx └── store │ └── createStore.tsx ├── .eslintrc.js ├── package.json └── README.md /cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .next 2 | .storybook 3 | node_modules 4 | package.json 5 | tsconfig.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .next 3 | cypress/videos/* 4 | node_modules 5 | *.iml 6 | *.log 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | .storybook 3 | node_modules 4 | package.json 5 | tsconfig.json -------------------------------------------------------------------------------- /modules/search/search-gifs/index.tsx: -------------------------------------------------------------------------------- 1 | import Component from './search-gifs' 2 | export default Component -------------------------------------------------------------------------------- /modules/search/search-field/index.tsx: -------------------------------------------------------------------------------- 1 | import Component from './search-field' 2 | export default Component -------------------------------------------------------------------------------- /modules/search/search-results/index.tsx: -------------------------------------------------------------------------------- 1 | import Component from './search-results' 2 | export default Component -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | tabWidth: 2, 5 | trailingComma: 'all', 6 | }; -------------------------------------------------------------------------------- /.huskyrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | 'pre-commit': 'yarn run lint-staged', 4 | 'pre-push': 'yarn run test', 5 | } 6 | } -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const presets = [ 2 | "next/babel", 3 | "@zeit/next-typescript/babel", 4 | ]; 5 | const plugins = []; 6 | 7 | module.exports = { 8 | presets, 9 | plugins, 10 | }; -------------------------------------------------------------------------------- /cypress/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "cypress" 4 | ], 5 | "env": { 6 | "cypress/globals": true 7 | }, 8 | "extends": [ 9 | "plugin:cypress/recommended" 10 | ] 11 | } -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // '*.{json,css,scss,html}': ['prettier --write', 'git add'], 3 | // '*.md': ['prettier --write', 'markdownlint', 'git add'], 4 | '*.{ts,tsx}': ['eslint --fix', 'tsc', 'git add'], 5 | }; -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": "../node_modules", 5 | "target": "es5", 6 | "lib": ["es5", "dom"], 7 | "types": ["cypress"] 8 | }, 9 | "include": [ 10 | "**/*.ts" 11 | ] 12 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testPathIgnorePatterns: ['/.next/', '/cypress/', '/node_modules/'], 3 | coverageThreshold: { 4 | global: { 5 | branches: 80, 6 | functions: 80, 7 | lines: 80, 8 | statements: -10 9 | } 10 | }, 11 | } -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | // sources: 2 | // https://storybook.js.org/docs/addons/addon-gallery/ 3 | // https://medium.com/@dandobusiness/improving-your-storybook-with-storybook-addons-717677e89de7 4 | import '@storybook/addon-a11y/register' 5 | import '@storybook/addon-actions/register' 6 | import '@storybook/addon-knobs/register' 7 | import '@storybook/addon-viewport/register' -------------------------------------------------------------------------------- /modules/search/search-field/search-field.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import { action } from '@storybook/addon-actions' 4 | 5 | import SearchField from './search-field' 6 | 7 | storiesOf('SearchField', module) 8 | .add('empty', () => { 9 | return 10 | }) -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ config }) => { 2 | config.module.rules.push({ 3 | test: /\.(ts|tsx)$/, 4 | use: [{ 5 | loader: require.resolve('babel-loader'), 6 | options: { 7 | presets: [['react-app', { flow: false, typescript: true }]], 8 | }, 9 | }], 10 | }); 11 | config.resolve.extensions.push('.ts', '.tsx'); 12 | return config; 13 | }; -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // next.config.js 2 | const withTypescript = require('@zeit/next-typescript') 3 | module.exports = withTypescript({ 4 | webpack: (config, { dev }) => { 5 | config.module.rules.push({ 6 | test: /\.tsx$/, 7 | exclude: /node_modules/, 8 | loader: 'eslint-loader', 9 | options: { 10 | emitWarning: dev, 11 | } 12 | }) 13 | return config 14 | } 15 | }) -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { addDecorator, addParameters, configure } from '@storybook/react' 2 | import { } from '@storybook/react'; 3 | import { checkA11y } from '@storybook/addon-a11y' 4 | import { withKnobs } from '@storybook/addon-knobs' 5 | 6 | const req = require.context('..', true, /.stories.tsx$/) 7 | function loadStories() { 8 | req.keys().forEach(filename => req(filename)) 9 | } 10 | 11 | 12 | // addDecorator((Story) => ) 13 | addDecorator(checkA11y); 14 | addDecorator(withKnobs); 15 | 16 | configure(loadStories, module) -------------------------------------------------------------------------------- /next-starter.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /modules/search/api.tsx: -------------------------------------------------------------------------------- 1 | export interface SearchGifsApiParams { 2 | query: string 3 | } 4 | 5 | export interface TenorMedia { 6 | tinygif: { 7 | url: string 8 | } 9 | } 10 | 11 | export interface TenorItem { 12 | media: TenorMedia[] 13 | } 14 | 15 | export interface TenorResponse { 16 | results: TenorItem[] 17 | } 18 | 19 | const apiKey = 'LYBXXK2VWWKU' 20 | 21 | export const searchGifs = ({ query }: SearchGifsApiParams): Promise => { 22 | const url = `http://api.tenor.com/v1/search?api_key=${apiKey}&q=${query}&limit=20` 23 | return fetch(url) 24 | .then(result => result.json()) 25 | } -------------------------------------------------------------------------------- /pages/about/about.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import React from 'react' 4 | import { render } from 'react-testing-library' 5 | 6 | import About from './index' 7 | 8 | describe('With React Testing Library', () => { 9 | it('Shows "Hello world!"', () => { 10 | const { getByText } = render() 11 | 12 | expect(getByText('About next-starter')).not.toBeNull() 13 | }) 14 | }) 15 | 16 | describe('With React Testing Library Snapshot', () => { 17 | it('Should match Snapshot', () => { 18 | const { asFragment } = render() 19 | 20 | expect(asFragment()).toMatchSnapshot() 21 | }) 22 | }) -------------------------------------------------------------------------------- /pages/index/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NextFunctionComponent } from 'next' 3 | import Head from 'next/head' 4 | import Link from 'next/link' 5 | 6 | import SearchGifs from '../../modules/search/search-gifs' 7 | 8 | const Home: NextFunctionComponent = (): JSX.Element => ( 9 |
10 | 11 | welcome | next-starter 12 | 13 |

Welcome to next-starter

14 | 15 |
16 | 17 | about 18 | 19 |
20 |
21 | ) 22 | 23 | export default Home 24 | -------------------------------------------------------------------------------- /cypress/integration/search.spec.ts: -------------------------------------------------------------------------------- 1 | // might be interesting: https://stackoverflow.com/questions/54937425/trying-to-make-cypress-typescript-and-istanbuljs-work-together 2 | 3 | describe('Search Feature', () => { 4 | it('has a search field', () => { 5 | cy.visit('localhost:3000') 6 | }) 7 | it('shows search query in input', () => { 8 | cy.visit('localhost:3000') 9 | cy.get('.search-field') 10 | .type('fake') 11 | .should('have.value', 'fake') 12 | }) 13 | it('shows search results', () => { 14 | cy.visit('localhost:3000') 15 | cy.get('.search-field') 16 | .type('fake') 17 | cy.get('.search-result-gif') 18 | .should('have.length', 20) 19 | }) 20 | }) -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import App, { Container, NextAppContext, DefaultAppIProps } from 'next/app' 3 | 4 | class MyApp extends App { 5 | static async getInitialProps({ Component, ctx }: NextAppContext): Promise { 6 | let pageProps = {} 7 | 8 | if (Component.getInitialProps) { 9 | pageProps = await Component.getInitialProps(ctx) 10 | } 11 | 12 | return { pageProps } 13 | } 14 | 15 | render(): ReactNode { 16 | const { Component, pageProps } = this.props 17 | 18 | return ( 19 | 20 | 21 | 22 | ) 23 | } 24 | } 25 | 26 | export default MyApp -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { NextFunctionComponent, NextContext } from 'next' 4 | 5 | type Props = { 6 | statusCode?: number 7 | message?: string 8 | } 9 | 10 | const Error: NextFunctionComponent = ({ statusCode, message }): JSX.Element => ( 11 | <> 12 |

13 | {statusCode 14 | ? `An error with code ${statusCode} occurred on server` 15 | : 'An error occurred on client'} 16 |

17 |

{message}

18 | 19 | ) 20 | 21 | Error.propTypes = { 22 | statusCode: PropTypes.number, 23 | message: PropTypes.string, 24 | } 25 | 26 | Error.getInitialProps = ({ res, err }: NextContext): Props => { 27 | const statusCode = res ? res.statusCode : undefined 28 | const message = (res ? res.statusMessage : undefined) || (err ? err.message : undefined) 29 | return { statusCode, message } 30 | } 31 | 32 | export default Error -------------------------------------------------------------------------------- /pages/about/__snapshots__/about.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`With React Testing Library Snapshot Should match Snapshot 1`] = ` 4 | 5 |
6 |

7 | About next-starter 8 |

9 |

10 | This next.js starter kit includes: 11 |

12 |
    13 |
  • 14 | typescript 15 |
  • 16 |
  • 17 | eslint 18 |
  • 19 |
  • 20 | prettier 21 |
  • 22 |
  • 23 | emotion 24 |
  • 25 |
  • 26 | immer 27 |
  • 28 |
  • 29 | jest 30 |
  • 31 |
  • 32 | cypress 33 |
  • 34 |
  • 35 | storybook 36 |
  • 37 |
  • 38 | husky 39 |
  • 40 |
41 |

42 | Your user agent: 43 |

44 | 47 | back 48 | 49 |
50 |
51 | `; 52 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | import 'cypress-testing-library/add-commands' -------------------------------------------------------------------------------- /modules/search/search-results/search-results.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NextFunctionComponent } from 'next' 3 | import { keyframes } from '@emotion/core' 4 | import styled from '@emotion/styled' 5 | 6 | import { useSearch } from '../store' 7 | 8 | const bounce = keyframes` 9 | from { 10 | transform: scale(1.01); 11 | } 12 | to { 13 | transform: scale(0.99); 14 | } 15 | ` 16 | 17 | const Loading = styled.p` 18 | animation: ${bounce} 0.18s infinite ease-in-out alternate; 19 | ` 20 | 21 | const SearchResults: NextFunctionComponent = (): JSX.Element => { 22 | const { isPending, results } = useSearch() 23 | 24 | return ( 25 | <> 26 | {isPending && ( 27 | Loading... 28 | )} 29 | {results.map(result => ( 30 | 31 | ))} 32 | {!results.length && !isPending && ( 33 |

34 | Nothing to show 35 |

36 | )} 37 | 38 | ) 39 | } 40 | 41 | export default SearchResults 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alper Ortac 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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": "src", 6 | "declaration": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "jsx": "react", 11 | "lib": ["es5", "es6", "es7", "es2017", "dom"], 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "noEmit": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noImplicitAny": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "preserveConstEnums": true, 21 | "outDir": "build/lib", 22 | "removeComments": false, 23 | "rootDirs": ["app", "modules", "pages"], 24 | "skipLibCheck": true, 25 | "sourceMap": true, 26 | "strict": true, 27 | "strictNullChecks": true, 28 | "suppressImplicitAnyIndexErrors": true, 29 | "target": "esnext", 30 | "types": ["node", "@emotion/core", "jest", "cypress"] 31 | }, 32 | "include": ["app/**/*", "modules/**/*", "pages/**/*"], 33 | "exclude": [".*", "cypress", "node_modules"] 34 | } -------------------------------------------------------------------------------- /modules/search/search-field/search-field.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { NextFunctionComponent } from 'next' 4 | import styled from '@emotion/styled' 5 | 6 | type Props = { 7 | onQueryChange?: Function 8 | } 9 | 10 | const Input = styled.input` 11 | padding: 0.2em; 12 | font-size: 1.2em; 13 | 14 | &:hover { 15 | border: 2px solid rgb(200, 200, 200); 16 | } 17 | ` 18 | 19 | const SearchField: NextFunctionComponent = ({ onQueryChange }): JSX.Element => { 20 | const onSearchChange = (e: React.ChangeEvent): void => { 21 | const query = e.target.value 22 | if (typeof onQueryChange === 'function') { 23 | onQueryChange({ query }) 24 | } 25 | } 26 | 27 | return ( 28 |
29 | 35 |
36 | ) 37 | } 38 | 39 | SearchField.propTypes = { 40 | onQueryChange: PropTypes.func 41 | } 42 | 43 | SearchField.defaultProps = { 44 | onQueryChange: () => {} 45 | } 46 | 47 | export default SearchField 48 | -------------------------------------------------------------------------------- /pages/about/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { NextFunctionComponent, NextContext } from 'next' 4 | import Head from 'next/head' 5 | import Link from 'next/link' 6 | 7 | type Props = { 8 | userAgent?: string 9 | } 10 | 11 | const About: NextFunctionComponent = ({ userAgent }): JSX.Element => ( 12 |
13 | 14 | about | next-starter 15 | 16 |

About next-starter

17 |

This next.js starter kit includes:

18 |
    19 |
  • typescript
  • 20 |
  • eslint
  • 21 |
  • prettier
  • 22 |
  • emotion
  • 23 |
  • immer
  • 24 |
  • jest
  • 25 |
  • cypress
  • 26 |
  • storybook
  • 27 |
  • husky
  • 28 |
29 |

Your user agent: {userAgent}

30 | 31 | back 32 | 33 |
34 | ) 35 | 36 | About.propTypes = { 37 | userAgent: PropTypes.string 38 | } 39 | 40 | About.getInitialProps = ({ req }: NextContext): Props => { 41 | const userAgent = req ? req.headers['user-agent'] : navigator.userAgent 42 | return { userAgent } 43 | } 44 | 45 | export default About 46 | -------------------------------------------------------------------------------- /app/hooks/useDebounce.tsx: -------------------------------------------------------------------------------- 1 | // use this for any type of value, that should be updated after the debounced delay 2 | // this is not suitable for callbacks. instead just debounce the value that is a 3 | // param to the callback and compose both hooks to perform the callback after the 4 | // debounced value has changed 5 | // 6 | // source: https://usehooks.com/useDebounce/ 7 | 8 | import { useState, useEffect } from "react" 9 | 10 | export default (value: any, delay: number) => { 11 | // State and setters for debounced value 12 | const [debouncedValue, setDebouncedValue] = useState(value) 13 | 14 | useEffect( 15 | () => { 16 | // Update debounced value after delay 17 | const handler = setTimeout(() => { 18 | setDebouncedValue(value) 19 | }, delay) 20 | 21 | // Cancel the timeout if value changes (also on delay change or unmount) 22 | // This is how we prevent debounced value from updating if value is changed ... 23 | // .. within the delay period. Timeout gets cleared and restarted. 24 | return () => { 25 | clearTimeout(handler) 26 | } 27 | }, 28 | [value, delay] // Only re-call effect if value or delay changes 29 | ) 30 | 31 | return debouncedValue 32 | } -------------------------------------------------------------------------------- /modules/search/store.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import createStore from '../../app/store/createStore' 4 | 5 | interface UseSearch { 6 | query: string 7 | isPending: boolean 8 | results: string[] 9 | setQuery: Function 10 | setIsPending: Function 11 | setResults: Function 12 | } 13 | 14 | // interface SearchStore { 15 | // query: string 16 | // pending: boolean 17 | // results: string[] 18 | // } 19 | 20 | const initialValue = { 21 | isPending: false, 22 | results: [], 23 | } 24 | 25 | const { useStore, Provider, withProvider } = createStore() 26 | 27 | export const useSearch = (): UseSearch => { 28 | const { useValue } = useStore() 29 | const [query, setQuery] = useValue('query') 30 | const [isPending, setIsPending] = useValue('isPending') 31 | const [results, setResults] = useValue('results') 32 | return { 33 | query, 34 | setQuery, 35 | isPending, 36 | setIsPending, 37 | results, 38 | setResults, 39 | } 40 | } 41 | 42 | export const SearchProvider = ({ children }: { children: React.ReactNode }): JSX.Element => ( 43 | {children} 44 | ) 45 | 46 | export const withSearchProvider = (ComposedComponent: React.ElementType): Function => ( 47 | withProvider(ComposedComponent, { initialValue }) 48 | ) 49 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 5 | 'ecmaFeatures': { 6 | 'jsx': true 7 | }, 8 | sourceType: 'module', // Allows for the use of imports 9 | }, 10 | 'settings': { 11 | 'react': { 12 | 'version': 'detect', 13 | }, 14 | }, 15 | extends: [ 16 | 'plugin:@typescript-eslint/recommended', 17 | 'plugin:react/recommended', 18 | 'plugin:jest/recommended', 19 | 'prettier', 20 | 'prettier/babel', 21 | 'prettier/react', 22 | 'prettier/@typescript-eslint' 23 | ], 24 | plugins: [ 25 | '@typescript-eslint', 26 | 'react', 27 | 'jest', 28 | ], 29 | rules: { 30 | 'semi': ['error', 'never'], 31 | '@typescript-eslint/indent': ['error', 2], 32 | '@typescript-eslint/explicit-member-accessibility': ['error', { 33 | accessibility: 'no-public', 34 | }], 35 | '@typescript-eslint/explicit-function-return-type': ['warn', { 36 | allowExpressions: true, 37 | }], 38 | '@typescript-eslint/member-delimiter-style': ['error', { 39 | 'multiline': { 40 | 'delimiter': 'none' 41 | }, 42 | }], 43 | '@typescript-eslint/no-explicit-any': 0, 44 | '@typescript-eslint/prefer-interface': 0, 45 | 'react/no-unescaped-entities': 0, 46 | }, 47 | }; -------------------------------------------------------------------------------- /modules/search/search-gifs/search-gifs.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { NextFunctionComponent } from 'next' 3 | 4 | import useDebounce from '../../../app/hooks/useDebounce' 5 | import { searchGifs } from '../api' 6 | import { withSearchProvider, useSearch } from '../store' 7 | import SearchField from '../search-field' 8 | import SearchResults from '../search-results' 9 | 10 | interface QueryChangeProps { 11 | query: string 12 | } 13 | 14 | const SearchGifs: NextFunctionComponent = (): JSX.Element => { 15 | const { query, setQuery, setIsPending, setResults } = useSearch() 16 | const debouncedQuery = useDebounce(query, 300) 17 | 18 | const queryChange = ({ query }: QueryChangeProps): void => { 19 | setIsPending(true) 20 | setQuery(query) 21 | } 22 | 23 | useEffect( 24 | () => { 25 | if (debouncedQuery) { 26 | searchGifs({ query }) 27 | .then((response) => { 28 | setIsPending(false) 29 | const gifs = response.results.map((item) => item.media[0].tinygif.url) 30 | setResults(gifs) 31 | }) 32 | } else { 33 | setIsPending(false) 34 | setResults([]) 35 | } 36 | }, 37 | [debouncedQuery] // Only call effect if debounced search term changes 38 | ) 39 | 40 | return ( 41 | <> 42 | 43 | 44 | 45 | ) 46 | } 47 | 48 | export default withSearchProvider(SearchGifs) 49 | -------------------------------------------------------------------------------- /app/utils/deep-equal.tsx: -------------------------------------------------------------------------------- 1 | // source: https://github.com/epoberezkin/fast-deep-equal/blob/master/index.js 2 | 3 | const isArray = Array.isArray 4 | const keyList = Object.keys 5 | const hasProp = Object.prototype.hasOwnProperty 6 | 7 | const deepEqual = (a: any, b: any): boolean => { 8 | if (a === b) return true 9 | 10 | if (a && b && typeof a == 'object' && typeof b == 'object') { 11 | const arrA = isArray(a) 12 | const arrB = isArray(b) 13 | let i 14 | let length 15 | let key 16 | 17 | if (arrA && arrB) { 18 | length = a.length 19 | if (length !== b.length) return false 20 | for (i = length; i-- !== 0;) { 21 | if (!deepEqual(a[i], b[i])) return false 22 | } 23 | return true 24 | } 25 | 26 | if (arrA != arrB) return false 27 | 28 | const dateA = a instanceof Date 29 | const dateB = b instanceof Date 30 | if (dateA != dateB) return false 31 | if (dateA && dateB) return a.getTime() == b.getTime() 32 | 33 | const regexpA = a instanceof RegExp 34 | const regexpB = b instanceof RegExp 35 | if (regexpA != regexpB) return false 36 | if (regexpA && regexpB) return a.toString() == b.toString() 37 | 38 | const keys = keyList(a) 39 | length = keys.length 40 | 41 | if (length !== keyList(b).length) return false 42 | 43 | for (i = length; i-- !== 0;) { 44 | if (!hasProp.call(b, keys[i])) return false 45 | } 46 | 47 | for (i = length; i-- !== 0;) { 48 | key = keys[i] 49 | if (!deepEqual(a[key], b[key])) return false 50 | } 51 | 52 | return true 53 | } 54 | 55 | return a !== a && b !== b 56 | } 57 | 58 | export default deepEqual -------------------------------------------------------------------------------- /modules/search/search-results/search-results.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import { boolean } from '@storybook/addon-knobs' 4 | 5 | import SearchResults from './search-results' 6 | import { useSearch, SearchProvider } from '../store' 7 | 8 | const exampleResults = [ 9 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAALklEQVR42u3OQREAAAjDsM2/aMAFn/QqIJ3k/qsAAAAAAAAAAAAAAAAAAADfgAWlvj/hJQLwQgAAAABJRU5ErkJggg==', 10 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAALUlEQVR42u3OMQEAAAjDMOZfNOBiT1oDyexfLAAAAAAAAAAAAAAAAAAAAG3AAYXeP+FHUlURAAAAAElFTkSuQmCC', 11 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAALElEQVR42u3OMQEAAAwCIO0feovhAwlocpehCggICAgICAgICAgICAgIrAMPZf4/4fUBWOEAAAAASUVORK5CYII=', 12 | ] 13 | 14 | storiesOf('SearchResults', module) 15 | .addDecorator(getStory => {getStory()}) 16 | .add('none', () => { 17 | return 18 | }) 19 | .add('with results', () => { 20 | const WithResults = () => { 21 | const { setResults } = useSearch() 22 | setResults(exampleResults) 23 | return 24 | } 25 | return 26 | }) 27 | .add('is pending', () => { 28 | const IsPending = () => { 29 | const { setIsPending } = useSearch() 30 | setIsPending(boolean('Pending', true)) 31 | return 32 | } 33 | return 34 | }) 35 | .add('is pending with results', () => { 36 | const IsPendingWithResults = () => { 37 | const { setIsPending, setResults } = useSearch() 38 | setIsPending(boolean('Pending', true)) 39 | setResults(exampleResults) 40 | return 41 | } 42 | return 43 | }) 44 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | // _document is only rendered on the server side and not on the client side 2 | // Event handlers like onClick can't be added to this file 3 | 4 | // ./pages/_document.js 5 | import React, { ReactNode } from 'react' 6 | import Document, { NextDocumentContext, DefaultDocumentIProps , RenderPageResponse, Html, Head, Main, NextScript} from 'next/document' 7 | import { css, Global } from '@emotion/core' 8 | 9 | class MyDocument extends Document { 10 | static async getInitialProps(ctx: NextDocumentContext): Promise { 11 | const originalRenderPage = ctx.renderPage 12 | 13 | ctx.renderPage = (): RenderPageResponse => 14 | originalRenderPage({ 15 | // useful for wrapping the whole react tree 16 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 17 | enhanceApp: App => App, 18 | // useful for wrapping in a per-page basis 19 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 20 | enhanceComponent: Component => Component 21 | }) 22 | 23 | // Run the parent `getInitialProps` using `ctx` that now includes our custom `renderPage` 24 | const initialProps = await Document.getInitialProps(ctx) 25 | return initialProps 26 | } 27 | 28 | render(): ReactNode { 29 | return ( 30 | 31 | 53 | 54 | 55 | 56 |
57 | 58 | 59 | 60 | ) 61 | } 62 | } 63 | 64 | export default MyDocument -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-starter", 3 | "description": "Next.js starter project with typescript, emotion, immer, hooks, eslint, prettier, jest, cypress and storybook", 4 | "version": "0.0.1", 5 | "author": "Alper Ortac", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "next", 9 | "build": "next build", 10 | "start": "next start", 11 | "test": "yarn run test:unit && yarn run test:e2e", 12 | "test:unit": "jest", 13 | "test:unit:coverage": "jest --coverage", 14 | "test:unit:update": "jest --updateSnapshot", 15 | "test:unit:ui": "majestic", 16 | "test:e2e": "cypress run", 17 | "test:e2e:ui": "cypress open", 18 | "lint:staged": "lint-staged", 19 | "typecheck": "tsc", 20 | "storybook": "start-storybook -p 6006", 21 | "storybook:build": "build-storybook" 22 | }, 23 | "dependencies": { 24 | "@emotion/core": "^10.0.10", 25 | "@emotion/styled": "^10.0.10", 26 | "@zeit/next-typescript": "^1.1.1", 27 | "immer": "^3.1.1", 28 | "next": "^8.0.4", 29 | "react": "^16.8.6", 30 | "react-dom": "^16.8.6", 31 | "use-immer": "^0.2.2" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.4.3", 35 | "@storybook/addon-a11y": "^5.0.10", 36 | "@storybook/addon-actions": "^5.0.10", 37 | "@storybook/addon-knobs": "^5.0.10", 38 | "@storybook/addon-viewport": "^5.0.10", 39 | "@storybook/react": "^5.0.10", 40 | "@types/jest": "^24.0.11", 41 | "@types/next": "^8.0.3", 42 | "@types/node": "^11.13.6", 43 | "@types/react": "^16.8.14", 44 | "@types/react-dom": "^16.8.4", 45 | "@types/storybook__addon-actions": "^3.4.2", 46 | "@types/storybook__addon-knobs": "^5.0.0", 47 | "@types/storybook__react": "^4.0.1", 48 | "@typescript-eslint/eslint-plugin": "^1.6.1-alpha.14", 49 | "@typescript-eslint/parser": "^1.6.0", 50 | "babel-eslint": "^10.0.1", 51 | "babel-jest": "^24.7.1", 52 | "babel-loader": "^8.0.5", 53 | "cypress": "^3.2.0", 54 | "cypress-testing-library": "^3.0.1", 55 | "eslint": "^5.16.0", 56 | "eslint-config-prettier": "^4.1.0", 57 | "eslint-config-react-app": "^3.0.8", 58 | "eslint-loader": "^2.1.2", 59 | "eslint-plugin-cypress": "^2.2.1", 60 | "eslint-plugin-flowtype": "^3.6.1", 61 | "eslint-plugin-import": "^2.17.1", 62 | "eslint-plugin-jest": "^22.4.1", 63 | "eslint-plugin-jsx-a11y": "^6.2.1", 64 | "eslint-plugin-prettier": "^3.0.1", 65 | "eslint-plugin-react": "^7.12.4", 66 | "husky": "^2.2.0", 67 | "jest": "^24.7.1", 68 | "lint-staged": "^8.1.6", 69 | "majestic": "^1.4.1", 70 | "prettier": "^1.17.0", 71 | "react-testing-library": "^6.1.2", 72 | "ts-jest": "^24.0.2", 73 | "typescript": "^3.4.4" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/store/createStore.tsx: -------------------------------------------------------------------------------- 1 | // create a store that can be used to share state between functional components 2 | // 3 | // source: https://blog.usejournal.com/react-hooks-the-rebirth-of-state-management-and-beyond-7d84f6026d87 4 | 5 | import React, { useContext, useMemo } from 'react' 6 | import PropTypes from 'prop-types' 7 | import { useImmer } from 'use-immer' 8 | import deepEqual from '../utils/deep-equal' 9 | 10 | interface UseStore { 11 | state: Record 12 | setState: Function 13 | useValue: (name: string) => [T, (value: T) => void] 14 | } 15 | 16 | export default () => { 17 | // Make a context for the store 18 | const initialContext = { state: {}, setState: (draft: Record) => draft } 19 | const context = React.createContext(initialContext) 20 | 21 | // Make a provider that takes an initialValue 22 | const Provider = ({ initialValue = {}, children }: { initialValue: any; children: React.ReactNode }): JSX.Element => { 23 | // Make a new state instance 24 | const [state, setState] = useImmer(initialValue) 25 | 26 | // Memoize the context value to update when the state does 27 | const contextValue = useMemo(() => ({ state, setState }), [state]) 28 | 29 | // Provide store to children 30 | return {children} 31 | } 32 | 33 | Provider.propTypes = { 34 | initialValue: PropTypes.any, 35 | children: PropTypes.oneOfType([ 36 | PropTypes.element, 37 | PropTypes.arrayOf(PropTypes.element) 38 | ]), 39 | } 40 | 41 | // a higher order component to wrap a component with the store provider 42 | const withProvider = (ComposedComponent: React.ElementType, { initialValue }: any): Function => { 43 | const WithProvider = (props: Record): JSX.Element => ( 44 | 45 | 46 | 47 | ) 48 | return WithProvider 49 | } 50 | 51 | // A hook to help consume the store 52 | // useStore returns an object with { 53 | // state, 54 | // setState, 55 | // useValue, 56 | // } 57 | // whereas state returns an object 58 | // setState uses an immer draft 59 | // and useValue returns getter and setter for a specific attribute 60 | const useStore = (): UseStore => { 61 | const { state, setState } = useContext(context) 62 | 63 | const useValue = (name: string): [T, (value: T) => void] => { 64 | const value: T = useMemo( 65 | (): T => state[name], 66 | [state[name]] 67 | ) 68 | 69 | const setValue = (value: T): void => { 70 | if (deepEqual(value, state[name])) return 71 | setState((draft: Record): void => { 72 | draft[name] = value 73 | }) 74 | } 75 | 76 | return [value, setValue] 77 | } 78 | 79 | return { state, setState, useValue } 80 | } 81 | 82 | return { useStore, Provider, withProvider } 83 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-starter 2 | Next.js starter project with typescript, hooks, eslint, prettier, immer, emotion, jest, cypress and storybook 3 | 4 | ## Features 5 | * Recommended defaults for typing and linting 6 | * Store without a library by using react hooks and context api 7 | * Immer integration for easy updates of immutable data 8 | * CSS-in-JS with native CSS syntax powered by Emotion 9 | * Unit tests with Jest and TypeScript 10 | * Integration tests with Cypress and TypeScript 11 | * Nice UI's for both test frameworks with real-time watchers via native Cypress and Majestic 12 | * Storybook with Typescript, React and Hook support 13 | * Git hooks to prevent committing and pushing bad code 14 | 15 | ## Getting started 16 | * Clone this repository and `cd` into it 17 | * `yarn install` 18 | * `yarn run dev` 19 | 20 | ## All commands 21 | 22 | ### `yarn run dev` 23 | Runs development server on http://localhost:3000. Page is hot reloading on code changes. 24 | 25 | ### `yarn run build` 26 | Build application for production use. 27 | 28 | ### `yarn run start` 29 | Start built application. You need to run `build` first. 30 | 31 | ### `yarn run test` 32 | Run all tests. 33 | 34 | ### `yarn run test:unit` 35 | Run all unit tests with jest. 36 | 37 | ### `yarn run test:unit:coverage` 38 | Run all unit tests with jest and generate coverage reports. This will fail if constraints `coverageThreshold` in `jest.config.js` are violated. 39 | 40 | ### `yarn run test:unit:update` 41 | Run all unit tests with jest and update all outdated snapshots. 42 | 43 | ### `yarn run test:unit:ui` 44 | Run majestic server to control jest via its UI. 45 | 46 | ### `yarn run test:e2e` 47 | Run all integration tests with headless cypress. 48 | 49 | ### `yarn run test:e2e:ui` 50 | Run all integration tests with cypress UI. 51 | 52 | ### `yarn run lint:staged` 53 | Run all linters and autofix issues on staged files. 54 | 55 | ### `yarn run typecheck` 56 | Perform type analysis on all source files. 57 | 58 | ### `yarn run storybook` 59 | Run Storybook server. 60 | 61 | ### `yarn run storybook:build` 62 | Build Storybook documentation for production use. 63 | 64 | ## Built upon fabulous libraries and tools 65 | 66 | This starter pack has solutions for Rendering, Styling, Routing, Server-Side-Rendering, Type checking, Linting, Testing, Documentation and Building 67 | 68 | ### [Next.js](https://github.com/zeit/next.js/https://github.com/zeit/next.js/) 69 | A great starting point that provides awesome features out-of-the-box. Hightlights are server-side-rendering, seo-readiness, code splitting, routing and prefetching support. 70 | 71 | ### [TypeScript](https://github.com/Microsoft/TypeScript) 72 | The addition of types to JavaScript allows code-completion and better static code analysis. 73 | 74 | ### [React](https://github.com/facebook/react/) 75 | Rendering library that makes component-based development easy and fun. Functional components in combination with hooks and context API allow flexible adjustments to many application needs. 76 | 77 | In this starter project, hooks and context are used to create a simple and versatile store. 78 | 79 | *Implicitly used by Next.js* 80 | 81 | ### [Immer](https://github.com/immerjs/immer) 82 | Very easy to use immutable state. Used for custom store implementation. 83 | 84 | ### [Emotion](https://github.com/emotion-js/emotion) 85 | Full fledged CSS-in-JS solution with native CSS syntax. 86 | 87 | ### [Jest](https://github.com/facebook/jest) 88 | Testing framework with many built-in features like code coverage or snaptshot testing. 89 | 90 | [react-testing-library](https://github.com/testing-library/react-testing-library) is included to provide a lightweight toolset for testing react components. It's a great alternative to enzyme. 91 | 92 | Additionally, [Majestic](https://github.com/Raathigesh/majestic/) is used as a UI for Jest. 93 | 94 | ### [Cypress](https://github.com/cypress-io/cypress) 95 | For Browser-based integration tests. Watches source code and automatically executes all tests on every change. With time-travel and real debugging, also records videos for failing tests. 96 | 97 | [cypress-testing-library](https://github.com/testing-library/cypress-testing-library) is another lightweight toolset to query dom elements. 98 | 99 | ### [Storybook](https://github.com/storybooks/storybook) 100 | Visualize isolated components for development and testing. Also for creating style guides. Addons provide additional benefits like switching between mobile viewports or automatic a11y profiling. 101 | 102 | ### [ESLint](https://github.com/eslint/eslint) 103 | Parse source code and find unwanted patterns. Great way to establish best practices and a common code style. 104 | 105 | ### [Prettier](https://github.com/prettier/prettier) 106 | Complements ESLint to support a consistent and aesthetic code style. Most formatting issues can be automatically fixed. 107 | 108 | ### [Husky](https://github.com/typicode/husky) 109 | Handy git hooks to prevent bad `git commit` or `git push`. 110 | 111 | ### [Babel](https://github.com/babel/babel) 112 | Foundation of every modern JavaScript application stack. Compiles next-generation code into natively supported code. 113 | 114 | *Implicitly used by Next.js* 115 | 116 | 117 | --------------------------------------------------------------------------------