├── Procfile ├── src ├── config.ts ├── types.d.ts ├── appHistory.ts ├── components │ ├── FormElementLink │ │ └── index.ts │ ├── FormButton │ │ └── index.ts │ ├── Navigation │ │ ├── styled.ts │ │ └── index.tsx │ ├── Layout │ │ └── index.ts │ ├── Content │ │ └── index.ts │ ├── Textarea │ │ ├── styled.ts │ │ └── index.tsx │ ├── Typography │ │ ├── H1.tsx │ │ └── H3.tsx │ ├── ErrorAlert │ │ ├── styled.ts │ │ └── index.tsx │ ├── FormElementError │ │ └── index.ts │ ├── Loader │ │ └── index.tsx │ ├── FormElementDescription │ │ └── index.ts │ ├── Button │ │ └── index.tsx │ ├── TextInput │ │ ├── styled.ts │ │ └── index.tsx │ ├── Empty │ │ └── index.tsx │ └── FormElementLabel │ │ └── index.ts ├── styles │ ├── mediaQueries.ts │ └── index.ts ├── modules │ ├── blog │ │ ├── components │ │ │ ├── PagesList │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── PageCard │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ └── RelevantPagesList │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ ├── hooks │ │ │ ├── useUpdatePage.ts │ │ │ ├── usePageDetail.ts │ │ │ ├── useCreatePage.ts │ │ │ └── useDeletePage.ts │ │ ├── gql │ │ │ ├── __generated__ │ │ │ │ ├── ListPages.ts │ │ │ │ ├── DeletePage.ts │ │ │ │ ├── CreatePage.ts │ │ │ │ ├── UpdatePage.ts │ │ │ │ └── PageDetail.ts │ │ │ └── index.ts │ │ ├── cache │ │ │ └── updateListPages.ts │ │ └── forms │ │ │ └── Page │ │ │ └── index.tsx │ ├── auth │ │ ├── hooks │ │ │ ├── useMe.ts │ │ │ ├── useLogin.ts │ │ │ ├── useRegister.ts │ │ │ ├── useChangePassword.ts │ │ │ └── useForgotPassword.ts │ │ ├── gql │ │ │ ├── __generated__ │ │ │ │ ├── AccessToken.ts │ │ │ │ ├── ForgotPassword.ts │ │ │ │ ├── Login.ts │ │ │ │ ├── Register.ts │ │ │ │ ├── Me.ts │ │ │ │ └── ChangePassword.ts │ │ │ └── index.ts │ │ └── forms │ │ │ ├── ForgotPassword │ │ │ └── index.tsx │ │ │ ├── ChangePassword │ │ │ └── index.tsx │ │ │ ├── Login │ │ │ └── index.tsx │ │ │ └── Register │ │ │ └── index.tsx │ └── router │ │ ├── previousLocation.ts │ │ └── routes │ │ └── Protected.tsx ├── pages │ ├── Auth │ │ ├── Me │ │ │ ├── styled.ts │ │ │ ├── index.tsx │ │ │ └── test │ │ │ │ └── Me.test.tsx │ │ ├── PasswordForgot │ │ │ ├── index.tsx │ │ │ └── test │ │ │ │ └── PasswordForgot.test.tsx │ │ ├── Login │ │ │ ├── index.tsx │ │ │ └── test │ │ │ │ └── Login.test.tsx │ │ ├── Register │ │ │ ├── index.tsx │ │ │ └── test │ │ │ │ └── Register.test.tsx │ │ ├── Logout │ │ │ └── index.ts │ │ └── PasswordReset │ │ │ ├── index.tsx │ │ │ └── test │ │ │ └── PasswordReset.test.tsx │ ├── NotFound │ │ ├── index.tsx │ │ └── test │ │ │ └── NotFound.test.tsx │ ├── Home │ │ ├── index.tsx │ │ └── test │ │ │ └── Home.test.tsx │ └── Blog │ │ ├── Detail │ │ ├── styled.ts │ │ └── index.tsx │ │ ├── Edit │ │ ├── test │ │ │ └── Edit.test.tsx │ │ └── index.tsx │ │ └── Create │ │ └── index.tsx ├── services │ ├── messages │ │ └── index.ts │ ├── auth │ │ └── index.ts │ └── token │ │ └── index.ts ├── test-utils │ ├── ApolloProvider.tsx │ ├── render.tsx │ ├── form │ │ └── input.ts │ ├── setup.ts │ ├── generators │ │ └── index.ts │ └── gql │ │ └── index.ts ├── app.tsx ├── index.html ├── index.tsx ├── globalTypes.ts ├── apolloClient.ts └── routes.tsx ├── .eslintignore ├── .gitignore ├── .prettierrc.js ├── __mocks__ └── styleMock.ts ├── .env.development ├── server ├── .babelrc ├── config.ts ├── .eslintrc.js ├── prod.ts ├── dev.ts └── middleware │ ├── prod.ts │ └── dev.ts ├── commitlint.config.js ├── .huskyrc ├── .lintstagedrc ├── .stylelintrc ├── docker-compose.yml ├── .browserslistrc ├── apollo.config.js ├── .eslintrc.js ├── .mergify.yml ├── nodemon.json ├── .babelrc ├── .dependabot └── config.yml ├── webpack ├── config.js ├── client │ ├── prod.js │ ├── dev.js │ └── common.js └── server │ └── prod.js ├── .graphqlconfig ├── tsconfig.json ├── .editorconfig ├── Dockerfile ├── jest.config.js ├── .github └── workflows │ └── main.yml ├── app.json ├── node-type-orm.graphql ├── README.md └── package.json /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run prod 2 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const { SERVER_URL } = process.env 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /webpack 2 | /node_modules 3 | /build 4 | /coverage 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /build 3 | /coverage 4 | /node_modules 5 | /dist 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@code-quality/prettier-config') 2 | -------------------------------------------------------------------------------- /__mocks__/styleMock.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | export default {} 3 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | SERVER_URL=https://node-type-orm-graphql.herokuapp.com/graphql 2 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-lines-ellipsis' 2 | declare module 'react-nl2br' 3 | -------------------------------------------------------------------------------- /server/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/typescript", 4 | "@babel/env" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@code-quality/commitlint-config', 4 | ], 5 | } 6 | -------------------------------------------------------------------------------- /src/appHistory.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history' 2 | 3 | export const browserHistory = createBrowserHistory() 4 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 4 | "pre-commit": "lint-staged" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx}": [ 3 | "eslint --fix", 4 | "prettier --write", 5 | "stylelint", 6 | "git add" 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@code-quality/stylelint-styled-components-config", 4 | "stylelint-config-prettier" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/components/FormElementLink/index.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const ElementLink = styled.div` 4 | margin: 10px 0; 5 | ` 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | web: 4 | container_name: react_apollo_graphql 5 | build: . 6 | ports: 7 | - "3000:3000" 8 | -------------------------------------------------------------------------------- /src/styles/mediaQueries.ts: -------------------------------------------------------------------------------- 1 | export const mediaQueries = { 2 | md: `@media (min-width: ${768 / 16}rem)`, 3 | lg: `@media (min-width: ${1224 / 16}rem)`, 4 | } 5 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | [production] 2 | >0.2% 3 | not dead 4 | not op_mini all 5 | 6 | [development] 7 | last 1 chrome version 8 | last 1 firefox version 9 | last 1 safari version 10 | -------------------------------------------------------------------------------- /apollo.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | client: { 3 | service: { 4 | name: 'node-type-orm-graphql', 5 | localSchemaFile: 'node-type-orm.graphql', 6 | }, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /src/components/FormButton/index.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Button } from '../Button' 3 | 4 | export const FormButton = styled(Button)` 5 | margin-top: 20px; 6 | ` 7 | -------------------------------------------------------------------------------- /src/components/Navigation/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Menu } from 'antd' 3 | 4 | export const StyledMenu = styled(Menu)` 5 | line-height: 64px !important; 6 | ` 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@code-quality/eslint-config-react', 4 | '@code-quality/eslint-config-typescript', 5 | 'prettier', 6 | 'prettier/react' 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/blog/components/PagesList/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const Container = styled.div` 4 | > div + div { 5 | margin-top: 25px !important; 6 | } 7 | ` 8 | -------------------------------------------------------------------------------- /src/components/Layout/index.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Layout as AntLayout } from 'antd' 3 | 4 | export const Layout = styled(AntLayout)` 5 | min-height: 100% !important; 6 | ` 7 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: automatic merge for Dependabot pull requests 3 | conditions: 4 | - author=dependabot-preview[bot] 5 | actions: 6 | merge: 7 | method: merge 8 | -------------------------------------------------------------------------------- /src/components/Content/index.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Layout } from 'antd' 3 | 4 | export const Content = styled(Layout.Content)` 5 | min-height: 100%; 6 | padding: 65px 50px; 7 | ` 8 | -------------------------------------------------------------------------------- /src/components/Textarea/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Input as AntInput } from 'antd' 3 | 4 | export const StyledTextArea = styled(AntInput.TextArea)` 5 | margin-top: 5px !important; 6 | ` 7 | -------------------------------------------------------------------------------- /src/modules/auth/hooks/useMe.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/react-hooks' 2 | import { ME_QUERY } from '../gql' 3 | import { Me } from '../gql/__generated__/Me' 4 | 5 | export const useMe = () => useQuery(ME_QUERY) 6 | -------------------------------------------------------------------------------- /src/pages/Auth/Me/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const Info = styled.div` 4 | display: block; 5 | font-size: 16px; 6 | 7 | & + & { 8 | margin-top: 5px; 9 | } 10 | ` 11 | -------------------------------------------------------------------------------- /src/components/Typography/H1.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from 'antd' 2 | import React, { FC } from 'react' 3 | 4 | export const H1: FC = ({ children }) => ( 5 | {children} 6 | ) 7 | -------------------------------------------------------------------------------- /src/components/Typography/H3.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from 'antd' 2 | import React, { FC } from 'react' 3 | 4 | export const H3: FC = ({ children }) => ( 5 | {children} 6 | ) 7 | -------------------------------------------------------------------------------- /src/components/ErrorAlert/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Alert as AntAlert } from 'antd' 3 | 4 | export const Alert = styled(AntAlert)` 5 | padding: 25px !important; 6 | font-size: 16px !important; 7 | ` 8 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "node_modules" 4 | ], 5 | "watch": [ 6 | "server", 7 | "webpack", 8 | "yarn.lock" 9 | ], 10 | "exec": "babel-node --extensions '.ts' ./server/dev.ts", 11 | "ext": "ts" 12 | } 13 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/react", 4 | "@babel/typescript", 5 | [ 6 | "@babel/env", 7 | { 8 | "modules": false 9 | } 10 | ] 11 | ], 12 | "plugins": ["@babel/plugin-transform-runtime"] 13 | } 14 | -------------------------------------------------------------------------------- /src/components/FormElementError/index.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const FormElementError = styled.span` 4 | font-size: 14px; 5 | line-height: 1.5; 6 | color: #f5222d; 7 | display: block; 8 | margin-top: 5px; 9 | ` 10 | -------------------------------------------------------------------------------- /src/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Spin } from 'antd' 3 | 4 | export const COMPONENT_LOADER_TEST_ID = 'loader-component' 5 | 6 | export const Loader = () => ( 7 | 8 | ) 9 | -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | 4 | update_configs: 5 | - package_manager: "javascript" 6 | directory: "/" 7 | update_schedule: "daily" 8 | version_requirement_updates: increase_versions 9 | commit_message: 10 | prefix: "chore" 11 | -------------------------------------------------------------------------------- /webpack/config.js: -------------------------------------------------------------------------------- 1 | const PORT = process.env.PORT || 3000 2 | const BUILD_DIR = process.env.BUILD_DIR || 'build' 3 | const BUILD_DIR_PUBLIC = process.env.BUILD_DIR_PUBLIC || 'public' 4 | 5 | module.exports = { 6 | PORT, 7 | BUILD_DIR, 8 | BUILD_DIR_PUBLIC, 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/router/previousLocation.ts: -------------------------------------------------------------------------------- 1 | import { Location as HistoryLocation } from 'history' 2 | 3 | export const previousLocation = ( 4 | routerLocation: HistoryLocation, 5 | defaultPath = '/me' 6 | ) => routerLocation.state && (routerLocation.state.from || defaultPath) 7 | -------------------------------------------------------------------------------- /src/modules/auth/hooks/useLogin.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@apollo/react-hooks' 2 | import { LOGIN_MUTATION } from '../gql' 3 | import { Login, LoginVariables } from '../gql/__generated__/Login' 4 | 5 | export const useLogin = () => useMutation(LOGIN_MUTATION) 6 | -------------------------------------------------------------------------------- /src/styles/index.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components' 2 | 3 | const GlobalStyles = createGlobalStyle` 4 | html { 5 | height: 100%; 6 | } 7 | 8 | #root { 9 | width: 100%; 10 | height: 100%; 11 | } 12 | ` 13 | 14 | export { GlobalStyles } 15 | -------------------------------------------------------------------------------- /src/pages/NotFound/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Empty } from 'components/Empty' 3 | 4 | export const PAGE_NOT_FOUND_TEST_ID = 'home-page' 5 | 6 | export const NotFoundPage = () => ( 7 |
8 | 9 |
10 | ) 11 | -------------------------------------------------------------------------------- /src/components/FormElementDescription/index.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const Description = styled.span` 4 | font-size: 14px; 5 | color: rgba(0, 0, 0, 0.25); 6 | font-variant: tabular-nums; 7 | display: block; 8 | line-height: 16px; 9 | margin-top: 10px; 10 | ` 11 | -------------------------------------------------------------------------------- /src/services/messages/index.ts: -------------------------------------------------------------------------------- 1 | import { message } from 'antd' 2 | import { GraphQLError } from 'graphql' 3 | 4 | export const showAllGraphQLErrors = (errors: GraphQLError[]) => { 5 | for (const error of errors) { 6 | // eslint-disable-next-line 7 | message.error(error.message) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/auth/hooks/useRegister.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@apollo/react-hooks' 2 | import { Register, RegisterVariables } from '../gql/__generated__/Register' 3 | import { REGISTER_MUTATION } from '../gql' 4 | 5 | export const useRegister = () => 6 | useMutation(REGISTER_MUTATION) 7 | -------------------------------------------------------------------------------- /server/config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | export const PORT = process.env.PORT || 3000 4 | 5 | export const BUILD_DIR_PUBLIC = process.env.BUILD_DIR_PUBLIC || 'public' 6 | 7 | export const PATH_TO_BUILD_DIR_PUBLIC = 8 | process.env.PATH_TO_BUILD_DIR_PUBLIC || 9 | path.resolve(__dirname, BUILD_DIR_PUBLIC) 10 | -------------------------------------------------------------------------------- /src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Button as AntButton } from 'antd' 3 | import { ButtonProps } from 'antd/lib/button' 4 | 5 | export const Button: FC = ({ children, ...rest }) => ( 6 | 7 | {children} 8 | 9 | ) 10 | -------------------------------------------------------------------------------- /src/components/TextInput/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Input as AntInput, Icon as AntIcon } from 'antd' 3 | 4 | export const Input = styled(AntInput)` 5 | margin-top: 5px !important; 6 | ` 7 | 8 | export const Icon = styled(AntIcon)` 9 | color: rgba(0, 0, 0, 0.25) !important; 10 | ` 11 | -------------------------------------------------------------------------------- /.graphqlconfig: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Node Type ORM GraphQL Schema", 3 | "schemaPath": "node-type-orm.graphql", 4 | "extensions": { 5 | "endpoints": { 6 | "Default Endpoint": { 7 | "url": "https://node-type-orm-graphql.herokuapp.com/graphql", 8 | "introspect": true 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@code-quality/eslint-config-node', 4 | '@code-quality/eslint-config-typescript', 5 | ], 6 | settings: { 7 | 'import/resolver': { 8 | node: { 9 | paths: [ 10 | 'server', 11 | ], 12 | }, 13 | }, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Empty/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Empty as AntEmpty } from 'antd' 3 | 4 | export const COMPONENT_EMPTY_TEST_ID = 'error-alert-component' 5 | 6 | export const Empty = () => ( 7 | 11 | ) 12 | -------------------------------------------------------------------------------- /src/modules/blog/hooks/useUpdatePage.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@apollo/react-hooks' 2 | import { 3 | UpdatePage, 4 | UpdatePageVariables, 5 | } from '../gql/__generated__/UpdatePage' 6 | import { UPDATE_PAGE_MUTATION } from '../gql' 7 | 8 | export const useUpdatePage = () => 9 | useMutation(UPDATE_PAGE_MUTATION) 10 | -------------------------------------------------------------------------------- /src/pages/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { H1 } from 'components/Typography/H1' 3 | import { PagesList } from 'modules/blog/components/PagesList' 4 | 5 | export const PAGE_HOME_TEST_ID = 'home-page' 6 | 7 | export const HomePage = () => ( 8 |
9 |

All Pages

10 | 11 |
12 | ) 13 | -------------------------------------------------------------------------------- /src/components/FormElementLabel/index.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Form } from 'antd' 3 | 4 | export const FormElementLabel = styled(Form.Item)<{ isHidden?: boolean }>` 5 | display: ${props => (props.isHidden ? 'none' : 'block')}; 6 | margin-bottom: 0 !important; 7 | 8 | & + & { 9 | display: block; 10 | margin-top: 15px; 11 | } 12 | ` 13 | -------------------------------------------------------------------------------- /src/modules/auth/hooks/useChangePassword.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@apollo/react-hooks' 2 | import { CHANGE_PASSWORD_MUTATION } from '../gql' 3 | import { 4 | ChangePassword, 5 | ChangePasswordVariables, 6 | } from '../gql/__generated__/ChangePassword' 7 | 8 | export const useChangePassword = () => 9 | useMutation(CHANGE_PASSWORD_MUTATION) 10 | -------------------------------------------------------------------------------- /src/modules/auth/hooks/useForgotPassword.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@apollo/react-hooks' 2 | import { FORGOT_PASSWORD_MUTATION } from '../gql' 3 | import { 4 | ForgotPassword, 5 | ForgotPasswordVariables, 6 | } from '../gql/__generated__/ForgotPassword' 7 | 8 | export const useForgotPassword = () => 9 | useMutation(FORGOT_PASSWORD_MUTATION) 10 | -------------------------------------------------------------------------------- /src/components/ErrorAlert/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Alert } from './styled' 3 | 4 | export const COMPONENT_ERROR_ALERT_TEST_ID = 'error-alert-component' 5 | 6 | export const ErrorAlert: FC = ({ children }) => ( 7 | 12 | ) 13 | -------------------------------------------------------------------------------- /src/modules/blog/components/PageCard/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Card as AntCard } from 'antd' 3 | import { Link } from 'react-router-dom' 4 | 5 | export const Card = styled(AntCard)` 6 | margin-top: 0 !important; 7 | 8 | & + & { 9 | margin-top: 25px; 10 | } 11 | ` 12 | 13 | export const CardLink = styled(Link)` 14 | margin-top: 25px; 15 | display: block; 16 | ` 17 | -------------------------------------------------------------------------------- /src/pages/Blog/Detail/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const DetailContainer = styled.div` 4 | background-color: white; 5 | margin-left: calc(50% - 50vw); 6 | margin-right: calc(50% - 50vw); 7 | padding: 25px 50px; 8 | ` 9 | 10 | export const ControlButtonsContainer = styled.div` 11 | margin-top: 25px; 12 | 13 | a + button { 14 | margin-left: 20px; 15 | } 16 | ` 17 | -------------------------------------------------------------------------------- /src/test-utils/ApolloProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { MockedProvider, MockedResponse } from '@apollo/react-testing' 3 | 4 | interface IProps { 5 | mocks: MockedResponse[] 6 | children: JSX.Element 7 | } 8 | 9 | export const ApolloProvider: FC = ({ mocks, children }) => ( 10 | 11 | {children} 12 | 13 | ) 14 | -------------------------------------------------------------------------------- /src/modules/blog/hooks/usePageDetail.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/react-hooks' 2 | import { 3 | PageDetail, 4 | PageDetailVariables, 5 | } from '../gql/__generated__/PageDetail' 6 | import { PAGE_DETAIL_QUERY } from '../gql' 7 | 8 | export const usePageDetail = ({ pageId }: { pageId: number }) => 9 | useQuery(PAGE_DETAIL_QUERY, { 10 | variables: { id: pageId }, 11 | }) 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "module": "commonjs", 7 | "target": "es6", 8 | "jsx": "react", 9 | "allowSyntheticDefaultImports": true, 10 | "baseUrl": "src", 11 | "esModuleInterop": true, 12 | "skipLibCheck": true 13 | }, 14 | "include": [ 15 | "**/*.ts", 16 | "**/*.tsx" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs. 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # We recommend you to keep these unchanged. 10 | charset = utf-8 11 | end_of_line = lf 12 | indent_size = 2 13 | indent_style = space 14 | insert_final_newline = true 15 | trim_trailing_whitespace = true 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /src/pages/Auth/PasswordForgot/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { H1 } from 'components/Typography/H1' 3 | import { ForgotPasswordForm } from 'modules/auth/forms/ForgotPassword' 4 | 5 | export const PAGE_PASSWORD_FORGOT_TEST_ID = 'password-forgot-page' 6 | 7 | export const PasswordForgotPage = () => ( 8 |
9 |

