├── .nvmrc ├── packages └── e2e │ ├── .nvmrc │ ├── cypress │ ├── fixtures │ │ └── .gitkeep │ ├── support │ │ └── index.js │ ├── integration │ │ └── basic.spec.js │ └── plugins │ │ └── index.js │ ├── cypress.json │ └── package.json ├── .browserslistrc ├── next-env.d.ts ├── src ├── __mocks__ │ └── fileMock.js ├── shims │ ├── Js.shim.ts │ ├── Next.shim.ts │ ├── ReasonPervasives.shim.ts │ ├── ReactShim.shim.ts │ └── ReactEvent.shim.ts ├── bindings │ ├── URL.re │ ├── URL.bs.js │ ├── Hoc.re │ └── Hoc.bs.js ├── @types │ ├── typings.d.ts │ └── extend-expect.d.ts ├── pages │ ├── index.re │ ├── subreddit │ │ └── [name].tsx │ ├── index.gen.tsx │ ├── index.bs.js │ ├── api │ │ └── graphql.ts │ └── _document.tsx ├── utils │ ├── AllTheProviders.tsx │ ├── testUtils.tsx │ ├── fixtures │ │ └── subredditFixture.tsx │ ├── RouterProvider.tsx │ ├── ApolloProvider.tsx │ └── fetchMocks.tsx ├── components │ ├── __tests__ │ │ ├── Header.test.tsx │ │ └── ActiveLink.test.tsx │ ├── Header.gen.tsx │ ├── Subreddit.gen.tsx │ ├── Header.re │ ├── ActiveLink.re │ ├── Header.css │ ├── Subreddit.re │ ├── Header.bs.js │ ├── ActiveLink.gen.tsx │ ├── ActiveLink.bs.js │ └── Subreddit.bs.js ├── __tests__ │ └── pages │ │ └── index.test.tsx └── helpers │ ├── initApollo.tsx │ └── withApollo.tsx ├── renovate.json ├── public └── static │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── manifest.json ├── .storybook ├── addons.js ├── webpack.config.js └── config.js ├── .eslintignore ├── .prettierignore ├── .stylelintignore ├── workflows └── action-cypress │ ├── .dockerignore │ ├── entrypoint.sh │ └── Dockerfile ├── .stylelintrc ├── postcss.config.js ├── .nowignore ├── babel.config.js ├── stories ├── ui.stories.tsx └── base.css ├── scripts └── deploy-ci.sh ├── .gitignore ├── jest ├── identity-obj-proxy-esm.js └── setup.js ├── now.json ├── jest.config.js ├── .github └── workflows │ └── workflow.yml ├── tsconfig.json ├── bsconfig.json ├── .eslintrc.js ├── next.config.js ├── README.md ├── package.json └── graphql_schema.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.15.3 2 | -------------------------------------------------------------------------------- /packages/e2e/.nvmrc: -------------------------------------------------------------------------------- 1 | 10.15.3 2 | -------------------------------------------------------------------------------- /packages/e2e/cypress/fixtures/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 4 versions 3 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/e2e/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "browser": "chrome" 3 | } 4 | -------------------------------------------------------------------------------- /src/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | export default 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "automerge": true, 4 | "ignoreDeps": [] 5 | } 6 | -------------------------------------------------------------------------------- /public/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/reason-graphql-demo/master/public/static/favicon.ico -------------------------------------------------------------------------------- /public/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/reason-graphql-demo/master/public/static/favicon-16x16.png -------------------------------------------------------------------------------- /public/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/reason-graphql-demo/master/public/static/favicon-32x32.png -------------------------------------------------------------------------------- /src/shims/Js.shim.ts: -------------------------------------------------------------------------------- 1 | export type Dict_t = unknown; 2 | 3 | export type Json_t = unknown; 4 | 5 | export type t = unknown; 6 | -------------------------------------------------------------------------------- /public/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/reason-graphql-demo/master/public/static/apple-touch-icon.png -------------------------------------------------------------------------------- /src/shims/Next.shim.ts: -------------------------------------------------------------------------------- 1 | import { Router_t as Next_Router_t } from '@dblechoc/bs-next'; 2 | export type Router_t = Next_Router_t; 3 | -------------------------------------------------------------------------------- /public/static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/reason-graphql-demo/master/public/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/reason-graphql-demo/master/public/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | import '@storybook/addon-viewport/register'; 4 | -------------------------------------------------------------------------------- /src/bindings/URL.re: -------------------------------------------------------------------------------- 1 | type t = { 2 | . 3 | "pathname": string, 4 | "query": Js.Dict.t(string), 5 | }; 6 | 7 | [@bs.module "url"] external format: t => string = "format"; 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | .next 3 | coverage 4 | node_modules 5 | **/node_modules/** 6 | lib 7 | .cache 8 | *.bs.js 9 | *.gen.tsx 10 | .graphql_ppx_cache 11 | storybook-static 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | .next 3 | coverage 4 | node_modules 5 | **/node_modules/** 6 | lib 7 | .cache 8 | *.bs.js 9 | *.gen.tsx 10 | .graphql_ppx_cache 11 | storybook-static 12 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | dist 2 | .next 3 | coverage 4 | node_modules 5 | **/node_modules/** 6 | lib 7 | .cache 8 | *.bs.js 9 | *.gen.tsx 10 | .graphql_ppx_cache 11 | storybook-static 12 | -------------------------------------------------------------------------------- /src/bindings/URL.bs.js: -------------------------------------------------------------------------------- 1 | // Generated by BUCKLESCRIPT, PLEASE EDIT WITH CARE 2 | /* This output is empty. Its source's type definitions, externals and/or unused code got optimized away. */ 3 | -------------------------------------------------------------------------------- /packages/e2e/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | const baseHost = 'http://localhost:3000'; 2 | 3 | Cypress.Commands.add('openPage', (pageUrl = '/') => { 4 | cy.visit(`${baseHost}${pageUrl}`); 5 | }); 6 | -------------------------------------------------------------------------------- /workflows/action-cypress/.dockerignore: -------------------------------------------------------------------------------- 1 | # ignore all files by default 2 | * 3 | # include required files with an exception 4 | !entrypoint.sh 5 | !LICENSE 6 | !README.md 7 | !THIRD_PARTY_NOTICE.md 8 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | ], 5 | "rules": { 6 | "declaration-colon-newline-after": null, 7 | "value-list-comma-newline-after": null 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/@types/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface Process { 3 | browser: any; 4 | } 5 | interface Global { 6 | page: any; 7 | fetch: any; 8 | } 9 | } 10 | 11 | declare module '*.css'; 12 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require('autoprefixer'); 2 | 3 | module.exports = { 4 | plugins: [ 5 | autoprefixer({ 6 | overrideBrowserslist: ['> 1%', 'last 4 versions'], 7 | }), 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /.nowignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .next 3 | .git 4 | .cache 5 | node_modules 6 | *.log 7 | coverage 8 | .DS_Store 9 | .merlin 10 | /lib/* 11 | !/lib/js/* 12 | /bundledOutputs/ 13 | .bsb.lock 14 | .graphql_ppx_cache 15 | storybook-static 16 | -------------------------------------------------------------------------------- /src/pages/index.re: -------------------------------------------------------------------------------- 1 | let ste = ReasonReact.string; 2 | 3 | [@react.component] 4 | let make = () => ; 5 | 6 | [@genType "Index"] 7 | let index = make; 8 | 9 | [@gentype] 10 | let default = Hoc.Apollo.withApollo(make); 11 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | api.cache(true); 3 | 4 | return { 5 | presets: ['next/babel'], 6 | plugins: [ 7 | '@babel/proposal-class-properties', 8 | '@babel/proposal-object-rest-spread', 9 | ], 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/bindings/Hoc.re: -------------------------------------------------------------------------------- 1 | module Apollo = { 2 | [@bs.module "../helpers/withApollo"] 3 | // takes a react component and returns a react component with the same signature 4 | external withApollo: React.component('props) => React.component('props) = 5 | "default"; 6 | }; 7 | -------------------------------------------------------------------------------- /src/@types/extend-expect.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace jest { 2 | interface Matchers { 3 | toHaveAttribute: (attr: string, value?: string) => R; 4 | toHaveTextContent: (text: string) => R; 5 | toHaveClass: (className: string) => R; 6 | toBeInTheDOM: () => R; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /stories/ui.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import Header from '../src/components/Header.gen'; 4 | 5 | import './base.css'; 6 | 7 | storiesOf('Header', module).add('Index', () => { 8 | return
; 9 | }); 10 | -------------------------------------------------------------------------------- /scripts/deploy-ci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | BRANCH=$(echo "$GITHUB_REF" | sed -e 's/refs\/heads\///') 6 | 7 | if echo "$BRANCH" | grep "^master$"; then 8 | echo 'Deploying production'; 9 | yarn deploy:production 10 | else 11 | echo 'Deploying staging'; 12 | yarn deploy:staging 13 | fi; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .cache 4 | .next 5 | coverage 6 | dist 7 | .DS_Store 8 | .merlin 9 | /lib/* 10 | !/lib/js/* 11 | /bundledOutputs/ 12 | .bsb.lock 13 | .graphql_ppx_cache 14 | storybook-static 15 | 16 | # Cypress 17 | packages/e2e/cypress.env.json 18 | packages/e2e/cypress/videos/* 19 | packages/e2e/cypress/screenshots/* 20 | -------------------------------------------------------------------------------- /jest/identity-obj-proxy-esm.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = new Proxy( 3 | {}, 4 | { 5 | get: function getter(target, key) { 6 | if (key === '__esModule') { 7 | // True instead of false to pretend we're an ES module. 8 | return true; 9 | } 10 | return key; 11 | }, 12 | }, 13 | ); 14 | -------------------------------------------------------------------------------- /jest/setup.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jasmine, jest */ 2 | 3 | global.__DEV__ = true; 4 | 5 | // date 6 | const constantDate = new Date(1506747294096); 7 | 8 | global.RealDate = Date; 9 | 10 | global.FakeDate = class extends Date { 11 | constructor() { 12 | super(); 13 | return constantDate; 14 | } 15 | }; 16 | 17 | global.Date = global.FakeDate; 18 | -------------------------------------------------------------------------------- /src/bindings/Hoc.bs.js: -------------------------------------------------------------------------------- 1 | // Generated by BUCKLESCRIPT, PLEASE EDIT WITH CARE 2 | 3 | import * as WithApollo from "../helpers/withApollo"; 4 | 5 | function withApollo(prim) { 6 | return WithApollo.default(prim); 7 | } 8 | 9 | var Apollo = { 10 | withApollo: withApollo 11 | }; 12 | 13 | export { 14 | Apollo , 15 | 16 | } 17 | /* ../helpers/withApollo Not a pure module */ 18 | -------------------------------------------------------------------------------- /src/utils/AllTheProviders.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import RouterProvider from '../utils/RouterProvider'; 3 | import ApolloProvider from './ApolloProvider'; 4 | 5 | const AllTheProviders = ({ children }) => { 6 | return ( 7 | 8 | {children} 9 | 10 | ); 11 | }; 12 | 13 | export default AllTheProviders; 14 | -------------------------------------------------------------------------------- /src/components/__tests__/Header.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '../../utils/testUtils'; 3 | import Header from '../Header.gen'; 4 | 5 | describe('Header', () => { 6 | it('renders a title and 2 links', () => { 7 | const { getByText } = render(
); 8 | 9 | expect(getByText('Reddit')).toBeTruthy(); 10 | expect(getByText('Home')).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dblechoc/e2e", 3 | "private": true, 4 | "version": "0.0.0", 5 | "engines": { 6 | "node": "10.x" 7 | }, 8 | "scripts": { 9 | "e2e": "cypress run" 10 | }, 11 | "devDependencies": { 12 | "bluebird": "3.7.2", 13 | "cypress": "3.8.1", 14 | "faker": "4.1.0", 15 | "graphql-request": "1.8.2", 16 | "ora": "4.0.3", 17 | "uuid": "3.3.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/e2e/cypress/integration/basic.spec.js: -------------------------------------------------------------------------------- 1 | context('Index Integrations', () => { 2 | let subreddit; 3 | 4 | before(() => { 5 | cy.task('getSubreddit', 'reactjs').then(providedSubreddit => { 6 | subreddit = providedSubreddit; 7 | }); 8 | }); 9 | 10 | beforeEach(() => { 11 | cy.openPage(); 12 | }); 13 | 14 | it('can display react news', () => { 15 | cy.contains(subreddit.posts[0].title); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/utils/testUtils.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import AllTheProviders from '../utils/AllTheProviders'; 4 | 5 | const customRender = (ui: React.ReactElement, options?: any) => 6 | render(ui, { wrapper: AllTheProviders, ...options }); 7 | 8 | // re-export everything 9 | export * from '@testing-library/react'; 10 | 11 | // override render method 12 | export { customRender as render }; 13 | -------------------------------------------------------------------------------- /stories/base.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --navbar: #673ab7; 3 | --navbarTextColor: #fff; 4 | --navbarHeight: 56px; 5 | } 6 | 7 | * { 8 | box-sizing: border-box !important; 9 | } 10 | 11 | html { 12 | font-size: 10px; 13 | } 14 | 15 | body { 16 | font-size: 1.6rem; 17 | margin: 0; 18 | font-family: system-ui, -apple-system, BlinkMacSystemFont, segoeUI, Roboto, 19 | Ubuntu, 'Helvetica Neue', sans-serif; 20 | -webkit-tap-highlight-color: transparent; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Header.gen.tsx: -------------------------------------------------------------------------------- 1 | /* TypeScript file generated by genType. */ 2 | /* eslint-disable import/first */ 3 | 4 | 5 | // tslint:disable-next-line:no-var-requires 6 | const HeaderBS = require('./Header.bs'); 7 | 8 | // tslint:disable-next-line:interface-over-type-literal 9 | export type Props = { readonly className?: string }; 10 | 11 | export const $$default: React.ComponentType<{ readonly className?: string }> = HeaderBS.default; 12 | 13 | export default $$default; 14 | -------------------------------------------------------------------------------- /src/components/Subreddit.gen.tsx: -------------------------------------------------------------------------------- 1 | /* TypeScript file generated by genType. */ 2 | /* eslint-disable import/first */ 3 | 4 | 5 | // tslint:disable-next-line:no-var-requires 6 | const SubredditBS = require('./Subreddit.bs'); 7 | 8 | // tslint:disable-next-line:interface-over-type-literal 9 | export type Props = { readonly name: string }; 10 | 11 | export const $$default: React.ComponentType<{ readonly name: string }> = SubredditBS.default; 12 | 13 | export default $$default; 14 | -------------------------------------------------------------------------------- /src/pages/subreddit/[name].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import { useRouter } from 'next/router'; 4 | import Subreddit from '../../components/Subreddit.gen'; 5 | import withApollo from '../../helpers/withApollo'; 6 | 7 | const SubredditForName: NextPage = () => { 8 | const router = useRouter(); 9 | return ; 10 | }; 11 | 12 | export default withApollo(SubredditForName); 13 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = async ({ config }) => { 2 | // get css modules working 3 | const cssLoaderOptions = config.module.rules[2].use[1].options; 4 | cssLoaderOptions.modules = true; 5 | 6 | // typescript 7 | config.module.rules.push({ 8 | test: /\.(ts|tsx)$/, 9 | use: [ 10 | { 11 | loader: require.resolve('awesome-typescript-loader'), 12 | }, 13 | ], 14 | }); 15 | config.resolve.extensions.push('.ts', '.tsx'); 16 | 17 | return config; 18 | }; 19 | -------------------------------------------------------------------------------- /src/utils/fixtures/subredditFixture.tsx: -------------------------------------------------------------------------------- 1 | export default { 2 | posts: [ 3 | { 4 | id: 'bvxng8', 5 | title: "Beginner's Thread / Easy Questions (June 2019)", 6 | __typename: 'Post', 7 | }, 8 | { 9 | id: 'bvnjwx', 10 | title: 'Who’s Hiring? [June 2019]', 11 | __typename: 'Post', 12 | }, 13 | { 14 | id: 'c0kx6u', 15 | title: 'Announcing styled-components v5: Beast Mode 💪🔥', 16 | __typename: 'Post', 17 | }, 18 | ], 19 | __typename: 'Subreddit', 20 | }; 21 | -------------------------------------------------------------------------------- /public/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Reddit", 3 | "short_name": "reason-graphql-demo", 4 | "background_color": "#FFFFFF", 5 | "theme_color": "#673AB7", 6 | "description": "reddit pwa next.js", 7 | "display": "standalone", 8 | "start_url": "/", 9 | "icons": [ 10 | { 11 | "src": "/static/android-chrome-192x192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "/static/android-chrome-512x512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/index.gen.tsx: -------------------------------------------------------------------------------- 1 | /* TypeScript file generated by genType. */ 2 | /* eslint-disable import/first */ 3 | 4 | 5 | // tslint:disable-next-line:no-var-requires 6 | const indexBS = require('./index.bs'); 7 | 8 | // tslint:disable-next-line:interface-over-type-literal 9 | export type Index_Props = {}; 10 | 11 | export const Index: React.ComponentType<{}> = indexBS.index; 12 | 13 | // tslint:disable-next-line:interface-over-type-literal 14 | export type Props = {}; 15 | 16 | export const $$default: React.ComponentType<{}> = indexBS.default; 17 | 18 | export default $$default; 19 | -------------------------------------------------------------------------------- /src/components/Header.re: -------------------------------------------------------------------------------- 1 | type css = { 2 | . 3 | "header": string, 4 | "active": string, 5 | }; 6 | [@bs.module] external css: css = "./Header.css"; 7 | 8 | [@react.component] 9 | let make = (~className=?) => { 10 | let className = Cn.make([css##header, className->Cn.unpack]); 11 | 12 |
13 |

{React.string("Reddit")}

14 | 19 |
; 20 | }; 21 | 22 | [@genType] 23 | let default = make; 24 | -------------------------------------------------------------------------------- /src/utils/RouterProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { parse } from 'querystring'; 3 | import { NextRouter } from 'next/router'; 4 | 5 | const RouterContext = React.createContext(null as any); 6 | 7 | const defaultRouter: any = { 8 | route: 'index', 9 | pathname: '/', 10 | query: parse('/'), 11 | asPath: '/', 12 | }; 13 | 14 | const RouterProvider = ({ router = defaultRouter, children }) => { 15 | return ( 16 | {children} 17 | ); 18 | }; 19 | 20 | export default RouterProvider; 21 | -------------------------------------------------------------------------------- /workflows/action-cypress/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [ -n "$NPM_AUTH_TOKEN" ]; then 6 | # Respect NPM_CONFIG_USERCONFIG if it is provided, default to $HOME/.npmrc 7 | NPM_CONFIG_USERCONFIG="${NPM_CONFIG_USERCONFIG-"$HOME/.npmrc"}" 8 | NPM_REGISTRY_URL="${NPM_REGISTRY_URL-registry.npmjs.org}" 9 | 10 | # Allow registry.npmjs.org to be overridden with an environment variable 11 | printf "//$NPM_REGISTRY_URL/:_authToken=$NPM_AUTH_TOKEN\nregistry=$NPM_REGISTRY_URL" > "$NPM_CONFIG_USERCONFIG" 12 | chmod 0600 "$NPM_CONFIG_USERCONFIG" 13 | fi 14 | 15 | sh -c "yarn $*" 16 | -------------------------------------------------------------------------------- /src/utils/ApolloProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ApolloProvider as ActualApolloProvider } from '@apollo/react-hooks'; 3 | import ApolloClient from 'apollo-client'; 4 | import { createApolloClient } from '../helpers/initApollo'; 5 | 6 | type Props = { 7 | apolloClient?: ApolloClient<{}>; 8 | }; 9 | 10 | const ApolloProvider: React.FC = ({ 11 | apolloClient = createApolloClient('localhost:666', {}), 12 | children, 13 | }) => { 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | 21 | export default ApolloProvider; 22 | -------------------------------------------------------------------------------- /src/shims/ReasonPervasives.shim.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:no-var-requires 2 | const $$Array = require('bs-platform/lib/js/array'); 3 | 4 | // tslint:disable-next-line:max-classes-per-file 5 | export abstract class EmptyList { 6 | protected opaque: any; 7 | } 8 | 9 | // tslint:disable-next-line:max-classes-per-file 10 | export abstract class Cons { 11 | protected opaque!: T; 12 | } 13 | 14 | export type list = Cons | EmptyList; 15 | 16 | export function cons(itm: T, lst: list): list { 17 | return /* :: */ [itm, lst] as any; 18 | } 19 | 20 | export const emptyList: EmptyList = /* [] */ 0 as any; 21 | 22 | export const fromArray = $$Array.to_list; 23 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "reason-graphql-demo", 4 | "alias": "reason-graphql-demo.now.sh", 5 | "public": false, 6 | "regions": ["all"], 7 | "builds": [ 8 | { "src": "/public/static/*", "use": "@now/static" }, 9 | { "src": "next.config.js", "use": "@now/next" } 10 | ], 11 | "routes": [ 12 | { "src": "/static/(.*)", "dest": "/public/static/$1" }, 13 | { 14 | "src": "/service-worker.js$", 15 | "dest": "/_next/static/service-worker.js", 16 | "headers": { 17 | "cache-control": "public, max-age=43200, immutable", 18 | "Service-Worker-Allowed": "/" 19 | } 20 | }, 21 | { "src": "/(.*)", "dest": "/$1" } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/index.bs.js: -------------------------------------------------------------------------------- 1 | // Generated by BUCKLESCRIPT, PLEASE EDIT WITH CARE 2 | 3 | import * as Hoc from "../bindings/Hoc.bs.js"; 4 | import * as React from "react"; 5 | import * as Subreddit from "../components/Subreddit.bs.js"; 6 | 7 | function ste(prim) { 8 | return prim; 9 | } 10 | 11 | function Index(Props) { 12 | return React.createElement(Subreddit.make, { 13 | name: "reactJs" 14 | }); 15 | } 16 | 17 | var $$default = Hoc.Apollo.withApollo(Index); 18 | 19 | var make = Index; 20 | 21 | var index = Index; 22 | 23 | export { 24 | ste , 25 | make , 26 | index , 27 | $$default , 28 | $$default as default, 29 | 30 | } 31 | /* default Not a pure module */ 32 | -------------------------------------------------------------------------------- /src/components/ActiveLink.re: -------------------------------------------------------------------------------- 1 | [@react.component] 2 | let make = 3 | ( 4 | ~href: string=?, 5 | ~activeClassName: string=?, 6 | ~router=Next.Router.useRouter(), 7 | ~children, 8 | ) => { 9 | let handleClick = event => { 10 | event |> ReactEvent.Mouse.preventDefault; 11 | router->Belt.Option.map(router => Next.Router.push(router, ~url=href)) 12 | |> ignore; 13 | }; 14 | 15 | let className = 16 | Cn.make([ 17 | activeClassName->Cn.ifTrue( 18 | router 19 | ->Belt.Option.map(router => router##pathname) 20 | ->Belt.Option.getWithDefault("/") 21 | === href, 22 | ), 23 | ]); 24 | 25 | children ; 26 | }; 27 | 28 | [@genType] 29 | let default = make; 30 | -------------------------------------------------------------------------------- /src/shims/ReactShim.shim.ts: -------------------------------------------------------------------------------- 1 | export type reactElement = JSX.Element; 2 | export type element = reactElement; 3 | 4 | // tslint:disable-next-line:max-classes-per-file 5 | export abstract class component { 6 | protected opaque: unknown; 7 | } 8 | // tslint:disable-next-line:max-classes-per-file 9 | export abstract class componentSpec { 10 | protected opaque: unknown; 11 | } 12 | // tslint:disable-next-line:max-classes-per-file 13 | export abstract class noRetainedProps { 14 | protected opaque: unknown; 15 | } 16 | // tslint:disable-next-line:max-classes-per-file 17 | export abstract class actionless { 18 | protected opaque: unknown; 19 | } 20 | // tslint:disable-next-line:max-classes-per-file 21 | export abstract class stateless { 22 | protected opaque: unknown; 23 | } 24 | // tslint:disable-next-line:max-classes-per-file 25 | export abstract class reactRef { 26 | protected opaque: unknown; 27 | } 28 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { 2 | configure, 3 | addDecorator, 4 | getStorybook, 5 | setAddon, 6 | } from '@storybook/react'; 7 | import centered from '@storybook/addon-centered/react'; 8 | import createPercyAddon from '@percy-io/percy-storybook'; 9 | import AllTheProviders from '../src/utils/AllTheProviders'; 10 | 11 | // automatically import all files ending in *.stories.js 12 | const req = require.context('../stories', true, /.stories.tsx?$/); 13 | function loadStories() { 14 | addDecorator(centered); 15 | addDecorator(fn => {fn()}); 16 | req.keys().forEach(filename => req(filename)); 17 | } 18 | 19 | const { percyAddon, serializeStories } = createPercyAddon(); 20 | setAddon(percyAddon); 21 | 22 | configure(loadStories, module); 23 | 24 | // NOTE: if you're using the Storybook options addon, call serializeStories *BEFORE* the setOptions call 25 | serializeStories(getStorybook); 26 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: ['jest-canvas-mock', '/jest/setup.js'], 3 | setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], 4 | moduleFileExtensions: ['web.js', 'js', 'jsx', 'json', 'ts', 'tsx', 'bs.js'], 5 | modulePathIgnorePatterns: ['dist'], 6 | moduleNameMapper: { 7 | '^.+\\.css$': '/jest/identity-obj-proxy-esm.js', 8 | '^.+\\.(ico|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 9 | '/__mocks__/fileMock.js', 10 | }, 11 | transform: { 12 | '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', 13 | }, 14 | transformIgnorePatterns: [ 15 | '/node_modules/(?!(bs-platform|re-classnames|@dblechoc/bs-apollo|@dblechoc/bs-next)/)', 16 | ], 17 | testRegex: '/__tests__/.*\\.(js|jsx|ts|tsx)$', 18 | testPathIgnorePatterns: [ 19 | '/dist/', 20 | '/packages/e2e/', 21 | '/packages/*/dist', 22 | ], 23 | clearMocks: true, 24 | }; 25 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Main workflow 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Install, Test, Snapshot, e2e and Deploy 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | 10 | - name: Install 11 | uses: ./workflows/action-cypress/ 12 | with: 13 | args: install 14 | 15 | - name: Test 16 | uses: ./workflows/action-cypress/ 17 | with: 18 | args: ci 19 | 20 | - name: Snapshot UI 21 | uses: ./workflows/action-cypress/ 22 | env: 23 | PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} 24 | with: 25 | args: snapshot-ui 26 | 27 | - name: End to End 28 | uses: ./workflows/action-cypress/ 29 | with: 30 | args: e2e 31 | 32 | - name: Deploy 33 | uses: ./workflows/action-cypress/ 34 | env: 35 | NOW_TOKEN: ${{ secrets.NOW_TOKEN }} 36 | with: 37 | args: deploy 38 | -------------------------------------------------------------------------------- /src/components/Header.css: -------------------------------------------------------------------------------- 1 | .header { 2 | position: sticky; 3 | left: 0; 4 | top: 0; 5 | width: 100%; 6 | height: var(--navbarHeight); 7 | padding: 0; 8 | background: var(--navbar); 9 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); 10 | z-index: 50; 11 | } 12 | 13 | .header h1 { 14 | float: left; 15 | margin: 0; 16 | padding: 0 15px; 17 | font-size: 24px; 18 | line-height: var(--navbarHeight); 19 | font-weight: 400; 20 | color: var(--navbarTextColor); 21 | } 22 | 23 | .header nav { 24 | float: right; 25 | } 26 | 27 | .header nav a { 28 | display: inline-block; 29 | line-height: var(--navbarHeight); 30 | padding: 0 15px; 31 | min-width: 50px; 32 | text-align: center; 33 | background: rgba(255, 255, 255, 0); 34 | text-decoration: none; 35 | color: var(--navbarTextColor); 36 | will-change: background-color; 37 | } 38 | 39 | .header nav a:hover, 40 | .header nav a:active { 41 | background: rgba(0, 0, 0, 0.1); 42 | } 43 | 44 | .header nav a.active { 45 | background: rgba(0, 0, 0, 0.2); 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/fetchMocks.tsx: -------------------------------------------------------------------------------- 1 | import { GlobalWithFetchMock } from 'jest-fetch-mock'; 2 | import subredditFixture from './fixtures/subredditFixture'; 3 | 4 | const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock; 5 | 6 | type Subreddit = typeof subredditFixture; 7 | 8 | export function mockFetchSubredditOnce({ 9 | delay = 0, 10 | subreddit = subredditFixture, 11 | }: { delay?: number; subreddit?: Subreddit | null } = {}) { 12 | customGlobal.fetch.mockResponseOnce( 13 | () => 14 | new Promise(resolve => 15 | setTimeout( 16 | () => 17 | resolve({ 18 | body: JSON.stringify({ 19 | data: { 20 | subreddit, 21 | }, 22 | }), 23 | }), 24 | delay, 25 | ), 26 | ), 27 | ); 28 | 29 | return subreddit; 30 | } 31 | 32 | export function mockFetchErrorResponseOnce(message = 'fake error message') { 33 | customGlobal.fetch.mockRejectOnce(new Error(message)); 34 | 35 | return message; 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "module": "esnext", 6 | "jsx": "preserve", 7 | "moduleResolution": "node", 8 | "allowSyntheticDefaultImports": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "removeComments": false, 12 | "preserveConstEnums": true, 13 | "sourceMap": true, 14 | "skipLibCheck": true, 15 | "typeRoots": ["./node_modules/@types"], 16 | "lib": ["dom", "es2015", "es2016", "es2017.object"], 17 | "strictNullChecks": true, 18 | "rootDir": "./", 19 | "baseUrl": "./", 20 | "allowJs": true, 21 | "strict": false, 22 | "forceConsistentCasingInFileNames": true, 23 | "esModuleInterop": true, 24 | "resolveJsonModule": true, 25 | "isolatedModules": true, 26 | "noEmit": true 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx"], 29 | "exclude": ["node_modules"], 30 | "awesomeTypescriptLoaderOptions": { 31 | "useBabel": true, 32 | "babelCore": "@babel/core" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /bsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reason-graphql-demo", 3 | "sources": [ 4 | { 5 | "dir": "src", 6 | "subdirs": true 7 | } 8 | ], 9 | "bs-dependencies": [ 10 | "reason-react", 11 | "re-classnames", 12 | "@dblechoc/bs-next", 13 | "@dblechoc/bs-apollo" 14 | ], 15 | "reason": { "react-jsx": 3 }, 16 | "package-specs": { 17 | "module": "es6", 18 | "in-source": true 19 | }, 20 | "suffix": ".bs.js", 21 | "bsc-flags": ["-bs-super-errors"], 22 | "ppx-flags": ["@baransu/graphql_ppx_re/ppx6"], 23 | "refmt": 3, 24 | "warnings": { 25 | "number": "+A-48-102", 26 | "error": "+A-3-44-102" 27 | }, 28 | "gentypeconfig": { 29 | "language": "typescript", 30 | "module": "es6", 31 | "importPath": "relative", 32 | "shims": { 33 | "Js": "Js", 34 | "React": "ReactShim", 35 | "ReactEvent": "ReactEvent", 36 | "ReasonPervasives": "ReasonPervasives", 37 | "ReasonReact": "ReactShim", 38 | "Next": "Next" 39 | }, 40 | "debug": { 41 | "all": false, 42 | "basic": false 43 | }, 44 | "exportInterfaces": false 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/Subreddit.re: -------------------------------------------------------------------------------- 1 | let ste = ReasonReact.string; 2 | 3 | type post = { 4 | id: string, 5 | title: string, 6 | }; 7 | 8 | type subreddit = {posts: array(post)}; 9 | 10 | module SubredditQuery = [%graphql 11 | {| 12 | query GetSubreddit($name: String!) { 13 | subreddit(name: $name) { 14 | posts { 15 | id 16 | title 17 | } 18 | } 19 | } 20 | |} 21 | ]; 22 | 23 | [@react.component] 24 | let make = (~name) => { 25 | let query = SubredditQuery.make(~name, ()); 26 | let result = ApolloHooks.useQuery(~query); 27 | 28 | switch (result) { 29 | | ApolloHooks.Loading =>
{ste("Loading")}
30 | | ApolloHooks.Error(message) =>
{ste(message)}
31 | | ApolloHooks.Data(response) => 32 | switch (response##subreddit) { 33 | | Some(subreddit) => 34 |
    35 | {subreddit##posts 36 | |> Array.map(post =>
  • {post##title |> ste}
  • ) 37 | |> ReasonReact.array} 38 |
39 | | _ =>
{"No stories found" |> ste}
40 | } 41 | }; 42 | }; 43 | 44 | [@genType "Subreddit"] 45 | let default = make; 46 | -------------------------------------------------------------------------------- /src/components/Header.bs.js: -------------------------------------------------------------------------------- 1 | // Generated by BUCKLESCRIPT, PLEASE EDIT WITH CARE 2 | 3 | import * as Cn from "re-classnames/src/Cn.bs.js"; 4 | import * as React from "react"; 5 | import * as ActiveLink from "./ActiveLink.bs.js"; 6 | import * as HeaderCss from "./Header.css"; 7 | 8 | var css = HeaderCss; 9 | 10 | function Header(Props) { 11 | var className = Props.className; 12 | var className$1 = Cn.make(/* :: */[ 13 | css.header, 14 | /* :: */[ 15 | Cn.unpack(className), 16 | /* [] */0 17 | ] 18 | ]); 19 | return React.createElement("header", { 20 | className: className$1 21 | }, React.createElement("h1", undefined, "Reddit"), React.createElement("nav", undefined, React.createElement(ActiveLink.make, { 22 | href: "/", 23 | activeClassName: css.active, 24 | children: "Home" 25 | }))); 26 | } 27 | 28 | var make = Header; 29 | 30 | var $$default = Header; 31 | 32 | export { 33 | css , 34 | make , 35 | $$default , 36 | $$default as default, 37 | 38 | } 39 | /* css Not a pure module */ 40 | -------------------------------------------------------------------------------- /src/components/ActiveLink.gen.tsx: -------------------------------------------------------------------------------- 1 | /* TypeScript file generated by genType. */ 2 | /* eslint-disable import/first */ 3 | 4 | 5 | import * as React from 'react'; 6 | 7 | // tslint:disable-next-line:no-var-requires 8 | const ActiveLinkBS = require('./ActiveLink.bs'); 9 | 10 | import {Router_t as Next_Router_t} from '../../src/shims/Next.shim'; 11 | 12 | // tslint:disable-next-line:interface-over-type-literal 13 | export type Props = { 14 | readonly activeClassName: string; 15 | readonly children: React.ReactNode; 16 | readonly href: string; 17 | readonly router?: (null | undefined | Next_Router_t) 18 | }; 19 | 20 | export const $$default: React.ComponentType<{ 21 | readonly activeClassName: string; 22 | readonly children: React.ReactNode; 23 | readonly href: string; 24 | readonly router?: (null | undefined | Next_Router_t) 25 | }> = function ActiveLink(Arg1: any) { 26 | const $props = {activeClassName:Arg1.activeClassName, children:Arg1.children, href:Arg1.href, router:(Arg1.router == null ? undefined : (Arg1.router == null ? undefined : Arg1.router))}; 27 | const result = React.createElement(ActiveLinkBS.default, $props); 28 | return result 29 | }; 30 | 31 | export default $$default; 32 | -------------------------------------------------------------------------------- /src/pages/api/graphql.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer, gql } from 'apollo-server-micro'; 2 | import fetch from 'isomorphic-fetch'; 3 | 4 | export type PostData = { 5 | data: Post; 6 | }; 7 | 8 | export type Post = { 9 | id: string; 10 | title: string; 11 | author: string; 12 | ups: number; 13 | }; 14 | 15 | export type Subreddit = { 16 | children: PostData[]; 17 | }; 18 | 19 | export type SubredditData = { 20 | data: Subreddit; 21 | }; 22 | 23 | const typeDefs = gql` 24 | type Query { 25 | subreddit(name: String!): Subreddit 26 | } 27 | 28 | type Subreddit { 29 | posts: [Post!]! 30 | } 31 | 32 | type Post { 33 | id: String! 34 | title: String! 35 | author: String! 36 | """ 37 | The upvotes a post has received 38 | """ 39 | ups: Int! 40 | } 41 | `; 42 | 43 | const resolvers = { 44 | Query: { 45 | subreddit: async (_: any, { name }) => { 46 | const response: SubredditData = await fetch( 47 | `https://www.reddit.com/r/${name}.json`, 48 | ).then(r => r.json()); 49 | return response && response.data; 50 | }, 51 | }, 52 | Subreddit: { 53 | posts: (subreddit: Subreddit) => 54 | subreddit ? subreddit.children.map(child => child.data) : [], 55 | }, 56 | }; 57 | 58 | const server = new ApolloServer({ 59 | typeDefs, 60 | resolvers, 61 | introspection: true, 62 | playground: true, 63 | }); 64 | 65 | export const config = { 66 | api: { 67 | bodyParser: false, 68 | }, 69 | }; 70 | 71 | export default server.createHandler({ path: '/api/graphql' }); 72 | -------------------------------------------------------------------------------- /src/components/ActiveLink.bs.js: -------------------------------------------------------------------------------- 1 | // Generated by BUCKLESCRIPT, PLEASE EDIT WITH CARE 2 | 3 | import * as Cn from "re-classnames/src/Cn.bs.js"; 4 | import * as React from "react"; 5 | import * as Belt_Option from "bs-platform/lib/es6/belt_Option.js"; 6 | import * as Caml_option from "bs-platform/lib/es6/caml_option.js"; 7 | import * as Router from "next/router"; 8 | 9 | function ActiveLink(Props) { 10 | var href = Props.href; 11 | var activeClassName = Props.activeClassName; 12 | var match = Props.router; 13 | var router = match !== undefined ? Caml_option.valFromOption(match) : Caml_option.nullable_to_opt(Router.useRouter()); 14 | var children = Props.children; 15 | var handleClick = function ($$event) { 16 | $$event.preventDefault(); 17 | Belt_Option.map(router, (function (router) { 18 | return router.push(href); 19 | })); 20 | return /* () */0; 21 | }; 22 | var className = Cn.make(/* :: */[ 23 | Cn.ifTrue(activeClassName, Belt_Option.getWithDefault(Belt_Option.map(router, (function (router) { 24 | return router.pathname; 25 | })), "/") === href), 26 | /* [] */0 27 | ]); 28 | return React.createElement("a", { 29 | className: className, 30 | href: href, 31 | onClick: handleClick 32 | }, children); 33 | } 34 | 35 | var make = ActiveLink; 36 | 37 | var $$default = ActiveLink; 38 | 39 | export { 40 | make , 41 | $$default , 42 | $$default as default, 43 | 44 | } 45 | /* react Not a pure module */ 46 | -------------------------------------------------------------------------------- /packages/e2e/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | const ora = require('ora'); 2 | const Promise = require('bluebird'); 3 | const { GraphQLClient } = require('graphql-request'); 4 | 5 | const queries = { 6 | subredditQuery: ` 7 | query GetSubreddit($name: String!) { 8 | subreddit(name: $name) { 9 | posts { 10 | id 11 | title 12 | } 13 | } 14 | } 15 | `, 16 | }; 17 | 18 | const makeGraphRequest = (apiUrl, operation, variables, user) => { 19 | const opts = {}; 20 | 21 | if (user && user.JWT) { 22 | opts.headers = { 23 | Authorization: `Bearer ${user.JWT}`, 24 | }; 25 | } 26 | 27 | const client = new GraphQLClient(apiUrl, opts); 28 | return client.request(operation, variables); 29 | }; 30 | 31 | const apiUrl = 'http://localhost:3000/api/graphql'; 32 | 33 | const getSubredditAsync = name => { 34 | return new Promise((resolve, reject) => { 35 | return makeGraphRequest(apiUrl, queries.subredditQuery, { 36 | name, 37 | }) 38 | .then(({ subreddit }) => { 39 | if (subreddit) { 40 | resolve(subreddit); 41 | } else { 42 | reject(new Error('Could not load subreddit')); 43 | } 44 | }) 45 | .catch(error => { 46 | reject(error); 47 | }); 48 | }); 49 | }; 50 | 51 | module.exports = (on, config) => { 52 | on('task', { 53 | getSubreddit(name) { 54 | const spinner = ora('Looking for subreddit').start(); 55 | return getSubredditAsync(name) 56 | .tap(() => { 57 | spinner.succeed('Found subreddit'); 58 | }) 59 | .tapCatch(err => { 60 | spinner.fail(err.message); 61 | }); 62 | }, 63 | }); 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/__tests__/ActiveLink.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from '../../utils/testUtils'; 3 | import ActiveLink from '../ActiveLink.gen'; 4 | 5 | describe('ActiveLink', () => { 6 | it('renders an active link', () => { 7 | const linkText = 'some text here'; 8 | const activeClassName = 'test-active'; 9 | 10 | const { getByText, container } = render( 11 | 12 | {linkText} 13 | , 14 | ); 15 | 16 | expect(getByText(linkText)).toBeTruthy(); 17 | expect(container.firstChild).toHaveClass(activeClassName); 18 | }); 19 | 20 | it('renders an inactive link', () => { 21 | const linkText = 'some text here'; 22 | const activeClassName = 'test-active'; 23 | 24 | const { getByText, container } = render( 25 | 26 | {linkText} 27 | , 28 | ); 29 | 30 | expect(getByText(linkText)).toBeTruthy(); 31 | expect(container.firstChild).not.toHaveClass(activeClassName); 32 | }); 33 | 34 | it('can click on a link', () => { 35 | const linkText = 'some text here'; 36 | const href = '/'; 37 | const router: any = { pathname: href }; 38 | router.push = jest.fn(); 39 | 40 | const { getByText } = render( 41 | 42 | {linkText} 43 | , 44 | ); 45 | 46 | const link = getByText(linkText); 47 | expect(link).toBeTruthy(); 48 | 49 | fireEvent.click(link); 50 | expect(router.push).toHaveBeenCalledWith(href); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /workflows/action-cypress/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | LABEL repository="https://github.com/sync/reason-graphql-demo" 4 | LABEL homepage="http://github.com/sync" 5 | LABEL maintainer="sync@github.com>" 6 | 7 | LABEL com.github.actions.name="GitHub Action for cypress" 8 | LABEL com.github.actions.description="Wraps the yarn CLI to enable common yarn commands with extra stuff added for cypress." 9 | LABEL com.github.actions.icon="package" 10 | LABEL com.github.actions.color="brown" 11 | 12 | # "fake" dbus address to prevent errors 13 | # https://github.com/SeleniumHQ/docker-selenium/issues/87 14 | ENV DBUS_SESSION_BUS_ADDRESS=/dev/null 15 | 16 | # For Cypress 17 | ENV CI=1 18 | 19 | # https://github.com/GoogleChrome/puppeteer/blob/9de34499ef06386451c01b2662369c224502ebe7/docs/troubleshooting.md#running-puppeteer-in-docker 20 | RUN apt-get update && apt-get install -y wget --no-install-recommends \ 21 | && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 22 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 23 | && apt-get update \ 24 | && apt-get -y install procps git less openssh-client python-dev python-pip \ 25 | && apt-get -y install libgtk2.0-0 libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 xvfb \ 26 | && apt-get -y install curl groff jq zip libpng-dev \ 27 | && apt-get install -y dbus-x11 google-chrome-unstable \ 28 | --no-install-recommends 29 | 30 | RUN npm install -g yarn 31 | RUN npm install -g --unsafe-perm now 32 | 33 | # It's a good idea to use dumb-init to help prevent zombie chrome processes. 34 | ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.0/dumb-init_1.2.0_amd64 /usr/local/bin/dumb-init 35 | RUN chmod +x /usr/local/bin/dumb-init 36 | 37 | COPY "entrypoint.sh" "/entrypoint.sh" 38 | ENTRYPOINT ["dumb-init", "--", "/entrypoint.sh"] 39 | CMD ["help"] 40 | -------------------------------------------------------------------------------- /src/__tests__/pages/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GlobalWithFetchMock } from 'jest-fetch-mock'; 3 | import mockConsole from 'jest-mock-console'; 4 | import { render, waitForElement } from '../../utils/testUtils'; 5 | import { Index } from '../../pages/index.gen'; 6 | import { 7 | mockFetchSubredditOnce, 8 | mockFetchErrorResponseOnce, 9 | } from '../../utils/fetchMocks'; 10 | 11 | const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock; 12 | 13 | describe('Index', () => { 14 | beforeEach(() => { 15 | // eslint-disable-next-line global-require 16 | customGlobal.fetch = require('jest-fetch-mock'); 17 | customGlobal.fetchMock = customGlobal.fetch; 18 | 19 | window.scrollTo = jest.fn(); 20 | }); 21 | 22 | afterEach(() => { 23 | customGlobal.fetch.resetMocks(); 24 | }); 25 | 26 | it('renders stories given some posts', async () => { 27 | const subreddit = mockFetchSubredditOnce()!; 28 | 29 | const { getByText } = render(); 30 | 31 | // first post 32 | await waitForElement(() => getByText(subreddit.posts[0].title)); 33 | 34 | // last post 35 | expect( 36 | getByText(subreddit.posts[subreddit.posts.length - 1].title), 37 | ).toBeTruthy(); 38 | }); 39 | 40 | it('renders no stories given no posts', async () => { 41 | mockFetchSubredditOnce({ subreddit: null }); 42 | 43 | const { getByText } = render(); 44 | 45 | await waitForElement(() => getByText('No stories found')); 46 | }); 47 | 48 | it('renders the provided error', async () => { 49 | const restoreConsole = mockConsole(); 50 | 51 | const message = mockFetchErrorResponseOnce(); 52 | 53 | const { getByText } = render(); 54 | 55 | await waitForElement(() => getByText(`Network error: ${message}`)); 56 | 57 | // eslint-disable-next-line no-console 58 | expect(console.log).toHaveBeenCalledWith( 59 | `[Network error]: Error: ${message}`, 60 | ); 61 | restoreConsole(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/shims/ReactEvent.shim.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:max-classes-per-file 2 | export abstract class Animation_t { 3 | protected opaque: unknown; 4 | } 5 | 6 | // tslint:disable-next-line:max-classes-per-file 7 | export abstract class Clipboard_t { 8 | protected opaque: unknown; 9 | } 10 | 11 | // tslint:disable-next-line:max-classes-per-file 12 | export abstract class Composition_t { 13 | protected opaque: unknown; 14 | } 15 | 16 | // tslint:disable-next-line:max-classes-per-file 17 | export abstract class Focus_t { 18 | protected opaque: unknown; 19 | } 20 | 21 | // tslint:disable-next-line:max-classes-per-file 22 | export abstract class Form_t { 23 | protected opaque: unknown; 24 | } 25 | 26 | // tslint:disable-next-line:max-classes-per-file 27 | export abstract class Keyboard_t { 28 | protected opaque: unknown; 29 | } 30 | 31 | // tslint:disable-next-line:max-classes-per-file 32 | export abstract class Image_t { 33 | protected opaque: unknown; 34 | } 35 | 36 | // tslint:disable-next-line:max-classes-per-file 37 | export abstract class Media_t { 38 | protected opaque: unknown; 39 | } 40 | 41 | // tslint:disable-next-line:max-classes-per-file 42 | export abstract class Mouse_t { 43 | protected opaque: unknown; 44 | } 45 | 46 | // tslint:disable-next-line:max-classes-per-file 47 | export abstract class Selection_t { 48 | protected opaque: unknown; 49 | } 50 | 51 | // tslint:disable-next-line:max-classes-per-file 52 | export abstract class Synthetic_t { 53 | protected opaque: unknown; 54 | } 55 | 56 | // tslint:disable-next-line:max-classes-per-file 57 | export abstract class Touch_t { 58 | protected opaque: unknown; 59 | } 60 | 61 | // tslint:disable-next-line:max-classes-per-file 62 | export abstract class Transition_t { 63 | protected opaque: unknown; 64 | } 65 | 66 | // tslint:disable-next-line:max-classes-per-file 67 | export abstract class UI_t { 68 | protected opaque: unknown; 69 | } 70 | 71 | // tslint:disable-next-line:max-classes-per-file 72 | export abstract class Wheel_t { 73 | protected opaque: unknown; 74 | } 75 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | ecmaVersion: 2018, 5 | sourceType: 'module', 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier', 11 | 'prettier/react', 12 | 'prettier/@typescript-eslint', 13 | 'plugin:jest/recommended', 14 | 'plugin:cypress/recommended', 15 | ], 16 | plugins: ['jest', '@typescript-eslint', 'cypress'], 17 | env: { 18 | browser: true, 19 | node: true, 20 | es6: true, 21 | }, 22 | settings: { 23 | react: { 24 | version: 'detect', 25 | }, 26 | }, 27 | rules: { 28 | 'jest/expect-expect': 0, 29 | }, 30 | overrides: [ 31 | { 32 | files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], 33 | rules: { 34 | '@typescript-eslint/no-undef': 'off', 35 | '@typescript-eslint/no-unused-vars': 'off', 36 | '@typescript-eslint/spaced-comment': 'off', 37 | '@typescript-eslint/no-restricted-globals': 'off', 38 | '@typescript-eslint/explicit-member-accessibility': 'off', 39 | '@typescript-eslint/explicit-function-return-type': 'off', 40 | '@typescript-eslint/camelcase': 'off', 41 | '@typescript-eslint/no-var-requires': 'off', 42 | '@typescript-eslint/class-name-casing': 'off', 43 | '@typescript-eslint/no-explicit-any': 'off', 44 | '@typescript-eslint/prefer-interface': 'off', 45 | '@typescript-eslint/no-non-null-assertion': 'off', 46 | }, 47 | }, 48 | { 49 | files: ['**/__tests__/**', '**/__mocks__/**'], 50 | globals: { 51 | mockData: true, 52 | }, 53 | env: { 54 | jest: true, 55 | }, 56 | }, 57 | ], 58 | globals: { 59 | fetch: true, 60 | __DEV__: true, 61 | window: true, 62 | FormData: true, 63 | XMLHttpRequest: true, 64 | requestAnimationFrame: true, 65 | cancelAnimationFrame: true, 66 | page: true, 67 | browser: true, 68 | 'cypress/globals': true, 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withTM = require('next-transpile-modules'); 2 | const withOffline = require('next-offline'); 3 | const withCSS = require('@zeit/next-css'); 4 | 5 | const isDev = process.env.NODE_ENV !== 'production'; 6 | const isProd = process.env.NODE_ENV === 'production'; 7 | const disableServerless = Boolean(process.env.DISABLE_SERVERLESS); 8 | 9 | const baseTarget = disableServerless ? {} : { target: 'serverless' }; 10 | 11 | const config = { 12 | env: { 13 | isDev, 14 | isProd, 15 | }, 16 | ...baseTarget, 17 | crossOrigin: 'anonymous', 18 | webpack: config => { 19 | const rules = config.module.rules; 20 | 21 | // don't even ask my why 22 | config.node = { 23 | fs: 'empty', 24 | }; 25 | 26 | // some react native library need this 27 | rules.push({ 28 | test: /\.(gif|jpe?g|png|svg)$/, 29 | use: { 30 | loader: 'url-loader', 31 | options: { 32 | name: '[name].[ext]', 33 | }, 34 | }, 35 | }); 36 | // .mjs before .js (fixing failing now.sh deploy) 37 | config.resolve.extensions = [ 38 | '.wasm', 39 | '.mjs', 40 | '.web.js', 41 | '.web.jsx', 42 | '.ts', 43 | '.tsx', 44 | '.js', 45 | '.jsx', 46 | '.json', 47 | '.bs.js', 48 | '.gen.tsx', 49 | ]; 50 | 51 | return config; 52 | }, 53 | dontAutoRegisterSw: true, 54 | workboxOpts: { 55 | swDest: 'static/service-worker.js', 56 | runtimeCaching: [ 57 | { 58 | urlPattern: /^https?.*/, 59 | handler: 'NetworkFirst', 60 | options: { 61 | cacheName: 'https-calls', 62 | networkTimeoutSeconds: 15, 63 | expiration: { 64 | maxEntries: 150, 65 | maxAgeSeconds: 30 * 24 * 60 * 60, // 1 month 66 | }, 67 | cacheableResponse: { 68 | statuses: [0, 200], 69 | }, 70 | }, 71 | }, 72 | ], 73 | }, 74 | pageExtensions: ['jsx', 'js', 'web.js', 'web.jsx', 'ts', 'tsx', 'bs.js'], 75 | transpileModules: ['bs-platform', 're-classnames', 'bs-next', 'bs-apollo'], 76 | cssModules: true, 77 | }; 78 | 79 | module.exports = withOffline(withCSS(withTM(config))); 80 | -------------------------------------------------------------------------------- /src/helpers/initApollo.tsx: -------------------------------------------------------------------------------- 1 | import { Hermes } from 'apollo-cache-hermes'; 2 | import { ApolloClient } from 'apollo-client'; 3 | import { ApolloLink } from 'apollo-link'; 4 | import { onError } from 'apollo-link-error'; 5 | import { createHttpLink } from 'apollo-link-http'; 6 | import fetch from 'isomorphic-fetch'; 7 | 8 | let apolloClient: ApolloClient<{}> | null = null; 9 | 10 | export function createApolloClient( 11 | baseUrl: string, 12 | initialState: object | null, 13 | ) { 14 | const isBrowser = typeof window !== 'undefined'; 15 | const httpLink = createHttpLink({ 16 | uri: `${baseUrl}/api/graphql`, 17 | credentials: 'same-origin', 18 | // Use fetch() polyfill on the server 19 | fetch: isBrowser ? undefined : fetch, 20 | }); 21 | 22 | const errorLink = onError(({ graphQLErrors, networkError }) => { 23 | if (graphQLErrors) { 24 | graphQLErrors.map(({ message, locations, path }) => 25 | // eslint-disable-next-line no-console 26 | console.log( 27 | `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`, 28 | ), 29 | ); 30 | } 31 | 32 | if (networkError) { 33 | // eslint-disable-next-line no-console 34 | console.log(`[Network error]: ${networkError}`); 35 | } 36 | }); 37 | 38 | const transports = [errorLink, httpLink]; 39 | const allLink: any = ApolloLink.from(transports); 40 | 41 | // Check out https://github.com/zeit/next.js/pull/4611 if you want to use the AWSAppSyncClient 42 | return new ApolloClient({ 43 | connectToDevTools: isBrowser, 44 | ssrMode: !isBrowser, // Disables forceFetch on the server (so queries are only run once) 45 | link: allLink, 46 | cache: new Hermes({ 47 | resolverRedirects: { 48 | Query: { 49 | node: ({ id }) => id, 50 | }, 51 | }, 52 | addTypename: true, 53 | freeze: true, 54 | }).restore(initialState || {}), 55 | }); 56 | } 57 | 58 | export default function initApolloClient( 59 | baseUrl: string, 60 | initialState: object | null, 61 | ) { 62 | // Make sure to create a new client for every server-side request so that data 63 | // isn't shared between connections (which would be bad) 64 | if (typeof window === 'undefined') { 65 | return createApolloClient(baseUrl, initialState); 66 | } 67 | 68 | // Reuse client on the client-side 69 | if (!apolloClient) { 70 | apolloClient = createApolloClient(baseUrl, initialState); 71 | } 72 | 73 | return apolloClient; 74 | } 75 | -------------------------------------------------------------------------------- /src/helpers/withApollo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import Head from 'next/head'; 3 | import { ApolloProvider } from '@apollo/react-hooks'; 4 | import initApolloClient from './initApollo'; 5 | 6 | function getBaseUrl(req: any) { 7 | const protocol = req.headers['x-forwarded-proto'] || 'http'; 8 | const host = req.headers['x-forwarded-host'] || req.headers.host; 9 | return `${protocol}://${host}`; 10 | } 11 | 12 | export default function withApollo(PageComponent: any, { ssr = true } = {}) { 13 | const WithApollo = ({ apolloClient, apolloState, ...pageProps }) => { 14 | const client = useMemo( 15 | () => apolloClient || initApolloClient('', apolloState), 16 | [], 17 | ); 18 | 19 | return ( 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | // Set the correct displayName in development 27 | if (process.env.NODE_ENV !== 'production') { 28 | const displayName = 29 | PageComponent.displayName || PageComponent.name || 'Component'; 30 | 31 | if (displayName === 'App') { 32 | console.warn('This withApollo HOC only works with PageComponents.'); 33 | } 34 | 35 | WithApollo.displayName = `withApollo(${displayName})`; 36 | } 37 | 38 | // Allow Next.js to remove getInitialProps from the browser build 39 | if (typeof window === 'undefined') { 40 | if (ssr) { 41 | WithApollo.getInitialProps = async (ctx: any) => { 42 | const { AppTree, req } = ctx; 43 | 44 | let pageProps = {}; 45 | if (PageComponent.getInitialProps) { 46 | pageProps = await PageComponent.getInitialProps(ctx); 47 | } 48 | 49 | const baseUrl = getBaseUrl(req); 50 | 51 | // Run all GraphQL queries in the component tree 52 | // and extract the resulting data 53 | const apolloClient = initApolloClient(baseUrl, {}); 54 | 55 | try { 56 | // Run all GraphQL queries 57 | await require('@apollo/react-ssr').getDataFromTree( 58 | , 64 | ); 65 | } catch (error) { 66 | // Prevent Apollo Client GraphQL errors from crashing SSR. 67 | // Handle them in components via the data.error prop: 68 | // https://www.apollographql.com/docs/react/api/react-apollo.html#graphql-query-data-error 69 | console.error('Error while running `getDataFromTree`', error); 70 | } 71 | 72 | // getDataFromTree does not call componentWillUnmount 73 | // head side effect therefore need to be cleared manually 74 | Head.rewind(); 75 | 76 | // Extract query data from the Apollo store 77 | const apolloState = apolloClient.cache.extract(); 78 | 79 | return { 80 | ...pageProps, 81 | apolloState, 82 | }; 83 | }; 84 | } 85 | } 86 | 87 | return WithApollo; 88 | } 89 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Head, Main, NextScript } from 'next/document'; 2 | import React from 'react'; 3 | 4 | const serviceWorkerRegistration = ` 5 | document.addEventListener('DOMContentLoaded', event => { 6 | if ('serviceWorker' in navigator) { 7 | window.addEventListener('load', () => { 8 | navigator.serviceWorker.register('/service-worker.js', { scope: "/" }).then(registration => { 9 | console.log('SW registered: ', registration) 10 | }).catch(registrationError => { 11 | console.log('SW registration failed: ', registrationError) 12 | }) 13 | }) 14 | } 15 | }) 16 | `; 17 | 18 | export default class MyDocument extends Document { 19 | static getInitialProps({ renderPage }) { 20 | const page = renderPage(); 21 | 22 | const styles = [ 23 |