├── .gitignore
├── README.md
├── api
├── .gitignore
├── Makefile
└── swagger.yml
├── components
├── Common
│ ├── components
│ │ ├── Page.test.tsx
│ │ └── Page.tsx
│ ├── index.ts
│ └── rehydrateGlamor.ts
└── User
│ ├── components
│ └── Profile
│ │ ├── components
│ │ ├── FriendsList.test.tsx
│ │ ├── FriendsList.tsx
│ │ ├── Header.tsx
│ │ ├── Profile.test.tsx
│ │ └── Profile.tsx
│ │ └── index.ts
│ └── index.ts
├── global.d.ts
├── json.d.ts
├── lib
├── api
│ └── Client.ts
├── app.ts
└── user
│ ├── FakeUserGateway.ts
│ ├── HTTPUserGateway.test.ts
│ ├── HTTPUserGateway.ts
│ ├── LoadUserProfile.test.ts
│ ├── LoadUserProfile.ts
│ ├── User.ts
│ ├── UserGateway.ts
│ └── index.ts
├── package-lock.json
├── package.json
├── pages
├── _document.tsx
├── home
│ └── index.tsx
└── users
│ └── profile.tsx
├── react.d.ts
├── test
├── acceptance
│ ├── home
│ │ └── welcomeFolk.test.tsx
│ └── users
│ │ └── viewingProfile.test.tsx
└── support
│ ├── enzyme.ts
│ ├── index.ts
│ ├── jestEnzyme.ts
│ ├── mockExecute.ts
│ └── raf.ts
├── tsconfig.json
└── tsconfig.production.json
/.gitignore:
--------------------------------------------------------------------------------
1 | **/*.js
2 | .next
3 | node_modules
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TypeScript Next.js Clean Architecture
2 |
3 | A mouthful I agree! Pretty cool though.
4 |
5 | **Install:**
6 |
7 | ```
8 | npm install
9 | ```
10 |
11 | **Run development server:**
12 |
13 | ```
14 | npm run dev
15 | ```
16 |
17 | **Run tests:**
18 |
19 | With development server running in another tab:
20 |
21 | ```
22 | npm test
23 | ```
24 |
25 | Or watch your tests:
26 |
27 | ```
28 | npm test -- --watch
29 | ```
30 |
31 | **Run in production mode:**
32 |
33 | ```
34 | npm run prepare
35 | npm start
36 | ```
37 |
--------------------------------------------------------------------------------
/api/.gitignore:
--------------------------------------------------------------------------------
1 | .swagger-codegen*
2 | swagger.json
3 | mock
4 | README.md
5 |
--------------------------------------------------------------------------------
/api/Makefile:
--------------------------------------------------------------------------------
1 | all: install start
2 |
3 | install:
4 | swagger-codegen generate -i swagger.yml -l swagger
5 | swagger-codegen generate -i swagger.yml -l nodejs-server -o mock
6 |
7 | start:
8 | cd mock && npm start
9 |
--------------------------------------------------------------------------------
/api/swagger.yml:
--------------------------------------------------------------------------------
1 | swagger: "2.0"
2 | info:
3 | version: "0.0.1"
4 | title: Hello World App
5 | basePath: /api
6 | schemes:
7 | - http
8 | - https
9 | consumes:
10 | - application/json
11 | produces:
12 | - application/json
13 | paths:
14 | /users/{userId}:
15 | get:
16 | operationId: findUserById
17 | description: Returns user object
18 | parameters:
19 | - name: userId
20 | in: path
21 | description: The ID of the user
22 | required: true
23 | type: string
24 | responses:
25 | "200":
26 | description: Success
27 | schema:
28 | $ref: "#/definitions/User"
29 | default:
30 | description: Error
31 | schema:
32 | $ref: "#/definitions/Error"
33 | definitions:
34 | UserBasicInfo:
35 | properties:
36 | name:
37 | type: string
38 | example: "Mr Bob Example"
39 | biography:
40 | type: string
41 | example: "I like bicycles"
42 | twitter:
43 | type: string
44 | example: "@bobexample"
45 | createdAt:
46 | type: string
47 | format: date-time
48 | lastSeenAt:
49 | type: string
50 | format: date-time
51 |
52 | User:
53 | properties:
54 | basicInfo:
55 | $ref: "#/definitions/UserBasicInfo"
56 | friends:
57 | type: array
58 | items:
59 | $ref: "#/definitions/UserBasicInfo"
60 | example:
61 | - name: James
62 | biography: Like Bond
63 | createdAt: 2017-11-12T22:27:35.278Z
64 | lastSeenAt: 2017-11-12T22:27:35.278Z
65 | - name: Dan
66 | biography: Yeah mayne
67 | twitter: "@danmayne"
68 | createdAt: 2017-11-12T22:27:35.278Z
69 | lastSeenAt: 2017-11-12T22:27:35.278Z
70 |
71 | Error:
72 | required:
73 | - message
74 | properties:
75 | message:
76 | type: string
77 | example: "An example error occurred"
78 |
--------------------------------------------------------------------------------
/components/Common/components/Page.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { mount } from 'enzyme'
3 | import Page from './Page'
4 |
5 | describe('', () => {
6 | it('should use children', () => {
7 | const page = mount(Cool)
8 | expect(page).toIncludeText('Cool')
9 | })
10 | })
11 |
--------------------------------------------------------------------------------
/components/Common/components/Page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import '../rehydrateGlamor'
3 | import Head from 'next/head'
4 | import { Main } from 'glamorous'
5 |
6 | interface PageProps {
7 | title: string
8 | children: any
9 | }
10 |
11 | export default ({ title, children }: PageProps) =>
12 |
13 |
14 | {title}
15 |
16 |
17 | {children}
18 |
19 |
--------------------------------------------------------------------------------
/components/Common/index.ts:
--------------------------------------------------------------------------------
1 | import Page from './components/Page'
2 |
3 | export { Page }
4 |
--------------------------------------------------------------------------------
/components/Common/rehydrateGlamor.ts:
--------------------------------------------------------------------------------
1 | import { rehydrate } from 'glamor'
2 |
3 | if (typeof window !== 'undefined' && window.__NEXT_DATA__) {
4 | rehydrate(window.__NEXT_DATA__.ids)
5 | }
6 |
--------------------------------------------------------------------------------
/components/User/components/Profile/components/FriendsList.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { shallow } from 'enzyme'
3 | import FriendsList from './FriendsList'
4 |
5 | describe('FriendsList', () => {
6 | describe('when there are friends', () => {
7 | const friends = [
8 | { name: 'Bob' },
9 | { name: 'Frankie' }
10 | ]
11 |
12 | it('should list friends', () => {
13 | const friendsList = shallow()
14 | expect(friendsList).toIncludeText('Bob')
15 | expect(friendsList).toIncludeText('Frankie')
16 | })
17 | })
18 | })
19 |
--------------------------------------------------------------------------------
/components/User/components/Profile/components/FriendsList.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | interface Friend {
4 | name: string
5 | }
6 |
7 | interface FriendsListProps {
8 | friends: Array
9 | }
10 |
11 | export default ({ friends }: FriendsListProps) =>
12 |
13 | {friends.map(({ name }, i) => - {name}
)}
14 |
15 |
--------------------------------------------------------------------------------
/components/User/components/Profile/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import glamorous from 'glamorous'
3 |
4 | const Header = glamorous.header({
5 | fontSize: '1.5em'
6 | })
7 |
8 | const Name = glamorous.div({
9 | fontSize: '2em'
10 | })
11 |
12 | const Bio = glamorous.p()
13 |
14 | const Twitter = glamorous.p()
15 |
16 | interface HeaderProps {
17 | name: string
18 | biography: string
19 | twitter: string
20 | }
21 |
22 | export default ({ name, biography, twitter }: HeaderProps) =>
23 |
24 | {name}
25 | {biography}
26 | Follow on twitter: {twitter}
27 |
28 |
--------------------------------------------------------------------------------
/components/User/components/Profile/components/Profile.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { shallow } from 'enzyme'
3 | import Profile from './Profile'
4 | import Header from './Header'
5 | import FriendsList from './FriendsList'
6 |
7 | describe('', () => {
8 | const props = {
9 | name: 'Luke',
10 | biography: 'My bio',
11 | twitter: '@Cool',
12 | friends: [
13 | { name: 'Jenny' }
14 | ]
15 | }
16 |
17 | it('should display header', () => {
18 | const profile = shallow()
19 | expect(profile.find(Header)).toBePresent()
20 | })
21 |
22 | it('should display friends', () => {
23 | const profile = shallow()
24 | expect(profile.find(FriendsList)).toBePresent()
25 | })
26 | })
27 |
--------------------------------------------------------------------------------
/components/User/components/Profile/components/Profile.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import glamorous from 'glamorous'
3 | import Header from './Header'
4 | import FriendsList from './FriendsList'
5 |
6 | const Profile = glamorous.div()
7 |
8 | interface ProfileProps {
9 | name: string
10 | biography: string
11 | twitter: string
12 | friends: any
13 | }
14 |
15 | export default ({ name, biography, twitter, friends }: ProfileProps) =>
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/components/User/components/Profile/index.ts:
--------------------------------------------------------------------------------
1 | import Profile from './components/Profile'
2 |
3 | export { Profile }
4 |
--------------------------------------------------------------------------------
/components/User/index.ts:
--------------------------------------------------------------------------------
1 | import { Profile } from './components/Profile/index'
2 |
3 | export { Profile }
4 |
--------------------------------------------------------------------------------
/global.d.ts:
--------------------------------------------------------------------------------
1 | interface Window {
2 | __NEXT_DATA__: any
3 | }
4 |
--------------------------------------------------------------------------------
/json.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.json" {
2 | const value: any
3 | export default value
4 | }
5 |
--------------------------------------------------------------------------------
/lib/api/Client.ts:
--------------------------------------------------------------------------------
1 | import * as Swagger from 'swagger-client'
2 | import * as spec from '../../api/swagger.json'
3 |
4 | const API_HOST = process.env.API_HOST
5 |
6 | export function loadSpec (): any {
7 | return { ...spec, host: API_HOST }
8 | }
9 |
10 | export async function execute (operationId: string, parameters: object): Promise {
11 | const spec = loadSpec()
12 |
13 | try {
14 | const { body } = await Swagger.execute({ spec, operationId, parameters })
15 | return body
16 | } catch (e) {
17 | if (e.status === 404) {
18 | console.info(e.message, operationId, parameters)
19 | } else {
20 | throw e
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/lib/app.ts:
--------------------------------------------------------------------------------
1 | import Republic, { route } from 'republic/next'
2 | import { LoadUserProfile } from './user/'
3 |
4 | export default new Republic(
5 | route.page('/', 'home#index'),
6 | route.page('/:userId', 'users#profile', async ({ params }) => {
7 | return await LoadUserProfile({ userId: params.userId })
8 | })
9 | )
10 |
--------------------------------------------------------------------------------
/lib/user/FakeUserGateway.ts:
--------------------------------------------------------------------------------
1 | import User from './User'
2 |
3 | export async function findById (id: string) : Promise {
4 | return {
5 | name: 'Mr Luke Fake',
6 | email: 'luke@example.com',
7 | biography: 'Coool',
8 | twitter: '@Cool',
9 | friends: []
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/lib/user/HTTPUserGateway.test.ts:
--------------------------------------------------------------------------------
1 | import * as HTTPUserGateway from './HTTPUserGateway'
2 | import mockExecute from '../../test/support/mockExecute'
3 |
4 | describe('HTTPUserGateway', () => {
5 | const fakeUser = {
6 | basicInfo: { name: 'Jim', email: 'jim@example.com' },
7 | friends: []
8 | }
9 |
10 | describe('when finding by id', () => {
11 | describe('and user exists', () => {
12 | let user
13 |
14 | beforeEach(async () => {
15 | mockExecute('findUserById', { userId: 'guid' }).reply(200, fakeUser)
16 | user = await HTTPUserGateway.findById('guid')
17 | })
18 |
19 | test('user has name', () => {
20 | expect(user.name).toEqual('Jim')
21 | })
22 |
23 | test('user has email', () => {
24 | expect(user.email).toEqual('jim@example.com')
25 | })
26 |
27 | test('user has friends', () => {
28 | expect(user.friends).toEqual([])
29 | })
30 | })
31 |
32 | describe('and user does not exist', () => {
33 | test('nothing is returned', async () => {
34 | mockExecute('findUserById', { userId: 'not-found' }).reply(404)
35 | const user = await HTTPUserGateway.findById('not-found')
36 | expect(user).not.toBeDefined()
37 | })
38 | })
39 | })
40 | })
41 |
--------------------------------------------------------------------------------
/lib/user/HTTPUserGateway.ts:
--------------------------------------------------------------------------------
1 | import { execute } from '../api/Client'
2 | import User from './User'
3 |
4 | const API_ORIGIN = process.env.API_ORIGIN
5 |
6 | export async function findById (userId: string) : Promise {
7 | const user = await execute('findUserById', { userId })
8 |
9 | if (user) {
10 | return { ...user.basicInfo, friends: user.friends }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/lib/user/LoadUserProfile.test.ts:
--------------------------------------------------------------------------------
1 | import LoadUserProfile from './LoadUserProfile'
2 | import * as FakeUserGateway from './FakeUserGateway'
3 |
4 | describe('LoadUserProfile', () => {
5 | describe('when loading profile by id', () => {
6 | test('user has name', async () => {
7 | const { user } = await LoadUserProfile(FakeUserGateway, { userId: 'guid' })
8 | expect(user.name).toBe('Mr Luke Fake')
9 | })
10 | })
11 | })
12 |
--------------------------------------------------------------------------------
/lib/user/LoadUserProfile.ts:
--------------------------------------------------------------------------------
1 | import UserGateway from './UserGateway'
2 | import User from './User'
3 |
4 | type Request = {
5 | userId: string
6 | }
7 |
8 | type Response = {
9 | user: User
10 | }
11 |
12 | export default async function LoadUserProfile (gateway: UserGateway, req: Request): Promise {
13 | const user = await gateway.findById(req.userId)
14 | return { user }
15 | }
16 |
--------------------------------------------------------------------------------
/lib/user/User.ts:
--------------------------------------------------------------------------------
1 | export default interface User {
2 | name: string
3 | email: string
4 | biography: string
5 | twitter: string,
6 | friends: Array
7 | }
8 |
--------------------------------------------------------------------------------
/lib/user/UserGateway.ts:
--------------------------------------------------------------------------------
1 | import User from './User'
2 |
3 | export default interface UserGateway {
4 | findById (string): Promise
5 | }
6 |
--------------------------------------------------------------------------------
/lib/user/index.ts:
--------------------------------------------------------------------------------
1 | import * as partial from 'lodash.partial'
2 |
3 | import * as HTTPUserGateway from './HTTPUserGateway'
4 |
5 | import _LoadUserProfile from './LoadUserProfile'
6 | export const LoadUserProfile = partial(_LoadUserProfile, HTTPUserGateway)
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "build:ts": "tsc -p tsconfig.production.json",
4 | "build:next": "API_HOST=localhost:8080 next build",
5 | "prepare": "npm run clean && npm run build:ts && npm run build:next",
6 | "start": "NODE_ENV=production API_HOST=localhost:8080 republic lib/app.js",
7 | "dev": "tsc && NODE_ENV=development API_HOST=localhost:8080 nodemon lib/app.js --exec republic --watch lib/",
8 | "api": "cd api && make",
9 | "test": "API_HOST=api jest --notify",
10 | "clean": "rm -f {components,lib,pages,spec}/{*,*/*,*/*/*,*/*/*/*,*/*/*/*/*}.js"
11 | },
12 | "dependencies": {
13 | "@types/isomorphic-fetch": "0.0.34",
14 | "@types/react": "^16.0",
15 | "body-parser": "^1.18.2",
16 | "express": "^4.16.2",
17 | "glamor": "^2.20.40",
18 | "glamorous": "^4.11.0",
19 | "js-yaml": "^3.10.0",
20 | "lodash.partial": "^4.2.1",
21 | "next": "^4.1.2",
22 | "react": "^16.0",
23 | "react-dom": "^16.0",
24 | "republic": "^0.4.15",
25 | "swagger-client": "^3.3.3"
26 | },
27 | "devDependencies": {
28 | "@types/enzyme": "^3.1.4",
29 | "@types/jest": "^21.1.6",
30 | "enzyme": "^3.1.1",
31 | "enzyme-adapter-react-16": "^1.0.4",
32 | "jest": "^21.0",
33 | "jest-enzyme": "^4.0.1",
34 | "nock": "^9.0.14",
35 | "nodemon": "^1.12.1",
36 | "react-test-renderer": "^16.0",
37 | "swagger": "^0.7.5",
38 | "ts-jest": "^21.1.4",
39 | "typescript": "^2.1.5"
40 | },
41 | "jest": {
42 | "setupTestFrameworkScriptFile": "./test/support/index.ts",
43 | "transform": {
44 | "^.+\\.tsx?$": "/node_modules/ts-jest/preprocessor.js"
45 | },
46 | "testRegex": "^.+\\.test\\.ts",
47 | "moduleFileExtensions": [
48 | "tsx",
49 | "ts",
50 | "js"
51 | ]
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import Document, { Head, Main, NextScript } from 'next/document'
3 | import { renderStatic } from 'glamor/server'
4 |
5 | export default class MyDocument extends Document {
6 | static async getInitialProps ({ renderPage }) {
7 | const page = renderPage()
8 | const styles = renderStatic(() => page.html)
9 | return { ...page, ...styles }
10 | }
11 |
12 | props: {
13 | __NEXT_DATA__: any,
14 | ids: any,
15 | css: any
16 | }
17 |
18 | constructor (props) {
19 | super(props)
20 |
21 | if (this.props.ids) {
22 | this.props.__NEXT_DATA__.ids = this.props.ids
23 | }
24 | }
25 |
26 | render () {
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | )
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/pages/home/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import app from '../../lib/app'
3 | import { Page } from '../../components/Common'
4 |
5 | export default app.page(class extends React.Component {
6 | render () {
7 | return (
8 |
9 | Welcome
10 |
11 | )
12 | }
13 | })
14 |
--------------------------------------------------------------------------------
/pages/users/profile.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import app from '../../lib/app'
3 | import User from '../../lib/user/User'
4 | import { Page } from '../../components/Common'
5 | import { Profile } from '../../components/User'
6 |
7 | export default app.page(class extends React.Component {
8 | props: {
9 | user: User
10 | }
11 |
12 | render () {
13 | return (
14 |
15 |
16 |
17 | )
18 | }
19 | })
20 |
--------------------------------------------------------------------------------
/react.d.ts:
--------------------------------------------------------------------------------
1 | import 'react'
2 |
3 | declare module 'react' {
4 | interface StyleHTMLAttributes extends React.HTMLAttributes {
5 | jsx?: boolean
6 | global?: boolean
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/test/acceptance/home/welcomeFolk.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { mountPage } from 'republic/test-next'
3 | import Home from '../../../pages/home/index'
4 |
5 | describe('Welcome folk', () => {
6 | describe('when user visits', () => {
7 | test('they are welcomed', async () => {
8 | const page = await mountPage(Home, 'home#index')
9 | expect(page).toIncludeText('Welcome')
10 | })
11 | })
12 | })
13 |
--------------------------------------------------------------------------------
/test/acceptance/users/viewingProfile.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { mountPage } from 'republic/test-next'
3 | import mockExecute from '../../support/mockExecute'
4 | import UserProfile from '../../../pages/users/profile'
5 |
6 | describe('Viewing profile', () => {
7 | const userId = 'uniq-guid'
8 | const fakeUser = {
9 | basicInfo: { name: 'Luke', email: 'luke@example.com' },
10 | friends: []
11 | }
12 |
13 | describe('when user views the page', () => {
14 | test('user can see user name', async () => {
15 | mockExecute('findUserById', { userId }).reply(200, fakeUser)
16 | const page = await mountPage(UserProfile, 'users#profile', { userId })
17 | expect(page).toIncludeText(fakeUser.basicInfo.name)
18 | })
19 | })
20 | })
21 |
--------------------------------------------------------------------------------
/test/support/enzyme.ts:
--------------------------------------------------------------------------------
1 | import { configure } from 'enzyme'
2 | import * as Adapter from 'enzyme-adapter-react-16'
3 | configure({ adapter: new Adapter() })
4 |
--------------------------------------------------------------------------------
/test/support/index.ts:
--------------------------------------------------------------------------------
1 | import './raf'
2 | import './enzyme'
3 | import './jestEnzyme'
4 |
--------------------------------------------------------------------------------
/test/support/jestEnzyme.ts:
--------------------------------------------------------------------------------
1 | import 'jest-enzyme'
2 |
--------------------------------------------------------------------------------
/test/support/mockExecute.ts:
--------------------------------------------------------------------------------
1 | import * as Swagger from 'swagger-client'
2 | import * as nock from 'nock'
3 | import { loadSpec } from '../../lib/api/Client'
4 |
5 | export default function mockExecute (operationId: String, parameters: any): any {
6 | const spec = loadSpec()
7 | const { method, url } = Swagger.buildRequest({ spec, operationId, parameters })
8 | return nock(url).intercept('', method)
9 | }
10 |
--------------------------------------------------------------------------------
/test/support/raf.ts:
--------------------------------------------------------------------------------
1 | (global as any).requestAnimationFrame = function (callback: Function) {
2 | setTimeout(callback, 0)
3 | }
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": true,
3 | "compilerOptions": {
4 | "module": "commonjs",
5 | "moduleResolution": "Node",
6 | "target": "es2015",
7 | "jsx": "react",
8 | "lib": [
9 | "dom",
10 | "es5",
11 | "esnext"
12 | ]
13 | },
14 | "exclude": [
15 | "node_modules"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.production.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": [
4 | "node_modules",
5 | "**/*.test.ts",
6 | "**/*.test.tsx",
7 | "test"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------