Forgotten Password

10 | 11 |
12 | ) 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | ENV NODE_ENV='production' 4 | ENV SERVER_URL='https://node-type-orm-graphql.herokuapp.com/graphql' 5 | 6 | # Create app directory 7 | RUN mkdir -p /usr/src/app 8 | WORKDIR /usr/src/app 9 | 10 | # Install app dependencies 11 | COPY package.json /usr/src/app/ 12 | COPY yarn.lock /usr/src/app/ 13 | RUN yarn install 14 | 15 | # Bundle app source 16 | COPY . /usr/src/app 17 | RUN yarn build 18 | EXPOSE 3000 19 | CMD [ "yarn", "prod" ] 20 | -------------------------------------------------------------------------------- /src/modules/auth/gql/__generated__/AccessToken.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | // ==================================================== 6 | // GraphQL query operation: AccessToken 7 | // ==================================================== 8 | 9 | export interface AccessToken { 10 | accessToken: string 11 | } 12 | 13 | export interface AccessTokenVariables { 14 | refreshToken: string 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/Auth/Login/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { RouteComponentProps } from 'react-router' 3 | import { H1 } from 'components/Typography/H1' 4 | import { LoginForm } from 'modules/auth/forms/Login' 5 | 6 | export const PAGE_LOGIN_TEST_ID = 'login-page' 7 | 8 | export const LoginPage: FC = ({ history }) => ( 9 |
10 |

Login Page

11 | 12 |
13 | ) 14 | -------------------------------------------------------------------------------- /src/modules/auth/gql/__generated__/ForgotPassword.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | // ==================================================== 6 | // GraphQL mutation operation: ForgotPassword 7 | // ==================================================== 8 | 9 | export interface ForgotPassword { 10 | forgotPassword: boolean 11 | } 12 | 13 | export interface ForgotPasswordVariables { 14 | email: string 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/NotFound/test/NotFound.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { PAGE_NOT_FOUND_TEST_ID } from '../index' 3 | import { renderWithRouter } from 'test-utils/render' 4 | import { App } from 'app' 5 | 6 | describe('[page] NotFound', () => { 7 | it('should render correctly', () => { 8 | const renderer = renderWithRouter(, '/does-not-exist') 9 | const renderedElement = renderer.getByTestId(PAGE_NOT_FOUND_TEST_ID) 10 | expect(renderedElement).toBeTruthy() 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { GlobalStyles } from 'styles' 3 | import { Navigation } from 'components/Navigation' 4 | import { Layout } from 'components/Layout' 5 | import { Content } from 'components/Content' 6 | import { Routes } from 'routes' 7 | import 'antd/dist/antd.css' 8 | 9 | export const App = () => ( 10 | <> 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | -------------------------------------------------------------------------------- /src/pages/Auth/Register/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { RouteComponentProps } from 'react-router' 3 | import { H1 } from 'components/Typography/H1' 4 | import { RegisterForm } from 'modules/auth/forms/Register' 5 | 6 | export const PAGE_REGISTER_TEST_ID = 'register-page' 7 | 8 | export const RegisterPage: FC = ({ history }) => ( 9 |
10 |

Register Page

11 | 12 |
13 | ) 14 | -------------------------------------------------------------------------------- /src/modules/blog/gql/__generated__/ListPages.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | // ==================================================== 6 | // GraphQL query operation: ListPages 7 | // ==================================================== 8 | 9 | export interface ListPages_listPages { 10 | __typename: 'Page' 11 | id: string 12 | title: string 13 | text: string 14 | } 15 | 16 | export interface ListPages { 17 | listPages: ListPages_listPages[] 18 | } 19 | -------------------------------------------------------------------------------- /server/prod.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import express from 'express' 3 | import { PATH_TO_BUILD_DIR_PUBLIC, PORT } from './config' 4 | import { 5 | handleCompression, 6 | handleHttpsRedirect, 7 | handleServeBaseRoute, 8 | } from './middleware/prod' 9 | 10 | const app = express() 11 | 12 | app.use(express.static(PATH_TO_BUILD_DIR_PUBLIC)) 13 | 14 | handleHttpsRedirect(app) 15 | handleCompression(app) 16 | handleServeBaseRoute(app) 17 | 18 | app.listen(PORT, () => { 19 | console.info('Express is listening on PORT %s.', PORT) 20 | }) 21 | -------------------------------------------------------------------------------- /webpack/client/prod.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const merge = require('webpack-merge') 3 | const common = require('./common') 4 | 5 | module.exports = merge(common, { 6 | mode: 'production', 7 | entry: './src/index.tsx', 8 | plugins: [ 9 | new webpack.DefinePlugin({ 10 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 11 | 'process.env.SERVER_URL': JSON.stringify(process.env.SERVER_URL), 12 | }), 13 | ], 14 | optimization: { 15 | splitChunks: { 16 | chunks: 'all', 17 | }, 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /src/test-utils/render.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from '@testing-library/react' 3 | import { MemoryRouter } from 'react-router-dom' 4 | import { MockedResponse } from '@apollo/react-testing' 5 | import { ApolloProvider } from './ApolloProvider' 6 | 7 | export const renderWithRouter = ( 8 | component: JSX.Element, 9 | uri: string, 10 | mocks: MockedResponse[] = [] 11 | ) => 12 | render( 13 | 14 | {component} 15 | 16 | ) 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | modulePaths: ['src'], 5 | setupFilesAfterEnv: ['/src/test-utils/setup.ts'], 6 | moduleFileExtensions: ['ts', 'tsx', 'js'], 7 | verbose: true, 8 | coverageReporters: ['json', 'lcov', 'text'], 9 | collectCoverageFrom: [ 10 | 'src/**/*.{ts,tsx}', 11 | ], 12 | moduleNameMapper: { 13 | '\\.(css|less)$': '/__mocks__/styleMock.ts', 14 | }, 15 | globals: { 16 | 'ts-jest': { 17 | 'diagnostics': false, 18 | }, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /server/dev.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import express from 'express' 3 | import { PATH_TO_BUILD_DIR_PUBLIC, PORT } from './config' 4 | import { 5 | handleServeBaseRouteDev, 6 | handleWebpackDevServer, 7 | } from './middleware/dev' 8 | 9 | const app = express() 10 | 11 | app.use(express.static(PATH_TO_BUILD_DIR_PUBLIC)) 12 | 13 | const { compiler } = handleWebpackDevServer(app) 14 | 15 | handleServeBaseRouteDev({ 16 | compiler, 17 | app, 18 | }) 19 | 20 | app.listen(PORT, () => { 21 | console.info('Express is listening on PORT %s.', PORT) 22 | }) 23 | -------------------------------------------------------------------------------- /src/modules/blog/components/RelevantPagesList/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { mediaQueries } from 'styles/mediaQueries' 3 | 4 | export const Container = styled.div` 5 | margin-top: 50px; 6 | ` 7 | 8 | export const PagesContainer = styled.div` 9 | display: grid; 10 | grid-template-columns: 1fr; 11 | grid-auto-rows: 1fr; 12 | grid-column-gap: 30px; 13 | grid-row-gap: 30px; 14 | 15 | ${mediaQueries.md} { 16 | grid-template-columns: repeat(2, 1fr); 17 | } 18 | 19 | ${mediaQueries.lg} { 20 | grid-template-columns: repeat(3, 1fr); 21 | } 22 | ` 23 | -------------------------------------------------------------------------------- /src/modules/auth/gql/__generated__/Login.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | // ==================================================== 6 | // GraphQL mutation operation: Login 7 | // ==================================================== 8 | 9 | export interface Login_login { 10 | __typename: 'Session' 11 | accessToken: string 12 | refreshToken: string 13 | } 14 | 15 | export interface Login { 16 | login: Login_login 17 | } 18 | 19 | export interface LoginVariables { 20 | email: string 21 | password: string 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/blog/hooks/useCreatePage.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@apollo/react-hooks' 2 | import { 3 | CreatePage, 4 | CreatePage_createPage, 5 | CreatePageVariables, 6 | } from '../gql/__generated__/CreatePage' 7 | import { CREATE_PAGE_MUTATION } from '../gql' 8 | import { updateListPages } from '../cache/updateListPages' 9 | 10 | export const useCreatePage = () => 11 | useMutation(CREATE_PAGE_MUTATION, { 12 | update: updateListPages( 13 | 'createPage', 14 | (listPages, createPage) => listPages.concat([createPage]) 15 | ), 16 | }) 17 | -------------------------------------------------------------------------------- /src/modules/blog/gql/__generated__/DeletePage.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | // ==================================================== 6 | // GraphQL mutation operation: DeletePage 7 | // ==================================================== 8 | 9 | export interface DeletePage_deletePage { 10 | __typename: 'Page' 11 | id: string 12 | title: string 13 | text: string 14 | } 15 | 16 | export interface DeletePage { 17 | deletePage: DeletePage_deletePage 18 | } 19 | 20 | export interface DeletePageVariables { 21 | id: number 22 | } 23 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | React Webpack 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /src/modules/blog/hooks/useDeletePage.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@apollo/react-hooks' 2 | import { 3 | DeletePage, 4 | DeletePage_deletePage, 5 | DeletePageVariables, 6 | } from '../gql/__generated__/DeletePage' 7 | import { DELETE_PAGE_MUTATION } from '../gql' 8 | import { updateListPages } from '../cache/updateListPages' 9 | 10 | export const useDeletePage = () => 11 | useMutation(DELETE_PAGE_MUTATION, { 12 | update: updateListPages( 13 | 'deletePage', 14 | (listPages, deletePage) => 15 | listPages.filter(page => page.id !== deletePage.id) 16 | ), 17 | }) 18 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { Router } from 'react-router-dom' 4 | import { ApolloProvider } from '@apollo/react-hooks' 5 | import { App } from 'app' 6 | import { apolloClient } from 'apolloClient' 7 | import { browserHistory } from 'appHistory' 8 | 9 | const renderApp = () => { 10 | ReactDOM.render( 11 | 12 | 13 | 14 | 15 | , 16 | document.querySelector('#root') 17 | ) 18 | } 19 | 20 | renderApp() 21 | 22 | if (module.hot) { 23 | module.hot.accept() 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/auth/gql/__generated__/Register.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | import { RegisterInput } from './../../../../globalTypes' 6 | 7 | // ==================================================== 8 | // GraphQL mutation operation: Register 9 | // ==================================================== 10 | 11 | export interface Register_register { 12 | __typename: 'Session' 13 | accessToken: string 14 | refreshToken: string 15 | } 16 | 17 | export interface Register { 18 | register: Register_register 19 | } 20 | 21 | export interface RegisterVariables { 22 | data: RegisterInput 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/auth/gql/__generated__/Me.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | // ==================================================== 6 | // GraphQL query operation: Me 7 | // ==================================================== 8 | 9 | export interface Me_me_pages { 10 | __typename: 'Page' 11 | id: string 12 | title: string 13 | text: string 14 | } 15 | 16 | export interface Me_me { 17 | __typename: 'User' 18 | id: string 19 | email: string 20 | firstName: string 21 | lastName: string 22 | pages: Me_me_pages[] | null 23 | } 24 | 25 | export interface Me { 26 | me: Me_me | null 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/blog/cache/updateListPages.ts: -------------------------------------------------------------------------------- 1 | import { DataProxy } from 'apollo-cache' 2 | import { ListPages } from '../gql/__generated__/ListPages' 3 | import { LIST_PAGES_QUERY } from '../gql' 4 | 5 | export const updateListPages = ( 6 | queryName: string, 7 | callBack: ( 8 | listPages: ListPages['listPages'], 9 | result: T 10 | ) => ListPages['listPages'] 11 | ) => (cache: DataProxy, params: any) => { 12 | const result = params.data[queryName] 13 | 14 | const { listPages } = cache.readQuery({ 15 | query: LIST_PAGES_QUERY, 16 | }) 17 | cache.writeQuery({ 18 | query: LIST_PAGES_QUERY, 19 | data: { listPages: callBack(listPages, result) }, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/blog/gql/__generated__/CreatePage.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | import { CreatePageInput } from './../../../../globalTypes' 6 | 7 | // ==================================================== 8 | // GraphQL mutation operation: CreatePage 9 | // ==================================================== 10 | 11 | export interface CreatePage_createPage { 12 | __typename: 'Page' 13 | id: string 14 | title: string 15 | text: string 16 | } 17 | 18 | export interface CreatePage { 19 | createPage: CreatePage_createPage 20 | } 21 | 22 | export interface CreatePageVariables { 23 | data: CreatePageInput 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/blog/gql/__generated__/UpdatePage.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | import { UpdatePageInput } from './../../../../globalTypes' 6 | 7 | // ==================================================== 8 | // GraphQL mutation operation: UpdatePage 9 | // ==================================================== 10 | 11 | export interface UpdatePage_updatePage { 12 | __typename: 'Page' 13 | id: string 14 | title: string 15 | text: string 16 | } 17 | 18 | export interface UpdatePage { 19 | updatePage: UpdatePage_updatePage 20 | } 21 | 22 | export interface UpdatePageVariables { 23 | data: UpdatePageInput 24 | } 25 | -------------------------------------------------------------------------------- /webpack/client/dev.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const merge = require('webpack-merge') 4 | const Dotenv = require('dotenv-webpack') 5 | const common = require('./common') 6 | const { 7 | PORT, 8 | } = require('../config') 9 | 10 | module.exports = merge(common, { 11 | mode: 'development', 12 | entry: [ 13 | 'webpack-hot-middleware/client?reload=true&overlay=false', 14 | './src/index.tsx' 15 | ], 16 | plugins: [ 17 | new webpack.HotModuleReplacementPlugin(), 18 | new Dotenv({ 19 | path: path.resolve(__dirname, '..', '..', '.env.development'), 20 | }), 21 | ], 22 | devServer: { 23 | hot: true, 24 | port: PORT, 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /src/pages/Auth/Logout/index.ts: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from 'react' 2 | import { message } from 'antd' 3 | import { useApolloClient } from '@apollo/react-hooks' 4 | import { RouteComponentProps } from 'react-router' 5 | import { auth } from 'services/auth' 6 | 7 | export const LogoutPage: FC = (props): null => { 8 | const client = useApolloClient() 9 | 10 | const handleLogOut = async () => { 11 | auth.logOut() 12 | props.history.push('/') 13 | await message.info('You are now logged out.') 14 | await client.resetStore() 15 | } 16 | 17 | useEffect(() => { 18 | // TODO: Fix floating promise 19 | // eslint-disable-next-line 20 | handleLogOut() 21 | }, []) 22 | 23 | return null 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/blog/components/PageCard/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import LinesEllipsis from 'react-lines-ellipsis' 3 | import { ListPages_listPages } from '../../gql/__generated__/ListPages' 4 | import { Card, CardLink } from './styled' 5 | 6 | interface IProps { 7 | page: Omit 8 | } 9 | 10 | export const COMPONENT_PAGE_CARD_TEST_ID = 'page-card-component' 11 | 12 | export const PageCard: FC = ({ page }) => ( 13 | 18 | 19 | Read More 20 | 21 | ) 22 | -------------------------------------------------------------------------------- /webpack/server/prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { 3 | BUILD_DIR, 4 | } = require('../config') 5 | 6 | module.exports = { 7 | mode: 'production', 8 | entry: './server/prod.ts', 9 | target: 'node', 10 | node: { 11 | __dirname: false, 12 | }, 13 | output: { 14 | publicPath: '/', 15 | filename: 'server.js', 16 | path: path.resolve(__dirname, '..', '..', BUILD_DIR), 17 | }, 18 | resolve: { 19 | extensions: ['.ts', '.js'], 20 | modules: ['./node_modules', './src'], 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.(ts)$/, 26 | exclude: /node_modules/, 27 | use: { 28 | loader: 'babel-loader', 29 | }, 30 | }, 31 | ], 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/auth/gql/__generated__/ChangePassword.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | import { ChangePasswordInput } from './../../../../globalTypes' 6 | 7 | // ==================================================== 8 | // GraphQL mutation operation: ChangePassword 9 | // ==================================================== 10 | 11 | export interface ChangePassword_changePassword { 12 | __typename: 'Session' 13 | accessToken: string 14 | refreshToken: string 15 | } 16 | 17 | export interface ChangePassword { 18 | changePassword: ChangePassword_changePassword | null 19 | } 20 | 21 | export interface ChangePasswordVariables { 22 | data: ChangePasswordInput 23 | } 24 | -------------------------------------------------------------------------------- /server/middleware/prod.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { Express } from 'express' 3 | import compression from 'compression' 4 | import { BUILD_DIR_PUBLIC } from '../config' 5 | 6 | export const handleHttpsRedirect = (app: Express) => 7 | app.use((req, res, next) => { 8 | if ( 9 | req.hostname !== 'localhost' && 10 | req.get('X-Forwarded-Proto') !== 'https' 11 | ) { 12 | return res.redirect(`https://${req.hostname}${req.url}`) 13 | } 14 | return next() 15 | }) 16 | 17 | // TODO: Fix typescript error 18 | export const handleCompression = (app: Express) => app.use(compression() as any) 19 | 20 | export const handleServeBaseRoute = (app: Express) => 21 | app.get('*', (req, res) => { 22 | res.sendFile(path.resolve(__dirname, BUILD_DIR_PUBLIC, 'index.html')) 23 | }) 24 | -------------------------------------------------------------------------------- /src/pages/Auth/PasswordReset/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { RouteComponentProps } from 'react-router' 3 | import { H1 } from 'components/Typography/H1' 4 | import { ChangePasswordForm } from 'modules/auth/forms/ChangePassword' 5 | 6 | export const PAGE_PASSWORD_RESET_TEST_ID = 'password-reset-page' 7 | 8 | // Get token from router props 9 | // Don't render the form if token doesn't exist and render alertError instead 10 | export const PasswordResetPage: FC> = ({ history, match }) => { 13 | const resetToken = match.params.token 14 | 15 | return ( 16 |
17 |

Password Reset

18 | 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/test-utils/form/input.ts: -------------------------------------------------------------------------------- 1 | import { RenderResult, fireEvent } from '@testing-library/react' 2 | 3 | export const changeInput = (renderer: RenderResult) => ( 4 | name: string, 5 | value: string | number 6 | ) => { 7 | const $element = renderer.container.querySelector(`input[name="${name}"]`) 8 | fireEvent.change($element, { target: { value } }) 9 | } 10 | 11 | export const changeTextarea = (renderer: RenderResult) => ( 12 | name: string, 13 | value: string | number 14 | ) => { 15 | const $element = renderer.container.querySelector(`textarea[name="${name}"]`) 16 | fireEvent.change($element, { target: { value } }) 17 | } 18 | 19 | export const submitForm = (renderer: RenderResult) => () => { 20 | const $element = renderer.container.querySelector('button[type="submit"]') 21 | fireEvent.click($element) 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: 12 13 | registry-url: https://registry.npmjs.org 14 | - name: Install Dependencies 15 | run: yarn install 16 | - name: Lint TS Files 17 | run: yarn lint:ts 18 | - name: Lint CSS Files 19 | run: yarn lint:css 20 | - name: Run type check 21 | run: yarn type-check 22 | - name: Test & publish code coverage 23 | uses: paambaati/codeclimate-action@v2.3.0 24 | env: 25 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 26 | with: 27 | coverageCommand: yarn test:coverage 28 | debug: true 29 | -------------------------------------------------------------------------------- /src/modules/blog/components/RelevantPagesList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { PageCard } from '../PageCard' 3 | import { ListPages_listPages } from '../../gql/__generated__/ListPages' 4 | import { PageDetail_pageDetail_user } from '../../gql/__generated__/PageDetail' 5 | import { Container, PagesContainer } from './styled' 6 | import { H3 } from 'components/Typography/H3' 7 | 8 | interface IProps { 9 | pages: Array> 10 | user?: Omit 11 | title?: string 12 | } 13 | 14 | export const RelevantPagesList: FC = ({ pages, user, title }) => ( 15 | 16 |

{title || `More from ${user.email}`}

17 | 18 | {pages.map(page => ( 19 | 20 | ))} 21 | 22 |
23 | ) 24 | -------------------------------------------------------------------------------- /src/test-utils/setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { cleanup } from '@testing-library/react' 3 | 4 | // Automatically unmount and cleanup DOM after the test is finished. 5 | afterEach(cleanup) 6 | 7 | // https://github.com/kentcdodds/react-testing-library/issues/281#issuecomment-480349256 8 | // This is just a little hack to silence a warning that we'll get until react 9 | // Fixes this: https://github.com/facebook/react/pull/14853 10 | const originalError = console.error 11 | beforeAll(() => { 12 | console.error = (...args: any) => { 13 | if ( 14 | /Warning.*not wrapped in act/u.test(args[0]) || 15 | args[0].includes( 16 | "Warning: Can't perform a React state update on an unmounted component" 17 | ) 18 | ) { 19 | return 20 | } 21 | originalError.call(console, ...args) 22 | } 23 | }) 24 | 25 | afterAll(() => { 26 | console.error = originalError 27 | }) 28 | -------------------------------------------------------------------------------- /src/globalTypes.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | //============================================================== 6 | // START Enums and Input Objects 7 | //============================================================== 8 | 9 | export interface ChangePasswordInput { 10 | password: string 11 | token: string 12 | } 13 | 14 | export interface CreatePageInput { 15 | text: string 16 | title: string 17 | } 18 | 19 | export interface RegisterInput { 20 | email: string 21 | firstName: string 22 | lastName: string 23 | password: string 24 | } 25 | 26 | export interface UpdatePageInput { 27 | id: number 28 | text: string 29 | title: string 30 | } 31 | 32 | //============================================================== 33 | // END Enums and Input Objects 34 | //============================================================== 35 | -------------------------------------------------------------------------------- /src/components/Textarea/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { ErrorMessage, Field, FieldProps } from 'formik' 3 | import { FormElementError } from '../FormElementError' 4 | import { FormElementLabel } from '../FormElementLabel' 5 | import { StyledTextArea } from './styled' 6 | 7 | interface IProps { 8 | name: string 9 | label: string 10 | } 11 | 12 | export const Textarea: FC = ({ name, label }) => ( 13 | 14 | {label} 15 | : 16 | ) => ( 19 | 24 | )} 25 | /> 26 | {message}} 29 | /> 30 | 31 | ) 32 | -------------------------------------------------------------------------------- /src/modules/router/routes/Protected.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, ComponentType } from 'react' 2 | import { Route, Redirect, RouteProps } from 'react-router-dom' 3 | import { auth } from 'services/auth' 4 | import { ROUTE_PATHS } from 'routes' 5 | 6 | interface IOuterProps { 7 | component: ComponentType 8 | } 9 | 10 | type IProps = IOuterProps & RouteProps 11 | 12 | export const ProtectedRoute: FunctionComponent = ({ 13 | component: TargetComponent, 14 | ...rest 15 | }) => ( 16 | { 19 | const accessToken = auth.getAccessToken() 20 | if (accessToken) { 21 | return 22 | } 23 | 24 | return ( 25 | 31 | ) 32 | }} 33 | /> 34 | ) 35 | -------------------------------------------------------------------------------- /src/modules/blog/gql/__generated__/PageDetail.ts: -------------------------------------------------------------------------------- 1 | /* Tslint:disable */ 2 | /* eslint-disable */ 3 | // This file was automatically generated and should not be edited. 4 | 5 | // ==================================================== 6 | // GraphQL query operation: PageDetail 7 | // ==================================================== 8 | 9 | export interface PageDetail_pageDetail_user_pages { 10 | __typename: 'Page' 11 | id: string 12 | title: string 13 | text: string 14 | } 15 | 16 | export interface PageDetail_pageDetail_user { 17 | __typename: 'User' 18 | id: string 19 | email: string 20 | pages: PageDetail_pageDetail_user_pages[] | null 21 | } 22 | 23 | export interface PageDetail_pageDetail { 24 | __typename: 'Page' 25 | id: string 26 | title: string 27 | text: string 28 | user: PageDetail_pageDetail_user 29 | } 30 | 31 | export interface PageDetail { 32 | pageDetail: PageDetail_pageDetail 33 | } 34 | 35 | export interface PageDetailVariables { 36 | id: number 37 | } 38 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "React Apollo GraphQl", 3 | "description": "React in tandem with Apollo. Minimal implementation that will help you get started with GraphQL.", 4 | "repository": "https://github.com/developer239/react-apollo-graphql", 5 | "keywords": [ 6 | "node", 7 | "apollo", 8 | "react", 9 | "react-apollo", 10 | "graphql", 11 | "typescript", 12 | "react-router", 13 | "express", 14 | "formik", 15 | "yup" 16 | ], 17 | "website": "https://react-apollo-graphql.herokuapp.com", 18 | "env": { 19 | "SERVER_URL": { 20 | "description": "This is where our GraphQL backend lives.", 21 | "value": "https://node-type-orm-graphql.herokuapp.com/graphql" 22 | }, 23 | "NODE_ENV": { 24 | "description": "We want to run the app in production mode.", 25 | "value": "production" 26 | }, 27 | "YARN_PRODUCTION": { 28 | "description": "We want to keep dev dependencies.", 29 | "value": "false" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/services/auth/index.ts: -------------------------------------------------------------------------------- 1 | import Cookie from 'js-cookie' 2 | 3 | enum STORAGE { 4 | ACCESS_TOKEN = 'accessToken', 5 | REFRESH_TOKEN = 'refreshToken', 6 | } 7 | 8 | export const auth = { 9 | logIn: (accessToken: string, refreshToken: string) => { 10 | auth.setAccessToken(accessToken) 11 | auth.setRefreshToken(refreshToken) 12 | }, 13 | 14 | logOut: () => { 15 | auth.removeAccessToken() 16 | auth.removeRefreshToken() 17 | }, 18 | 19 | // Access Token 20 | setAccessToken: (token: string) => 21 | Cookie.set(STORAGE.ACCESS_TOKEN, token, { expires: 365 }), 22 | getAccessToken: () => Cookie.get(STORAGE.ACCESS_TOKEN), 23 | removeAccessToken: () => Cookie.remove(STORAGE.ACCESS_TOKEN), 24 | 25 | // Refresh Token 26 | setRefreshToken: (token: string) => 27 | Cookie.set(STORAGE.REFRESH_TOKEN, token, { expires: 365 }), 28 | getRefreshToken: () => Cookie.get(STORAGE.REFRESH_TOKEN) || '', 29 | removeRefreshToken: () => Cookie.remove(STORAGE.REFRESH_TOKEN), 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/blog/components/PagesList/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useQuery } from '@apollo/react-hooks' 3 | import { PageCard } from '../PageCard' 4 | import { Container } from './styled' 5 | import { LIST_PAGES_QUERY } from 'modules/blog/gql' 6 | import { ListPages } from 'modules/blog/gql/__generated__/ListPages' 7 | import { Loader } from 'components/Loader' 8 | import { Empty } from 'components/Empty' 9 | import { ErrorAlert } from 'components/ErrorAlert' 10 | 11 | export const PagesList = React.memo(() => { 12 | const { data, loading, error } = useQuery(LIST_PAGES_QUERY) 13 | 14 | if (error) { 15 | return {error.message} 16 | } 17 | 18 | if (loading) { 19 | return 20 | } 21 | 22 | if (!data.listPages.length) { 23 | return 24 | } 25 | 26 | return ( 27 | 28 | {data.listPages.map(page => ( 29 | 30 | ))} 31 | 32 | ) 33 | }) 34 | -------------------------------------------------------------------------------- /src/test-utils/generators/index.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | 3 | export const createPage = (id: number) => ({ 4 | id, 5 | title: faker.lorem.words(3), 6 | text: faker.lorem.text(), 7 | }) 8 | 9 | export type MockPageType = ReturnType 10 | 11 | export const createUser = (id: number) => ({ 12 | id, 13 | firstName: faker.name.findName(), 14 | lastName: faker.name.lastName(), 15 | email: faker.internet.email(), 16 | password: faker.internet.password(), 17 | }) 18 | 19 | export type MockUserType = ReturnType 20 | 21 | export const createUserWithPages = ( 22 | userId: number, 23 | pagesIds: number[] = [] 24 | ) => ({ 25 | ...createUser(userId), 26 | pages: pagesIds.map(createPage), 27 | }) 28 | 29 | export type MockUserWithPages = ReturnType 30 | 31 | export const createPageWithUser = ( 32 | pageId: number, 33 | userId: number, 34 | pagesIds: number[] = [] 35 | ) => ({ 36 | ...createPage(pageId), 37 | user: createUserWithPages(userId, pagesIds), 38 | }) 39 | 40 | export type MockPageWithUserType = ReturnType 41 | -------------------------------------------------------------------------------- /src/pages/Blog/Edit/test/Edit.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { waitForElement } from '@testing-library/react' 3 | import { PAGE_EDIT_TEST_ID } from '../index' 4 | import { App } from 'app' 5 | import { ROUTE_PATHS } from 'routes' 6 | import { renderWithRouter } from 'test-utils/render' 7 | import { createPageWithUser } from 'test-utils/generators' 8 | import { mockPageDetailSuccess } from 'test-utils/gql' 9 | import { auth } from 'services/auth' 10 | 11 | describe('[page] Edit Page', () => { 12 | beforeEach(() => { 13 | auth.setAccessToken('mockAccessToken') 14 | }) 15 | 16 | afterEach(() => { 17 | auth.removeAccessToken() 18 | }) 19 | 20 | describe('when page is loaded', () => { 21 | it('it should render correctly', async () => { 22 | const page = createPageWithUser(1, 1) 23 | const renderer = renderWithRouter( 24 | , 25 | ROUTE_PATHS.blog.edit(String(page.id)), 26 | [mockPageDetailSuccess(page)] 27 | ) 28 | 29 | await waitForElement(() => renderer.getByTestId(PAGE_EDIT_TEST_ID)) 30 | 31 | expect(renderer.getByTestId(PAGE_EDIT_TEST_ID)).toBeTruthy() 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/modules/blog/gql/index.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const LIST_PAGES_QUERY = gql` 4 | query ListPages { 5 | listPages { 6 | id 7 | title 8 | text 9 | } 10 | } 11 | ` 12 | 13 | export const PAGE_DETAIL_QUERY = gql` 14 | query PageDetail($id: Float!) { 15 | pageDetail(id: $id) { 16 | id 17 | title 18 | text 19 | user { 20 | id 21 | email 22 | pages { 23 | id 24 | title 25 | text 26 | } 27 | } 28 | } 29 | } 30 | ` 31 | 32 | export const CREATE_PAGE_MUTATION = gql` 33 | mutation CreatePage($data: CreatePageInput!) { 34 | createPage(data: $data) { 35 | id 36 | title 37 | text 38 | } 39 | } 40 | ` 41 | 42 | export const UPDATE_PAGE_MUTATION = gql` 43 | mutation UpdatePage($data: UpdatePageInput!) { 44 | updatePage(data: $data) { 45 | id 46 | title 47 | text 48 | } 49 | } 50 | ` 51 | 52 | export const DELETE_PAGE_MUTATION = gql` 53 | mutation DeletePage($id: Float!) { 54 | deletePage(id: $id) { 55 | id 56 | title 57 | text 58 | } 59 | } 60 | ` 61 | -------------------------------------------------------------------------------- /src/pages/Auth/Me/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Info } from './styled' 3 | import { H1 } from 'components/Typography/H1' 4 | import { Loader } from 'components/Loader' 5 | import { ErrorAlert } from 'components/ErrorAlert' 6 | import { useMe } from 'modules/auth/hooks/useMe' 7 | import { RelevantPagesList } from 'modules/blog/components/RelevantPagesList' 8 | 9 | export const PAGE_ME_TEST_ID = 'me-page' 10 | 11 | export const MePage = () => { 12 | const { data, loading, error } = useMe() 13 | 14 | if (loading) { 15 | return 16 | } 17 | 18 | if (error) { 19 | return {error.message} 20 | } 21 | 22 | return ( 23 |
24 |

Me

25 | 26 | Email: {data.me.email} 27 | 28 | 29 | First Name: {data.me.firstName} 30 | 31 | 32 | Last Name: {data.me.lastName} 33 | 34 | {Boolean(data.me.pages.length) && ( 35 | 36 | )} 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/Blog/Create/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { RouteComponentProps } from 'react-router' 3 | import { message } from 'antd' 4 | import { FormikHelpers } from 'formik' 5 | import { H1 } from 'components/Typography/H1' 6 | import { IPageFormValues, PageForm } from 'modules/blog/forms/Page' 7 | import { useCreatePage } from 'modules/blog/hooks/useCreatePage' 8 | 9 | export const PAGE_CREATE_TEST_ID = 'create-page' 10 | 11 | export const CreatePagePage: FC = props => { 12 | const [createPage] = useCreatePage() 13 | 14 | const handleSubmit = async ( 15 | values: IPageFormValues, 16 | { setSubmitting }: FormikHelpers 17 | ) => { 18 | try { 19 | const result = await createPage({ variables: { data: values } }) 20 | if (result) { 21 | props.history.push(`/blog/${result.data.createPage.id}`) 22 | } 23 | } catch (error) { 24 | setSubmitting(false) 25 | await message.error(error.message) 26 | } 27 | } 28 | 29 | return ( 30 |
31 |

Create Page

32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/services/token/index.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'apollo-client/util/Observable' 2 | import { message } from 'antd' 3 | import { ApolloClient } from 'apollo-client' 4 | import { NextLink, Operation } from 'apollo-link' 5 | import { auth } from '../auth' 6 | import { 7 | AccessToken, 8 | AccessTokenVariables, 9 | } from 'modules/auth/gql/__generated__/AccessToken' 10 | import { ACCESS_TOKEN_QUERY } from 'modules/auth/gql' 11 | import { browserHistory } from 'appHistory' 12 | 13 | export const handleRefreshToken = ({ 14 | client, 15 | forward, 16 | operation, 17 | }: { 18 | client: ApolloClient 19 | forward: NextLink 20 | operation: Operation 21 | }) => 22 | new Observable(subscriber => { 23 | client 24 | .query({ 25 | query: ACCESS_TOKEN_QUERY, 26 | variables: { refreshToken: auth.getRefreshToken() }, 27 | }) 28 | .then(result => { 29 | auth.setAccessToken(result.data.accessToken) 30 | subscriber.next(result) 31 | subscriber.complete() 32 | }) 33 | .catch(async error => { 34 | await message.error(error.message) 35 | auth.logOut() 36 | browserHistory.push('/login') 37 | }) 38 | }).flatMap(() => forward(operation)) 39 | -------------------------------------------------------------------------------- /src/modules/auth/gql/index.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const ME_QUERY = gql` 4 | query Me { 5 | me { 6 | id 7 | email 8 | firstName 9 | lastName 10 | pages { 11 | id 12 | title 13 | text 14 | } 15 | } 16 | } 17 | ` 18 | 19 | export const REGISTER_MUTATION = gql` 20 | mutation Register($data: RegisterInput!) { 21 | register(data: $data) { 22 | accessToken 23 | refreshToken 24 | } 25 | } 26 | ` 27 | 28 | export const LOGIN_MUTATION = gql` 29 | mutation Login($email: String!, $password: String!) { 30 | login(email: $email, password: $password) { 31 | accessToken 32 | refreshToken 33 | } 34 | } 35 | ` 36 | 37 | export const FORGOT_PASSWORD_MUTATION = gql` 38 | mutation ForgotPassword($email: String!) { 39 | forgotPassword(email: $email) 40 | } 41 | ` 42 | 43 | export const CHANGE_PASSWORD_MUTATION = gql` 44 | mutation ChangePassword($data: ChangePasswordInput!) { 45 | changePassword(data: $data) { 46 | accessToken 47 | refreshToken 48 | } 49 | } 50 | ` 51 | 52 | export const ACCESS_TOKEN_QUERY = gql` 53 | query AccessToken($refreshToken: String!) { 54 | accessToken(refreshToken: $refreshToken) 55 | } 56 | ` 57 | -------------------------------------------------------------------------------- /src/apolloClient.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from 'apollo-client' 2 | import { createHttpLink } from 'apollo-link-http' 3 | import { setContext } from 'apollo-link-context' 4 | import { InMemoryCache } from 'apollo-cache-inmemory' 5 | import { ApolloLink } from 'apollo-link' 6 | import { onError } from 'apollo-link-error' 7 | import { auth } from 'services/auth' 8 | import { handleRefreshToken } from 'services/token' 9 | import { SERVER_URL } from 'config' 10 | 11 | const httpLink = createHttpLink({ 12 | uri: SERVER_URL, 13 | }) 14 | 15 | const authLink = setContext((_, { headers }) => { 16 | const token = auth.getAccessToken() 17 | 18 | return { 19 | headers: { 20 | ...headers, 21 | authorization: token ? `Bearer ${token}` : '', 22 | }, 23 | } 24 | }) 25 | 26 | export const apolloClient: ApolloClient = new ApolloClient({ 27 | link: ApolloLink.from([ 28 | onError(({ forward, graphQLErrors, operation }) => { 29 | if ( 30 | graphQLErrors.length && 31 | graphQLErrors[0].message === 'Token Expired' 32 | ) { 33 | return handleRefreshToken({ 34 | client: apolloClient, 35 | forward, 36 | operation, 37 | }) 38 | } 39 | }), 40 | authLink, 41 | httpLink, 42 | ]), 43 | cache: new InMemoryCache(), 44 | }) 45 | -------------------------------------------------------------------------------- /server/middleware/dev.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import path from 'path' 3 | import { Express } from 'express' 4 | import webpack, { Compiler } from 'webpack' 5 | import webpackDevMiddleware from 'webpack-dev-middleware' 6 | import webpackHotMiddleware from 'webpack-hot-middleware' 7 | 8 | export const handleWebpackDevServer = (app: Express) => { 9 | // eslint-disable-next-line 10 | const webpackDevConfig = require('../../webpack/client/dev') 11 | 12 | const compiler = webpack(webpackDevConfig) 13 | 14 | app.use( 15 | webpackDevMiddleware(compiler, { 16 | publicPath: webpackDevConfig.output.publicPath, 17 | }) 18 | ) 19 | 20 | app.use(webpackHotMiddleware(compiler, { log: false })) 21 | 22 | return { compiler } 23 | } 24 | 25 | export const handleServeBaseRouteDev = ({ 26 | compiler, 27 | app, 28 | }: { 29 | app: Express 30 | compiler: Compiler 31 | }) => { 32 | app.use('*', (req, res, next) => { 33 | const filename = path.join(compiler.outputPath, 'index.html') 34 | 35 | compiler.inputFileSystem.readFile( 36 | filename, 37 | (err: Error, result: unknown) => { 38 | if (err) { 39 | return next(err) 40 | } 41 | res.set('content-type', 'text/html') 42 | res.send(result) 43 | res.end() 44 | } 45 | ) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /src/pages/Auth/PasswordForgot/test/PasswordForgot.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { waitForElement } from '@testing-library/react' 3 | import { PAGE_PASSWORD_FORGOT_TEST_ID } from '../index' 4 | import { renderWithRouter } from 'test-utils/render' 5 | import { App } from 'app' 6 | import { changeInput, submitForm } from 'test-utils/form/input' 7 | import { SUCCESS_MESSAGE } from 'modules/auth/forms/ForgotPassword' 8 | import { mockPasswordForgotSuccess } from 'test-utils/gql' 9 | 10 | describe('[page] PasswordForgot', () => { 11 | const mockEmail = 'email@email.com' 12 | 13 | it('should render correctly', () => { 14 | const renderer = renderWithRouter(, '/password-forgot') 15 | const renderedElement = renderer.getByTestId(PAGE_PASSWORD_FORGOT_TEST_ID) 16 | expect(renderedElement).toBeTruthy() 17 | }) 18 | 19 | describe('when form is submitted', () => { 20 | it('should handle success', async () => { 21 | const renderer = renderWithRouter(, '/password-forgot', [ 22 | mockPasswordForgotSuccess(mockEmail), 23 | ]) 24 | 25 | changeInput(renderer)('email', mockEmail) 26 | submitForm(renderer)() 27 | 28 | await waitForElement(() => renderer.getByText(SUCCESS_MESSAGE)) 29 | 30 | expect(renderer.getByText(SUCCESS_MESSAGE)).toBeTruthy() 31 | }) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /webpack/client/common.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebPackPlugin = require('html-webpack-plugin') 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') 4 | const { 5 | BUILD_DIR, 6 | BUILD_DIR_PUBLIC, 7 | } = require('../config') 8 | 9 | module.exports = { 10 | output: { 11 | publicPath: '/', 12 | filename: '[name]-[hash].min.js', 13 | path: path.resolve(__dirname, '..', '..', BUILD_DIR, BUILD_DIR_PUBLIC), 14 | }, 15 | resolve: { 16 | extensions: ['.ts', '.tsx', '.js'], 17 | modules: ['./node_modules', './src'], 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.(ts|tsx)$/, 23 | exclude: /node_modules/, 24 | use: { 25 | loader: 'babel-loader', 26 | }, 27 | }, 28 | { 29 | test: /\.js$/, 30 | use: ['source-map-loader'], 31 | enforce: 'pre', 32 | }, 33 | { 34 | test: /\.css$/i, 35 | use: ['style-loader', 'css-loader'], 36 | }, 37 | { 38 | test: /\.html$/, 39 | use: [ 40 | { 41 | loader: 'html-loader', 42 | }, 43 | ], 44 | }, 45 | ], 46 | }, 47 | plugins: [ 48 | new CleanWebpackPlugin(), 49 | new HtmlWebPackPlugin({ 50 | template: './src/index.html', 51 | filename: './index.html', 52 | }), 53 | ], 54 | } 55 | -------------------------------------------------------------------------------- /src/modules/blog/forms/Page/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Formik, Form, FormikHelpers } from 'formik' 3 | import * as Yup from 'yup' 4 | import { TextInput } from 'components/TextInput' 5 | import { Textarea } from 'components/Textarea' 6 | import { FormButton } from 'components/FormButton' 7 | 8 | const registerSchema = Yup.object().shape({ 9 | title: Yup.string().required('Required'), 10 | text: Yup.string().required('Required'), 11 | }) 12 | 13 | export interface IPageFormValues { 14 | id?: number 15 | title: string 16 | text: string 17 | } 18 | 19 | export interface IProps { 20 | initialValues?: IPageFormValues 21 | handleSubmit: ( 22 | values: IPageFormValues, 23 | actions?: FormikHelpers 24 | ) => Promise 25 | } 26 | 27 | export const PageForm: FC = ({ initialValues, handleSubmit }) => ( 28 | 33 | {({ isSubmitting }) => ( 34 |
35 | 36 |