├── .nvmrc ├── src ├── client │ ├── cypress.json │ ├── src │ │ ├── api │ │ │ ├── index.ts │ │ │ └── users.ts │ │ ├── actions │ │ │ ├── index.ts │ │ │ ├── users.test.ts │ │ │ └── users.ts │ │ ├── selectors │ │ │ ├── index.ts │ │ │ ├── users.ts │ │ │ └── users.test.ts │ │ ├── react-app-env.d.ts │ │ ├── constants │ │ │ ├── index.ts │ │ │ └── users.ts │ │ ├── reducers │ │ │ ├── router.ts │ │ │ ├── users.test.ts │ │ │ ├── index.ts │ │ │ ├── initialState.ts │ │ │ └── users.ts │ │ ├── setupTests.ts │ │ ├── pages │ │ │ ├── Home │ │ │ │ ├── Page.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── Page.tsx │ │ │ ├── NoMatch │ │ │ │ ├── Page.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── Page.tsx │ │ │ ├── UserList │ │ │ │ ├── Page.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── Page.tsx │ │ │ ├── UserNew │ │ │ │ ├── Page.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── Page.tsx │ │ │ └── UserView │ │ │ │ ├── Page.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── Page.tsx │ │ ├── config │ │ │ └── index.ts │ │ ├── components │ │ │ ├── Header │ │ │ │ ├── index.test.tsx │ │ │ │ └── index.tsx │ │ │ ├── Root │ │ │ │ ├── index.test.tsx │ │ │ │ └── index.tsx │ │ │ ├── UserList │ │ │ │ ├── index.test.tsx │ │ │ │ └── index.tsx │ │ │ └── UserForm │ │ │ │ ├── index.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── Form.tsx │ │ ├── index.css │ │ ├── validation │ │ │ └── index.ts │ │ ├── containers │ │ │ └── Header │ │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── store │ │ │ └── index.ts │ │ └── serviceWorker.ts │ ├── sample.env │ ├── cypress │ │ ├── config │ │ │ ├── ci.json │ │ │ └── development.json │ │ ├── fixtures │ │ │ └── example.json │ │ ├── integration │ │ │ └── home-page.spec.ts │ │ ├── support │ │ │ ├── index.js │ │ │ └── commands.js │ │ └── plugins │ │ │ └── index.js │ ├── public │ │ ├── favicon.ico │ │ ├── manifest.json │ │ ├── 404.html │ │ └── index.html │ ├── .gitignore │ ├── tsconfig.json │ ├── tslint.json │ ├── README.md │ └── package.json └── server │ ├── .dockerignore │ ├── .prettierrc │ ├── tsconfig.build.json │ ├── nodemon.json │ ├── test.env │ ├── nodemon-debug.json │ ├── sample.env │ ├── development.env │ ├── src │ ├── api │ │ ├── api.module.ts │ │ └── users │ │ │ ├── user.interface.ts │ │ │ ├── create-user.dto.ts │ │ │ ├── users.module.ts │ │ │ ├── users.entity.ts │ │ │ ├── users.controller.ts │ │ │ ├── users.service.ts │ │ │ └── users.controller.spec.ts │ ├── config │ │ ├── config.module.ts │ │ └── config.service.ts │ ├── main.ts │ ├── app.module.ts │ ├── common │ │ └── validation.pipe.ts │ └── migration │ │ └── 1549545103672-UsersTable.ts │ ├── test │ ├── jest-e2e.json │ └── app.e2e-spec.ts │ ├── tsconfig.json │ ├── tslint.json │ ├── ormconfig.ci.json │ ├── ormconfig.sample.json │ ├── Dockerfile │ ├── deployment.yml │ ├── README.md │ ├── package.json │ └── .gitignore ├── .semaphore ├── .gitignore ├── secrets │ ├── .gitignore │ ├── server-production-env-secret.sample.yml │ ├── server-ormconfig-production-secret.sample.yml │ └── gcr-secret.sample.yml ├── client-deploy.yml ├── client-deploy-build.yml ├── server-docker-build.yml ├── server-deploy-k8s.yml └── semaphore.yml ├── .gitignore ├── .sample.env ├── images ├── ci-pipeline-client.png └── ci-pipeline-server.png ├── docker-compose.yml ├── LICENSE.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.16.0 2 | -------------------------------------------------------------------------------- /src/client/cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.semaphore/.gitignore: -------------------------------------------------------------------------------- 1 | *-secret.yml 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *.swp 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.semaphore/secrets/.gitignore: -------------------------------------------------------------------------------- 1 | *-secret.yml 2 | -------------------------------------------------------------------------------- /src/client/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './users'; 2 | -------------------------------------------------------------------------------- /src/client/src/actions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './users'; 2 | -------------------------------------------------------------------------------- /src/client/src/selectors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './users'; 2 | -------------------------------------------------------------------------------- /src/client/sample.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_BASE_URL=http://localhost:3001/v1/api 2 | -------------------------------------------------------------------------------- /src/client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | Dockerfile 4 | -------------------------------------------------------------------------------- /src/server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.sample.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=postgres 2 | POSTGRES_USER=postgres 3 | POSTGRES_PASSWORD= 4 | POSTGRES_PORT=5432 5 | -------------------------------------------------------------------------------- /src/client/cypress/config/ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3030", 3 | "env": { 4 | "env": "ci" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/client/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | import { UsersActionTypes } from './users'; 2 | 3 | export { 4 | UsersActionTypes, 5 | }; 6 | -------------------------------------------------------------------------------- /images/ci-pipeline-client.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semaphoreci-demos/semaphore-demo-javascript/HEAD/images/ci-pipeline-client.png -------------------------------------------------------------------------------- /images/ci-pipeline-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semaphoreci-demos/semaphore-demo-javascript/HEAD/images/ci-pipeline-server.png -------------------------------------------------------------------------------- /src/client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semaphoreci-demos/semaphore-demo-javascript/HEAD/src/client/public/favicon.ico -------------------------------------------------------------------------------- /src/server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/client/cypress/config/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3030", 3 | "env": { 4 | "env": "development" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/client/src/selectors/users.ts: -------------------------------------------------------------------------------- 1 | import { IRootState } from '../reducers/initialState'; 2 | 3 | export const usersSelector = (state: IRootState) => state.users; 4 | -------------------------------------------------------------------------------- /src/server/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "ts-node -r tsconfig-paths/register src/main.ts" 6 | } 7 | -------------------------------------------------------------------------------- /src/client/src/reducers/router.ts: -------------------------------------------------------------------------------- 1 | import { History } from 'history'; 2 | import { connectRouter } from 'connected-react-router'; 3 | 4 | export default (history: History) => connectRouter(history); 5 | -------------------------------------------------------------------------------- /src/client/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new Adapter() }); 5 | 6 | export default undefined; 7 | -------------------------------------------------------------------------------- /src/server/test.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | PORT=3001 3 | URL_PREFIX=v1/api 4 | DATABASE_HOST=localhost 5 | DATABASE_USER=postgres 6 | DATABASE_PASSWORD= 7 | DATABASE_DBNAME=postgres 8 | DATABASE_PORT=5432 9 | -------------------------------------------------------------------------------- /src/client/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /src/server/nodemon-debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register src/main.ts" 6 | } -------------------------------------------------------------------------------- /src/server/sample.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=3001 3 | URL_PREFIX=v1/api 4 | DATABASE_HOST=localhost 5 | DATABASE_USER=demouser 6 | DATABASE_PASSWORD=qwerty 7 | DATABASE_DBNAME=demo 8 | DATABASE_PORT=5432 9 | -------------------------------------------------------------------------------- /src/client/src/pages/Home/Page.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Page from './Page'; 4 | 5 | it('renders without crashing', () => { 6 | shallow(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/client/src/pages/NoMatch/Page.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Page from './Page'; 4 | 5 | it('renders without crashing', () => { 6 | shallow(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/server/development.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=3001 3 | DATABASE_TYPE=postgres 4 | DATABASE_HOST=localhost 5 | DATABASE_USER=demouser 6 | DATABASE_PASSWORD=qwerty 7 | DATABASE_DBNAME=demo 8 | DATABASE_PORT=5432 -------------------------------------------------------------------------------- /src/server/src/api/api.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersModule } from './users/users.module'; 3 | 4 | @Module({ 5 | imports: [UsersModule], 6 | }) 7 | export class ApiModule {} 8 | -------------------------------------------------------------------------------- /src/client/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export const routes = { 2 | profile: '/me', 3 | users: { 4 | list: '/users/list', 5 | new: '/users/new', 6 | view: '/users/edit/:id', 7 | }, 8 | default: '/', 9 | }; 10 | -------------------------------------------------------------------------------- /src/server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/client/cypress/integration/home-page.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Home Page', () => { 4 | beforeEach(() => { 5 | cy.visit('/'); 6 | }); 7 | 8 | it('cy.get() - query DOM elements', () => { 9 | cy.get('h1').should('contain', 'Home'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/server/src/api/users/user.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | readonly username: string; 3 | readonly description: string; 4 | readonly firstName?: string; 5 | readonly lastName?: string; 6 | readonly age?: number; 7 | } 8 | 9 | export interface ICreatedUser extends IUser { 10 | readonly id: number; 11 | } 12 | -------------------------------------------------------------------------------- /src/client/src/components/Header/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Header from '.'; 4 | import { initialState } from '../../reducers/initialState'; 5 | 6 | it('renders without crashing', () => { 7 | const users = initialState.users; 8 | shallow(
); 9 | }); 10 | -------------------------------------------------------------------------------- /src/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es6", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./" 12 | }, 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /src/client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/client/src/components/Root/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Root from '.'; 4 | import configureStore, {history} from '../../store'; 5 | 6 | it('renders without crashing', () => { 7 | const {store} = configureStore(); 8 | shallow( 9 | , 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /src/client/src/selectors/users.test.ts: -------------------------------------------------------------------------------- 1 | import { initialState } from '../reducers/initialState'; 2 | import { usersSelector } from './users'; 3 | 4 | describe('users selectors', () => { 5 | it('select users', () => { 6 | const state = initialState; 7 | 8 | const expectedState = initialState.users; 9 | 10 | expect(usersSelector(state)).toEqual(expectedState); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/server/src/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigService } from './config.service'; 3 | 4 | @Module({ 5 | providers: [ 6 | { 7 | provide: ConfigService, 8 | useValue: new ConfigService(`${__dirname}/../../${process.env.NODE_ENV || 'development'}.env`), 9 | }, 10 | ], 11 | exports: [ConfigService], 12 | }) 13 | export class ConfigModule {} 14 | -------------------------------------------------------------------------------- /src/client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /src/client/src/components/UserList/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import UserList from '.'; 4 | import { initialState, } from '../../reducers/initialState'; 5 | 6 | it('renders without crashing', () => { 7 | const onDelete = (id: string) => {}; // tslint:disable-line no-empty 8 | shallow( 9 | 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /src/server/src/api/users/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsInt, IsOptional } from 'class-validator'; 2 | 3 | export class CreateUserDto { 4 | @IsString() readonly username: string; 5 | 6 | @IsString() readonly description: string; 7 | 8 | @IsString() 9 | @IsOptional() 10 | readonly firstName?: string; 11 | 12 | @IsString() 13 | @IsOptional() 14 | readonly lastName?: string; 15 | 16 | @IsString() 17 | @IsOptional() 18 | readonly age?: number; 19 | } 20 | -------------------------------------------------------------------------------- /src/server/src/api/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { UsersController } from './users.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User } from './users.entity'; 6 | 7 | @Module({ 8 | imports: [ 9 | TypeOrmModule.forFeature([User]), 10 | ], 11 | providers: [UsersService], 12 | controllers: [UsersController], 13 | }) 14 | export class UsersModule {} 15 | -------------------------------------------------------------------------------- /src/server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ConfigService } from './config/config.service'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule, { cors: true }); 7 | const configService: ConfigService = app.get(ConfigService); 8 | app.setGlobalPrefix(configService.get('URL_PREFIX')); 9 | await app.listen(configService.get('PORT')); 10 | } 11 | 12 | bootstrap(); 13 | -------------------------------------------------------------------------------- /src/client/src/validation/index.ts: -------------------------------------------------------------------------------- 1 | import * as Yup from 'yup'; 2 | 3 | export const userForm = Yup.object({ 4 | username: Yup 5 | .string() 6 | .required('username is required') 7 | .default(''), 8 | description: Yup 9 | .string() 10 | .required('description is required') 11 | .default(''), 12 | age: Yup 13 | .number() 14 | .default(0), 15 | firstName: Yup 16 | .string() 17 | .default(''), 18 | lastName: Yup 19 | .string() 20 | .default(''), 21 | }); 22 | -------------------------------------------------------------------------------- /src/client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | cypress/integration/examples 26 | cypress/videos 27 | 28 | production.env 29 | -------------------------------------------------------------------------------- /src/client/src/reducers/users.test.ts: -------------------------------------------------------------------------------- 1 | import { initialState } from '../reducers/initialState'; 2 | import reducer from './users'; 3 | import { fetchOneUserActions } from '../actions'; 4 | 5 | describe('users selectors', () => { 6 | it('select users', () => { 7 | const userId = '1'; 8 | 9 | const expectedState = { 10 | ...initialState.users, 11 | loading: true, 12 | }; 13 | 14 | expect(reducer(undefined, fetchOneUserActions.request(userId))).toEqual(expectedState); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { ApiModule } from './api/api.module'; 4 | import { ConfigModule } from './config/config.module'; 5 | import { ConfigService } from './config/config.service'; 6 | 7 | @Module({ 8 | imports: [ 9 | TypeOrmModule.forRootAsync({ 10 | imports: [ConfigModule], 11 | useExisting: ConfigService, 12 | }), 13 | ApiModule, 14 | ], 15 | }) 16 | export class AppModule {} 17 | -------------------------------------------------------------------------------- /src/server/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 150], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false 16 | }, 17 | "rulesDirectory": [] 18 | } 19 | -------------------------------------------------------------------------------- /src/client/src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { History } from 'history'; 2 | import { combineReducers, } from 'redux'; 3 | import { IRootState } from './initialState'; 4 | import router from './router'; 5 | import users from './users'; 6 | import { UsersActions } from './users'; 7 | import { RouterAction } from 'connected-react-router'; 8 | 9 | export type RootActions = UsersActions | RouterAction; 10 | 11 | export default (history: History) => combineReducers({ 12 | users, 13 | router: router(history), 14 | }); 15 | -------------------------------------------------------------------------------- /src/server/ormconfig.ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "postgres", 3 | "host": "localhost", 4 | "port": 5432, 5 | "username": "postgres", 6 | "password": "", 7 | "database": "postgres", 8 | "synchronize": false, 9 | "migrationsRun": true, 10 | "logging": false, 11 | "entities": [ 12 | "src/entity/**/*.ts" 13 | ], 14 | "migrations": [ 15 | "src/migration/**/*.ts" 16 | ], 17 | "subscribers": [ 18 | "src/subscriber/**/*.ts" 19 | ], 20 | "cli": { 21 | "migrationsDir": "src/migration" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/server/ormconfig.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "postgres", 3 | "host": "localhost", 4 | "port": 5432, 5 | "username": "demouser", 6 | "password": "qwerty", 7 | "database": "demo", 8 | "synchronize": false, 9 | "migrationsRun": true, 10 | "logging": false, 11 | "entities": [ 12 | "src/entity/**/*.ts" 13 | ], 14 | "migrations": [ 15 | "src/migration/**/*.ts" 16 | ], 17 | "subscribers": [ 18 | "src/subscriber/**/*.ts" 19 | ], 20 | "cli": { 21 | "migrationsDir": "src/migration" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | db: 5 | image: postgres 6 | restart: always 7 | environment: 8 | POSTGRES_DB: $POSTGRES_DB 9 | POSTGRES_USER: $POSTGRES_USER 10 | POSTGRES_PASSWORD: $POSTGRES_PASSWORD 11 | volumes: 12 | - database_data:/var/lib/postgresql/data 13 | ports: 14 | - $POSTGRES_PORT:$POSTGRES_PORT 15 | 16 | adminer: 17 | image: adminer 18 | restart: always 19 | ports: 20 | - 8080:8080 21 | 22 | volumes: 23 | database_data: 24 | driver: local -------------------------------------------------------------------------------- /src/client/src/pages/UserList/Page.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Page from './Page'; 4 | import { initialState, } from '../../reducers/initialState'; 5 | 6 | it('renders without crashing', () => { 7 | const onDelete = (id: string) => {}; // tslint:disable-line no-empty 8 | const fetchUsers = () => {}; // tslint:disable-line no-empty 9 | shallow( 10 | 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /src/client/src/pages/UserNew/Page.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Page from './Page'; 4 | import { initialState, IUser, ICreatedUser, } from '../../reducers/initialState'; 5 | import {history} from '../../store'; 6 | 7 | it('renders without crashing', () => { 8 | const onEntitySave = (payload: IUser|ICreatedUser) => {}; // tslint:disable-line no-empty 9 | shallow( 10 | 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /src/server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.16.0-bullseye as dist 2 | WORKDIR /tmp/ 3 | COPY package*.json tsconfig*.json *.env ormconfig*.json ./ 4 | COPY src/ src/ 5 | RUN npm install 6 | RUN npm run build 7 | 8 | FROM node:16.16.0-bullseye as node_modules 9 | WORKDIR /tmp/ 10 | COPY package.json package-lock.json ./ 11 | RUN npm install --production 12 | 13 | FROM node:16.16.0-bullseye 14 | WORKDIR /usr/local/nub-api 15 | COPY --from=node_modules /tmp/node_modules ./node_modules 16 | COPY --from=dist /tmp/dist ./dist 17 | COPY --from=dist /tmp/*.env /tmp/ormconfig*.json ./ 18 | EXPOSE 3001 19 | CMD ["node", "dist/main.js"] 20 | -------------------------------------------------------------------------------- /src/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/client/src/pages/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import { ThunkDispatch } from 'redux-thunk'; 2 | import { connect } from 'react-redux'; 3 | 4 | import Page from './Page'; 5 | import { IRootState } from '../../reducers/initialState'; 6 | import { RootActions } from '../../reducers'; 7 | 8 | const mapStateToProps = (state: IRootState) => { 9 | return { 10 | }; 11 | }; 12 | 13 | const mapDispatchToProps = (dispatch: ThunkDispatch) => { 14 | return { 15 | }; 16 | }; 17 | 18 | const Container = connect( 19 | mapStateToProps, 20 | mapDispatchToProps 21 | )(Page); 22 | 23 | export default Container; 24 | -------------------------------------------------------------------------------- /src/client/src/pages/NoMatch/index.tsx: -------------------------------------------------------------------------------- 1 | import { ThunkDispatch } from 'redux-thunk'; 2 | import { connect } from 'react-redux'; 3 | 4 | import Page from './Page'; 5 | import { IRootState } from '../../reducers/initialState'; 6 | import { RootActions } from '../../reducers'; 7 | 8 | const mapStateToProps = (state: IRootState) => { 9 | return { 10 | }; 11 | }; 12 | 13 | const mapDispatchToProps = (dispatch: ThunkDispatch) => { 14 | return { 15 | }; 16 | }; 17 | 18 | const Container = connect( 19 | mapStateToProps, 20 | mapDispatchToProps 21 | )(Page); 22 | 23 | export default Container; 24 | -------------------------------------------------------------------------------- /src/client/src/containers/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { ThunkDispatch } from 'redux-thunk'; 2 | import { connect } from 'react-redux'; 3 | 4 | import Page from '../../components/Header'; 5 | import { IRootState } from '../../reducers/initialState'; 6 | import { RootActions } from '../../reducers'; 7 | 8 | const mapStateToProps = (state: IRootState) => { 9 | return { 10 | users: state.users, 11 | }; 12 | }; 13 | 14 | const mapDispatchToProps = (dispatch: ThunkDispatch) => { 15 | return { 16 | }; 17 | }; 18 | 19 | const Container = connect( 20 | mapStateToProps, 21 | mapDispatchToProps 22 | )(Page); 23 | 24 | export default Container; 25 | -------------------------------------------------------------------------------- /src/server/src/api/users/users.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; 2 | 3 | @Entity() 4 | export class User { 5 | @PrimaryGeneratedColumn() id: number; 6 | 7 | @Column({ length: 100 }) 8 | username: string; 9 | 10 | @Column('text') description: string; 11 | 12 | @Column({nullable: true}) 13 | firstName: string; 14 | 15 | @Column({nullable: true}) 16 | lastName: string; 17 | 18 | @Column({nullable: true}) 19 | age: number; 20 | 21 | @CreateDateColumn({type: 'timestamp'}) 22 | createdAt: Date; 23 | 24 | @UpdateDateColumn({type: 'timestamp'}) 25 | updatedAt: Date; 26 | } 27 | -------------------------------------------------------------------------------- /src/client/src/components/UserForm/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import UserForm from '.'; 4 | import {history} from '../../store'; 5 | import { initialState, IUser, ICreatedUser } from '../../reducers/initialState'; 6 | 7 | it('renders without crashing', () => { 8 | const onEntitySave = (payload: IUser|ICreatedUser) => {}; // tslint:disable-line no-empty 9 | const onDelete = (id: string) => {}; // tslint:disable-line no-empty 10 | shallow( 11 | 18 | ); 19 | }); 20 | -------------------------------------------------------------------------------- /src/client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import Root from './components/Root'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | import configureStore, {history} from './store'; 8 | 9 | const {store} = configureStore(); 10 | 11 | ReactDOM.render( 12 | , 16 | document.getElementById('root') 17 | ); 18 | 19 | // If you want your app to work offline and load faster, you can change 20 | // unregister() to register() below. Note this comes with some pitfalls. 21 | // Learn more about service workers: http://bit.ly/CRA-PWA 22 | serviceWorker.unregister(); 23 | -------------------------------------------------------------------------------- /src/client/src/constants/users.ts: -------------------------------------------------------------------------------- 1 | export enum UsersActionTypes { 2 | FETCH_ONE_REQUEST = '@@users/FETCH_ONE_REQUEST', 3 | FETCH_ONE_SUCCESS = '@@users/FETCH_ONE_SUCCESS', 4 | FETCH_ONE_FAILURE = '@@users/FETCH_ONE_FAILURE', 5 | 6 | FETCH_ALL_REQUEST = '@@users/FETCH_ALL_REQUEST', 7 | FETCH_ALL_SUCCESS = '@@users/FETCH_ALL_SUCCESS', 8 | FETCH_ALL_FAILURE = '@@users/FETCH_ALL_FAILURE', 9 | 10 | SAVE_ONE_REQUEST = '@@users/SAVE_ONE_REQUEST', 11 | SAVE_ONE_SUCCESS = '@@users/SAVE_ONE_SUCCESS', 12 | SAVE_ONE_FAILURE = '@@users/SAVE_ONE_FAILURE', 13 | 14 | REMOVE_ONE_REQUEST = '@@users/REMOVE_ONE_REQUEST', 15 | REMOVE_ONE_SUCCESS = '@@users/REMOVE_ONE_SUCCESS', 16 | REMOVE_ONE_FAILURE = '@@users/REMOVE_ONE_FAILURE', 17 | } 18 | -------------------------------------------------------------------------------- /src/client/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /src/client/src/api/users.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { IUser, ICreatedUser } from '../reducers/initialState'; 3 | 4 | axios.defaults.baseURL = process.env.REACT_APP_API_BASE_URL; 5 | 6 | export const fetchAllUsersCall = () => axios({ 7 | method: 'get', 8 | url: '/users', 9 | }); 10 | 11 | export const fetchOneUserCall = (id: string) => axios({ 12 | method: 'get', 13 | url: `/users/${id}`, 14 | }); 15 | 16 | export const createUserCall = (user: IUser) => axios({ 17 | method: 'post', 18 | url: '/users', 19 | data: user, 20 | }); 21 | 22 | export const updateUserCall = (user: ICreatedUser) => axios({ 23 | method: 'put', 24 | url: `/users/${user.id}`, 25 | data: user, 26 | }); 27 | 28 | export const removeUserCall = (id: string) => axios({ 29 | method: 'delete', 30 | url: `/users/${id}`, 31 | }); 32 | -------------------------------------------------------------------------------- /src/client/src/pages/UserNew/index.tsx: -------------------------------------------------------------------------------- 1 | import { ThunkDispatch } from 'redux-thunk'; 2 | import { connect } from 'react-redux'; 3 | 4 | import Page from './Page'; 5 | import { IRootState, IUser, ICreatedUser } from '../../reducers/initialState'; 6 | import { saveUser } from '../../actions'; 7 | import { usersSelector } from '../../selectors'; 8 | import { RootActions } from '../../reducers'; 9 | 10 | const mapStateToProps = (state: IRootState) => { 11 | return { 12 | users: usersSelector(state), 13 | }; 14 | }; 15 | 16 | const mapDispatchToProps = (dispatch: ThunkDispatch) => { 17 | return { 18 | onEntitySave: (payload: IUser|ICreatedUser) => dispatch(saveUser(payload)), 19 | }; 20 | }; 21 | 22 | const Container = connect( 23 | mapStateToProps, 24 | mapDispatchToProps 25 | )(Page); 26 | 27 | export default Container; 28 | -------------------------------------------------------------------------------- /src/client/src/pages/UserList/index.tsx: -------------------------------------------------------------------------------- 1 | import { ThunkDispatch } from 'redux-thunk'; 2 | import { connect } from 'react-redux'; 3 | 4 | import Page from './Page'; 5 | import { IRootState } from '../../reducers/initialState'; 6 | import { fetchUsers, removeUser } from '../../actions'; 7 | import { usersSelector } from '../../selectors'; 8 | import { RootActions } from '../../reducers'; 9 | 10 | const mapStateToProps = (state: IRootState) => { 11 | return { 12 | users: usersSelector(state), 13 | }; 14 | }; 15 | 16 | const mapDispatchToProps = (dispatch: ThunkDispatch) => { 17 | return { 18 | fetchUsers: () => dispatch(fetchUsers()), 19 | onDelete: (id: string) => dispatch(removeUser(id)), 20 | }; 21 | }; 22 | 23 | const Container = connect( 24 | mapStateToProps, 25 | mapDispatchToProps 26 | )(Page); 27 | 28 | export default Container; 29 | -------------------------------------------------------------------------------- /src/server/deployment.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: semaphore-demo-javascript-server 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: semaphore-demo-javascript-server 10 | template: 11 | metadata: 12 | labels: 13 | app: semaphore-demo-javascript-server 14 | spec: 15 | containers: 16 | - name: semaphore-demo-javascript-server 17 | image: gcr.io/$GCP_PROJECT_ID/semaphore-demo-javascript-server:$SEMAPHORE_WORKFLOW_ID 18 | env: 19 | - name: NODE_ENV 20 | value: "production" 21 | 22 | --- 23 | 24 | apiVersion: v1 25 | kind: Service 26 | metadata: 27 | name: semaphore-demo-javascript-server-lb 28 | spec: 29 | selector: 30 | app: semaphore-demo-javascript-server 31 | type: LoadBalancer 32 | ports: 33 | - port: 80 34 | targetPort: 3001 35 | -------------------------------------------------------------------------------- /src/client/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended", "tslint-react"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single", "jsx-double"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 150], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false, 16 | "trailing-comma": [ 17 | true, 18 | { 19 | "multiline": { 20 | "arrays": true, 21 | "objects": true, 22 | "functions": false, 23 | "imports": true, 24 | "exports": true, 25 | "typeLiterals": true 26 | }, 27 | "singleline": "ignore" 28 | } 29 | ], 30 | "jsx-no-multiline-js": false 31 | }, 32 | "rulesDirectory": [] 33 | } 34 | -------------------------------------------------------------------------------- /src/client/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /.semaphore/secrets/server-production-env-secret.sample.yml: -------------------------------------------------------------------------------- 1 | # Copy this file into one without .sample part and then populate it with actual values. 2 | # Then you can create secret, by using command 3 | # `sem create -f path/to/this/file` 4 | # More info https://docs.semaphoreci.com/article/66-environment-variables-and-secrets 5 | apiVersion: v1beta 6 | kind: Secret 7 | metadata: 8 | # Use this name to create this secret manually 9 | name: server-production-env 10 | data: 11 | files: 12 | # Server production.env file doesn't exist by default, copy src/server/sample.env into src/server/production.env 13 | # and populate with production values 14 | # Then create secret - in the end it should be here - https://.semaphoreci.com/secrets 15 | - path: server-production.env 16 | # Could be created by 17 | # - `base64 -w 0 /path/to/file` and put in 18 | # - upload in https://.semaphoreci.com/secrets 19 | content: PASTE_BASE64_ENCODED_CONTENT_HERE 20 | -------------------------------------------------------------------------------- /src/client/src/pages/UserView/Page.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Page from './Page'; 4 | import { initialState, IUser, ICreatedUser, } from '../../reducers/initialState'; 5 | import {history} from '../../store'; 6 | import { routes } from '../../config'; 7 | 8 | it('renders without crashing', () => { 9 | const onEntitySave = (payload: IUser|ICreatedUser) => {}; // tslint:disable-line no-empty 10 | const onDelete = (id: string) => {}; // tslint:disable-line no-empty 11 | const fetchUsers = () => {}; // tslint:disable-line no-empty 12 | shallow( 13 | 27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /src/client/src/pages/NoMatch/Page.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { withStyles, Theme } from '@material-ui/core/styles'; 3 | import Paper from '@material-ui/core/Paper'; 4 | import { ClassNameMap } from '@material-ui/core/styles/withStyles'; 5 | 6 | const styles = (theme: Theme) => ({ 7 | root: { 8 | flexGrow: 1, 9 | ...theme.mixins.gutters(), 10 | paddingTop: theme.spacing.unit * 2, 11 | paddingBottom: theme.spacing.unit * 2, 12 | }, 13 | }); 14 | 15 | export interface IProps { 16 | classes: Partial>; 17 | } 18 | 19 | class Page extends Component { 20 | render() { 21 | const { classes, } = this.props; 22 | 23 | return ( 24 |
25 | 26 | 404 - Page Not Found 27 | 28 |
29 | ); 30 | } 31 | } 32 | 33 | export default withStyles(styles, { withTheme: true })(Page); 34 | -------------------------------------------------------------------------------- /src/server/src/common/validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | import { 3 | PipeTransform, 4 | Injectable, 5 | ArgumentMetadata, 6 | HttpStatus, 7 | } from '@nestjs/common'; 8 | import { validate } from 'class-validator'; 9 | import { plainToClass } from 'class-transformer'; 10 | 11 | @Injectable() 12 | export class ValidationPipe implements PipeTransform { 13 | async transform(value, metadata: ArgumentMetadata) { 14 | const { metatype } = metadata; 15 | if (!metatype || !this.toValidate(metatype)) { 16 | return value; 17 | } 18 | const object = plainToClass(metatype, value); 19 | const errors = await validate(object); 20 | if (errors.length > 0) { 21 | throw new BadRequestException('Validation failed'); 22 | } 23 | return value; 24 | } 25 | 26 | private toValidate(metatype): boolean { 27 | const types = [String, Boolean, Number, Array, Object]; 28 | return !types.find(type => metatype === type); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/client/src/pages/UserView/index.tsx: -------------------------------------------------------------------------------- 1 | import { ThunkDispatch } from 'redux-thunk'; 2 | import { connect } from 'react-redux'; 3 | 4 | import Page from './Page'; 5 | import { IRootState, IUser, ICreatedUser } from '../../reducers/initialState'; 6 | import { saveUser, removeUser, fetchOneUser } from '../../actions'; 7 | import { usersSelector } from '../../selectors'; 8 | import { RootActions } from '../../reducers'; 9 | 10 | const mapStateToProps = (state: IRootState) => { 11 | return { 12 | users: usersSelector(state), 13 | }; 14 | }; 15 | 16 | const mapDispatchToProps = (dispatch: ThunkDispatch) => { 17 | return { 18 | onEntitySave: (payload: IUser|ICreatedUser) => dispatch(saveUser(payload)), 19 | onDelete: (id: string) => dispatch(removeUser(id)), 20 | fetchUser: (id: string) => dispatch(fetchOneUser(id)), 21 | }; 22 | }; 23 | 24 | const Container = connect( 25 | mapStateToProps, 26 | mapDispatchToProps 27 | )(Page); 28 | 29 | export default Container; 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Rendered Text 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import createHistory from 'history/createBrowserHistory'; 2 | import { createStore, applyMiddleware, Store, } from 'redux'; 3 | import thunk from 'redux-thunk'; 4 | import { composeWithDevTools } from 'redux-devtools-extension'; 5 | 6 | import { routerMiddleware } from 'connected-react-router'; 7 | 8 | import rootReducer from '../reducers'; 9 | import { IRootState } from '../reducers/initialState'; 10 | 11 | export const history = createHistory({ 12 | basename: process.env.PUBLIC_URL, 13 | }); 14 | 15 | const configureStore = (): { store: Store } => { 16 | const middlewares = [ 17 | routerMiddleware(history), 18 | thunk, 19 | ]; 20 | 21 | const middlewareEnhancer = applyMiddleware(...middlewares); 22 | 23 | const enhancers = [ 24 | middlewareEnhancer, 25 | ]; 26 | const composedEnhancers = composeWithDevTools(...enhancers); 27 | 28 | const store = createStore( 29 | rootReducer(history), 30 | undefined, 31 | composedEnhancers 32 | ); 33 | 34 | return { store }; 35 | }; 36 | 37 | export default configureStore; 38 | -------------------------------------------------------------------------------- /src/client/src/reducers/initialState.ts: -------------------------------------------------------------------------------- 1 | import { RouterState } from 'connected-react-router'; 2 | 3 | export interface IUser { 4 | readonly username: string; 5 | readonly description: string; 6 | readonly firstName?: string; 7 | readonly lastName?: string; 8 | readonly age?: number; 9 | } 10 | 11 | export interface ICreatedUser extends IUser { 12 | readonly id: string; 13 | } 14 | 15 | export function isCreatedUser(object: any): object is ICreatedUser { 16 | return object.id !== undefined; 17 | } 18 | 19 | export interface IUsers { 20 | readonly [key: string]: ICreatedUser; 21 | } 22 | 23 | export type UserIds = string[]; 24 | 25 | export interface IUsersState { 26 | readonly itemIds: UserIds; 27 | readonly items: IUsers; 28 | readonly loading: boolean; 29 | readonly loaded: boolean; 30 | } 31 | 32 | export interface IRootState { 33 | readonly users: IUsersState; 34 | readonly router?: RouterState; 35 | } 36 | 37 | export const initialState: IRootState = { 38 | users: { 39 | itemIds: [], 40 | items: {}, 41 | loading: false, 42 | loaded: false, 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /src/client/src/pages/Home/Page.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { withStyles, Theme } from '@material-ui/core/styles'; 3 | import Paper from '@material-ui/core/Paper'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import { ClassNameMap } from '@material-ui/core/styles/withStyles'; 6 | 7 | const styles = (theme: Theme) => ({ 8 | root: { 9 | flexGrow: 1, 10 | ...theme.mixins.gutters(), 11 | paddingTop: theme.spacing.unit * 2, 12 | paddingBottom: theme.spacing.unit * 2, 13 | }, 14 | }); 15 | 16 | export interface IProps { 17 | classes: Partial>; 18 | } 19 | 20 | class Page extends Component { 21 | render() { 22 | const { classes, } = this.props; 23 | 24 | return ( 25 |
26 | 27 | 28 | Home 29 | 30 | 31 |
32 | ); 33 | } 34 | } 35 | 36 | export default withStyles(styles, { withTheme: true })(Page); 37 | -------------------------------------------------------------------------------- /.semaphore/secrets/server-ormconfig-production-secret.sample.yml: -------------------------------------------------------------------------------- 1 | # Copy this file into one without .sample part and then populate it with actual values. 2 | # Then you can create secret, by using command 3 | # `sem create -f path/to/this/file` 4 | # More info https://docs.semaphoreci.com/article/66-environment-variables-and-secrets 5 | apiVersion: v1beta 6 | kind: Secret 7 | metadata: 8 | # Use this name to create this secret manually 9 | name: server-ormconfig-production 10 | data: 11 | files: 12 | # Copy src/server/ormconfig.sample.json into ormconfig.production.json and populate with production values 13 | # You will need production connection settings in this config file. 14 | # In this eample heroku postgres database addon is used. 15 | # You will need to create heroku app and then add addon https://elements.heroku.com/addons/heroku-postgresql 16 | # Then you will be able to view database connection settings like described here https://devcenter.heroku.com/articles/heroku-postgresql#external-connections-ingress 17 | - path: ormconfig.production.json 18 | # Could be created by 19 | # - `base64 -w 0 /path/to/file` and put in 20 | # - upload in https://.semaphoreci.com/secrets 21 | content: PASTE_BASE64_ENCODED_CONTENT_HERE 22 | -------------------------------------------------------------------------------- /src/client/src/components/Root/index.tsx: -------------------------------------------------------------------------------- 1 | import { History } from 'history'; 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import { Route, Switch } from 'react-router-dom'; 5 | import { ConnectedRouter as Router } from 'connected-react-router'; 6 | import Header from '../../containers/Header'; 7 | import HomePage from '../../pages/Home'; 8 | import UserListPage from '../../pages/UserList'; 9 | import UserNewPage from '../../pages/UserNew'; 10 | import UserViewPage from '../../pages/UserView'; 11 | import NoMatchPage from '../../pages/NoMatch'; 12 | import { routes } from '../../config'; 13 | 14 | const Root = ({ store, history }: {store: any, history: History}) => ( 15 | 16 | 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 |
29 | ); 30 | 31 | export default Root; 32 | -------------------------------------------------------------------------------- /src/server/src/api/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Delete, 4 | Get, 5 | Param, 6 | Post, 7 | Put, 8 | Controller, 9 | UsePipes, 10 | } from '@nestjs/common'; 11 | import { UsersService } from './users.service'; 12 | import { ICreatedUser } from './user.interface'; 13 | import { ValidationPipe } from '../../common/validation.pipe'; 14 | import { CreateUserDto } from './create-user.dto'; 15 | 16 | @Controller('users') 17 | export class UsersController { 18 | constructor(private readonly usersService: UsersService) {} 19 | 20 | @Get() 21 | async findAll(): Promise { 22 | return this.usersService.findAll(); 23 | } 24 | 25 | @Get(':id') 26 | async findOne(@Param('id') id): Promise { 27 | return this.usersService.findOne(id); 28 | } 29 | 30 | @Delete(':id') 31 | async remove(@Param('id') id): Promise { 32 | return this.usersService.remove(id); 33 | } 34 | 35 | @Post() 36 | @UsePipes(new ValidationPipe()) 37 | async create(@Body() user: CreateUserDto): Promise { 38 | return this.usersService.create(user); 39 | } 40 | 41 | @Put(':id') 42 | @UsePipes(new ValidationPipe()) 43 | async update(@Param('id') id, @Body() user: CreateUserDto): Promise { 44 | return this.usersService.update(id, user); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/server/README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | App is built using [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | $ npm install 9 | ``` 10 | 11 | ## Configuration 12 | 13 | These values should work on development environment without changes 14 | 15 | ### Copy app config 16 | 17 | ```bash 18 | $ cp sample.env .env 19 | ``` 20 | 21 | ### Copy db config 22 | 23 | ```bash 24 | $ cp ormconfig.sample.json ormconfig.json 25 | ``` 26 | 27 | ### Run migrations 28 | 29 | ```bash 30 | $ npm run migrate:up 31 | ``` 32 | 33 | ## Running the app 34 | 35 | ```bash 36 | # development 37 | $ npm run start 38 | 39 | # watch mode 40 | $ npm run start:dev 41 | 42 | # production mode 43 | $ npm run start:prod 44 | ``` 45 | 46 | ## Lint 47 | 48 | Check app code style 49 | 50 | ```bash 51 | $ npm run lint 52 | ``` 53 | 54 | ## Test 55 | 56 | ```bash 57 | # unit tests 58 | $ npm run test 59 | 60 | # e2e tests 61 | $ npm run test:e2e 62 | 63 | # test coverage 64 | $ npm run test:cov 65 | ``` 66 | 67 | ## Migrations 68 | 69 | ```bash 70 | # up migrations 71 | $ npm run migrate:up 72 | 73 | # revert last migration 74 | $ npm run migrate:revert 75 | ``` 76 | 77 | ## License 78 | 79 | Copyright (c) 2019 Rendered Text 80 | 81 | Distributed under the MIT License. See the file [LICENSE.md](../../LICENSE.md) in repo root. -------------------------------------------------------------------------------- /src/client/src/pages/UserNew/Page.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { withStyles, Theme } from '@material-ui/core/styles'; 3 | import Paper from '@material-ui/core/Paper'; 4 | import { ClassNameMap } from '@material-ui/core/styles/withStyles'; 5 | import UserForm from '../../components/UserForm'; 6 | import { IUsersState, IUser, ICreatedUser } from '../../reducers/initialState'; 7 | import { History } from 'history'; 8 | 9 | const styles = (theme: Theme) => ({ 10 | root: { 11 | flexGrow: 1, 12 | ...theme.mixins.gutters(), 13 | paddingTop: theme.spacing.unit * 2, 14 | paddingBottom: theme.spacing.unit * 2, 15 | }, 16 | }); 17 | 18 | export interface IProps { 19 | classes: Partial>; 20 | users: IUsersState; 21 | history: History; 22 | onEntitySave: (payload: IUser|ICreatedUser) => any; 23 | } 24 | 25 | class Page extends Component { 26 | render() { 27 | const { classes, users, history, } = this.props; 28 | 29 | return ( 30 |
31 | 32 | 38 | 39 |
40 | ); 41 | } 42 | } 43 | 44 | export default withStyles(styles, { withTheme: true })(Page); 45 | -------------------------------------------------------------------------------- /src/client/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | const wp = require('@cypress/webpack-preprocessor'); 14 | const fs = require('fs-extra') 15 | const path = require('path') 16 | 17 | function getConfigurationByFile (file) { 18 | const pathToConfigFile = path.resolve(__dirname, '..', 'config', `${file}.json`) 19 | 20 | return fs.readJson(pathToConfigFile) 21 | } 22 | 23 | module.exports = (on, config) => { 24 | const options = { 25 | webpackOptions: { 26 | resolve: { 27 | extensions: [".ts", ".tsx", ".js"] 28 | }, 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.tsx?$/, 33 | loader: "ts-loader", 34 | options: { transpileOnly: true } 35 | } 36 | ] 37 | } 38 | }, 39 | } 40 | on('file:preprocessor', wp(options)); 41 | 42 | const file = config.env.configFile || 'development' 43 | 44 | return getConfigurationByFile(file); 45 | } 46 | -------------------------------------------------------------------------------- /.semaphore/client-deploy.yml: -------------------------------------------------------------------------------- 1 | # This pipeline runs after semaphore.yml 2 | version: v1.0 3 | name: Client deploy 4 | agent: 5 | machine: 6 | # Use a machine type with more RAM and CPU power for faster container 7 | # builds: 8 | type: e1-standard-2 9 | os_image: ubuntu2004 10 | blocks: 11 | - name: Build 12 | task: 13 | # Set environment variables that your project requires. 14 | # See https://docs.semaphoreci.com/article/66-environment-variables-and-secrets 15 | env_vars: 16 | - name: BUCKET_NAME 17 | value: YOUR_APP_URL 18 | # For info on creating secrets, see: 19 | # https://docs.semaphoreci.com/article/66-environment-variables-and-secrets 20 | secrets: 21 | - name: gcr-secret 22 | jobs: 23 | - name: Deploy to Google Cloud Storage 24 | commands: 25 | # Authenticate using the file injected from the secret 26 | - gcloud auth activate-service-account --key-file=.secrets.gcp.json 27 | - gcloud config set project $GCP_PROJECT_ID 28 | - gcloud config set compute/zone $GCP_PROJECT_DEFAULT_ZONE 29 | 30 | # Restore build from cache. 31 | - cache restore client-build-$SEMAPHORE_WORKFLOW_ID 32 | 33 | # Deploy to Google Cloud Storage 34 | - gsutil -m rsync -r build gs://$BUCKET_NAME 35 | - gsutil iam ch allUsers:objectViewer gs://$BUCKET_NAME 36 | - gsutil web set -m index.html -e 404.html gs://$BUCKET_NAME 37 | -------------------------------------------------------------------------------- /src/server/src/api/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { User } from './users.entity'; 5 | import { CreateUserDto } from './create-user.dto'; 6 | 7 | @Injectable() 8 | export class UsersService { 9 | constructor( 10 | @InjectRepository(User) 11 | private readonly usersRepository: Repository, 12 | ) {} 13 | 14 | async findAll(): Promise { 15 | return await this.usersRepository.find(); 16 | } 17 | 18 | async findOne(id: number): Promise { 19 | return await this.usersRepository.findOne({id}); 20 | } 21 | 22 | async remove(id: number): Promise { 23 | await this.usersRepository.delete(id); 24 | return; 25 | } 26 | 27 | async create(user: CreateUserDto): Promise { 28 | const item = new User(); 29 | item.username = user.username; 30 | item.description = user.description; 31 | item.age = user.age; 32 | item.firstName = user.firstName; 33 | item.lastName = user.lastName; 34 | return await this.usersRepository.save(item); 35 | } 36 | 37 | async update(id: number, user: CreateUserDto): Promise { 38 | const item = await this.usersRepository.findOne({id}); 39 | item.username = user.username; 40 | item.description = user.description; 41 | item.age = user.age; 42 | item.firstName = user.firstName; 43 | item.lastName = user.lastName; 44 | return await this.usersRepository.save(item); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/server/src/migration/1549545103672-UsersTable.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner, Table} from 'typeorm'; 2 | 3 | export class UsersTable1549545103672 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.createTable(new Table({ 6 | name: 'user', 7 | columns: [ 8 | { 9 | name: 'id', 10 | type: 'int', 11 | isPrimary: true, 12 | isGenerated: true, 13 | }, 14 | { 15 | name: 'username', 16 | type: 'varchar', 17 | length: '100', 18 | }, 19 | { 20 | name: 'description', 21 | type: 'text', 22 | }, 23 | { 24 | name: 'age', 25 | type: 'int', 26 | isNullable: true, 27 | }, 28 | { 29 | name: 'firstName', 30 | type: 'varchar', 31 | length: '100', 32 | isNullable: true, 33 | }, 34 | { 35 | name: 'lastName', 36 | type: 'varchar', 37 | length: '100', 38 | isNullable: true, 39 | }, 40 | { 41 | name: 'createdAt', 42 | type: 'timestamp', 43 | default: 'now()', 44 | }, 45 | { 46 | name: 'updatedAt', 47 | type: 'timestamp', 48 | default: 'now()', 49 | }, 50 | ], 51 | }), true); 52 | } 53 | 54 | public async down(queryRunner: QueryRunner): Promise { 55 | await queryRunner.dropTable('user'); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.semaphore/secrets/gcr-secret.sample.yml: -------------------------------------------------------------------------------- 1 | # Copy this file into one without .sample part and then populate it with actual values. 2 | # Then you can create secret, by using command 3 | # `sem create -f path/to/this/file` 4 | # More info https://docs.semaphoreci.com/article/66-environment-variables-and-secrets 5 | apiVersion: v1alpha 6 | kind: Secret 7 | metadata: 8 | # Use this name to create this secret manually 9 | name: gcr-secret 10 | data: 11 | # If you haven't set up k8s on google you need to do it first 12 | # Start with https://cloud.google.com/kubernetes-engine/docs/quickstart 13 | # Play with it and then you can use it with this project. 14 | # Check out https://docs.semaphoreci.com/article/72-google-container-registry-gcr as an example. 15 | # Also check out https://docs.semaphoreci.com/article/119-ci-cd-for-microservices-on-kubernetes 16 | # as example of k8s deploy. 17 | env_vars: 18 | # Id of your project 19 | # More info here https://cloud.google.com/resource-manager/docs/creating-managing-projects?visit_id=636878590586351739-3388570778&rd=1#identifying_projects 20 | - name: GCP_PROJECT_ID 21 | value: "your-gcp-project-id" 22 | # Default compute zone you've selected 23 | # https://cloud.google.com/compute/docs/regions-zones/#available 24 | - name: GCP_PROJECT_DEFAULT_ZONE 25 | value: "europe-west1-b" 26 | files: 27 | # You need to create service account and export json key file for it here https://console.cloud.google.com/iam-admin/serviceaccounts 28 | # to use in this file. 29 | - path: .secrets.gcp.json 30 | # Could be created by 31 | # - `base64 -w 0 /path/to/file` and put in 32 | # - upload in https://.semaphoreci.com/secrets 33 | content: PASTE_BASE64_ENCODED_CONTENT_HERE 34 | -------------------------------------------------------------------------------- /src/client/src/pages/UserList/Page.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { withStyles, Theme } from '@material-ui/core/styles'; 3 | import Paper from '@material-ui/core/Paper'; 4 | import { Link } from 'react-router-dom'; 5 | import Fab from '@material-ui/core/Fab'; 6 | import AddIcon from '@material-ui/icons/Add'; 7 | import { ClassNameMap } from '@material-ui/core/styles/withStyles'; 8 | import UserList from '../../components/UserList'; 9 | import { IUsersState } from '../../reducers/initialState'; 10 | import { routes } from '../../config'; 11 | 12 | const styles = (theme: Theme) => ({ 13 | root: { 14 | flexGrow: 1, 15 | ...theme.mixins.gutters(), 16 | paddingTop: theme.spacing.unit * 2, 17 | paddingBottom: theme.spacing.unit * 2, 18 | }, 19 | fab: { 20 | position: 'fixed' as 'fixed', 21 | bottom: theme.spacing.unit * 2, 22 | right: theme.spacing.unit * 2, 23 | }, 24 | }); 25 | 26 | export interface IProps { 27 | classes: Partial>; 28 | users: IUsersState; 29 | fetchUsers(): any; 30 | onDelete: (id: string) => any; 31 | } 32 | 33 | class Page extends Component { 34 | async componentDidMount() { 35 | if (!this.props.users.loaded) { 36 | await this.props.fetchUsers(); 37 | } 38 | } 39 | 40 | render() { 41 | const { classes, users, } = this.props; 42 | 43 | return ( 44 |
45 | 46 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 | ); 58 | } 59 | } 60 | 61 | export default withStyles(styles, { withTheme: true })(Page); 62 | -------------------------------------------------------------------------------- /src/client/src/pages/UserView/Page.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { withStyles, Theme } from '@material-ui/core/styles'; 3 | import Paper from '@material-ui/core/Paper'; 4 | import { ClassNameMap } from '@material-ui/core/styles/withStyles'; 5 | import UserForm from '../../components/UserForm'; 6 | import { IUsersState, ICreatedUser, IUser } from '../../reducers/initialState'; 7 | import { History } from 'history'; 8 | import { RouteComponentProps } from 'react-router-dom'; 9 | 10 | const styles = (theme: Theme) => ({ 11 | root: { 12 | flexGrow: 1, 13 | ...theme.mixins.gutters(), 14 | paddingTop: theme.spacing.unit * 2, 15 | paddingBottom: theme.spacing.unit * 2, 16 | }, 17 | }); 18 | 19 | export interface IProps extends RouteComponentProps , React.Props { 20 | classes: Partial>; 21 | users: IUsersState; 22 | history: History; 23 | onEntitySave: (payload: IUser|ICreatedUser) => any; 24 | onDelete: (id: string) => any; 25 | fetchUser: (id: string) => any; 26 | } 27 | 28 | class Page extends Component { 29 | async componentDidMount() { 30 | const user = this.props.users.items[this.props.match.params.id]; 31 | if (!user && !this.props.users.loading) { 32 | await this.props.fetchUser(this.props.match.params.id); 33 | } 34 | } 35 | 36 | render() { 37 | const { classes, users, onEntitySave, onDelete, history, match } = this.props; 38 | 39 | return ( 40 |
41 | 42 | 49 | 50 |
51 | ); 52 | } 53 | } 54 | 55 | export default withStyles(styles, { withTheme: true })(Page); 56 | -------------------------------------------------------------------------------- /src/client/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Single Page Apps for GitHub Pages 6 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/client/src/components/UserForm/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, SyntheticEvent } from 'react'; 2 | import { Formik, FormikActions, FormikConfig, FormikProps } from 'formik'; 3 | import Form from './Form'; 4 | import {userForm} from '../../validation'; 5 | import { routes } from '../../config'; 6 | import { IUsersState, ICreatedUser, IUser } from '../../reducers/initialState'; 7 | import { ConnectedRouterProps } from 'connected-react-router'; 8 | 9 | export interface IProps extends ConnectedRouterProps { 10 | onEntitySave: (payload: IUser|ICreatedUser) => void; 11 | onDelete?: (id: string) => void; 12 | users: IUsersState; 13 | id?: string; 14 | } 15 | 16 | class UserForm extends Component { 17 | handleSubmit = async (values: ICreatedUser, actions: FormikActions) => { 18 | try { 19 | await this.props.onEntitySave(values); 20 | actions.setSubmitting(false); 21 | 22 | this.props.history.push(routes.users.list); 23 | } catch (error) { 24 | actions.setSubmitting(false); 25 | actions.setErrors(error); 26 | actions.setStatus({ msg: 'Set some arbitrary status or data' }); 27 | } 28 | } 29 | 30 | handleDelete = (event: SyntheticEvent) => { 31 | event.preventDefault(); 32 | if (this.props.onDelete && this.props.id) { 33 | this.props.onDelete(this.props.id); 34 | } 35 | this.props.history.push(routes.users.list); 36 | } 37 | 38 | handleGoBack = () => this.props.history.goBack(); 39 | 40 | handleFormikRender = (props: FormikProps) => ( 41 |
47 | ) 48 | 49 | render() { 50 | const item = this.props.users.items[`${this.props.id}`] || {}; 51 | 52 | const values: IUser|ICreatedUser = { 53 | id: item.id, 54 | username: !item.username 55 | ? '' 56 | : item.username, 57 | description: !item.description 58 | ? '' 59 | : item.description, 60 | age: !item.age 61 | ? 0 62 | : item.age, 63 | firstName: !item.firstName 64 | ? '' 65 | : item.firstName, 66 | lastName: !item.lastName 67 | ? '' 68 | : item.lastName, 69 | }; 70 | 71 | return ( 72 | 79 | ); 80 | } 81 | } 82 | 83 | export default UserForm; 84 | -------------------------------------------------------------------------------- /src/client/src/components/UserList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, SyntheticEvent } from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | import { withStyles, Theme } from '@material-ui/core/styles'; 4 | import List from '@material-ui/core/List'; 5 | import ListItem from '@material-ui/core/ListItem'; 6 | import ListItemText from '@material-ui/core/ListItemText'; 7 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; 8 | import { routes } from '../../config'; 9 | import { IUsersState } from '../../reducers/initialState'; 10 | import { ClassNameMap } from '@material-ui/core/styles/withStyles'; 11 | import IconButton from '@material-ui/core/IconButton'; 12 | import DeleteIcon from '@material-ui/icons/Delete'; 13 | 14 | const styles = (theme: Theme) => ({ 15 | sideListItem: { 16 | color: 'inherit', 17 | textDecoration: 'none', 18 | }, 19 | }); 20 | 21 | export interface IProps { 22 | users: IUsersState; 23 | classes: Partial>; 24 | onDelete: (id: string) => void; 25 | } 26 | 27 | class UserList extends Component { 28 | handleDelete = (id: string) => (event: SyntheticEvent) => { 29 | event.preventDefault(); 30 | this.props.onDelete(id); 31 | } 32 | 33 | render() { 34 | const { classes, users } = this.props; 35 | 36 | return ( 37 | 38 | 39 | { 40 | users.itemIds.map((id, index) => { 41 | const item = users.items[id]; 42 | return ( 43 | 48 | 52 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | }) 65 | } 66 | 67 | 68 | ); 69 | } 70 | } 71 | 72 | export default withStyles(styles, { withTheme: true })(UserList); 73 | -------------------------------------------------------------------------------- /src/server/src/config/config.service.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import * as dotenv from 'dotenv'; 3 | import * as fs from 'fs'; 4 | import { DatabaseType } from 'typeorm'; 5 | 6 | export interface EnvConfig { 7 | [key: string]: string; 8 | } 9 | 10 | export class ConfigService { 11 | private readonly envConfig: EnvConfig; 12 | 13 | constructor(filePath: string) { 14 | const config = dotenv.parse(fs.readFileSync(filePath)); 15 | this.envConfig = this.validateInput(config); 16 | } 17 | 18 | /** 19 | * Ensures all needed variables are set, and returns the validated JavaScript object 20 | * including the applied default values. 21 | */ 22 | private validateInput(envConfig: EnvConfig): EnvConfig { 23 | const envVarsSchema: Joi.ObjectSchema = Joi.object({ 24 | NODE_ENV: Joi 25 | .string() 26 | .valid(['development', 'production', 'test', 'provision', 'ci']) 27 | .default('development'), 28 | PORT: Joi 29 | .number() 30 | .default(3001), 31 | URL_PREFIX: Joi 32 | .string() 33 | .default('v1/api'), 34 | DATABASE_TYPE: Joi 35 | .string() 36 | .valid(['postgres']) 37 | .default('postgres'), 38 | DATABASE_HOST: Joi 39 | .string() 40 | .default('localhost'), 41 | DATABASE_PORT: Joi 42 | .number() 43 | .default(5432), 44 | DATABASE_USER: Joi 45 | .string() 46 | .default('postgres'), 47 | DATABASE_PASSWORD: Joi 48 | .string() 49 | .allow('') 50 | .allow(null), 51 | DATABASE_DBNAME: Joi 52 | .string() 53 | .default('postgres'), 54 | }); 55 | 56 | const { error, value: validatedEnvConfig } = Joi.validate( 57 | envConfig, 58 | envVarsSchema, 59 | ); 60 | if (error) { 61 | throw new Error(`Config validation error: ${error.message}`); 62 | } 63 | return validatedEnvConfig; 64 | } 65 | 66 | get(key: string): string { 67 | return this.envConfig[key]; 68 | } 69 | 70 | createTypeOrmOptions() { 71 | return { 72 | type: 'postgres' as 'postgres', 73 | host: this.get('DATABASE_HOST'), 74 | port: parseInt(this.get('DATABASE_PORT'), 10), 75 | username: this.get('DATABASE_USER'), 76 | password: this.get('DATABASE_PASSWORD'), 77 | database: this.get('DATABASE_DBNAME'), 78 | entities: [__dirname + '/../**/**/*.entity{.ts,.js}'], 79 | synchronize: false, 80 | migrationsRun: true, 81 | migrations: [__dirname + '/../migration/*{.ts,.js}'], 82 | cli: { 83 | migrationsDir: 'migration', 84 | }, 85 | extra: { 86 | ssl: this.get('NODE_ENV') === 'production' 87 | ? true 88 | : false, 89 | }, 90 | }; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from './../src/app.module'; 4 | import { INestApplication } from '@nestjs/common'; 5 | import { UsersService } from '../src/api/users/users.service'; 6 | import { ConfigService } from '../src/config/config.service'; 7 | 8 | describe('AppController (e2e)', () => { 9 | let prefix = ''; 10 | const user = { 11 | id: 1, 12 | username: 'test', 13 | description: 'test', 14 | age: null, 15 | firstName: null, 16 | lastName: null, 17 | createdAt: null, 18 | updatedAt: null, 19 | }; 20 | const result = { 21 | findAll: [ 22 | {...user}, 23 | ], 24 | findOne: {...user}, 25 | remove: '', 26 | create: {...user}, 27 | update: {...user}, 28 | }; 29 | let app: INestApplication; 30 | const usersService = { 31 | findAll: () => result.findAll, 32 | findOne: (id) => result.findOne, 33 | remove: (id) => result.remove, 34 | create: (item) => result.create, 35 | update: (id, item) => result.update, 36 | }; 37 | 38 | beforeAll(async () => { 39 | const moduleFixture = await Test 40 | .createTestingModule({ 41 | imports: [ 42 | AppModule, 43 | ], 44 | }) 45 | .overrideProvider(UsersService) 46 | .useValue(usersService) 47 | .compile(); 48 | 49 | app = moduleFixture.createNestApplication(); 50 | const configService: ConfigService = app.get(ConfigService); 51 | prefix = configService.get('URL_PREFIX'); 52 | app.setGlobalPrefix(prefix); 53 | await app.init(); 54 | }); 55 | 56 | it(`/${prefix}/users (GET)`, () => { 57 | return request(app.getHttpServer()) 58 | .get(`/${prefix}/users`) 59 | .expect(200) 60 | .expect(JSON.stringify(result.findAll)); 61 | }); 62 | 63 | it(`/${prefix}/users/${user.id} (DELETE)`, () => { 64 | return request(app.getHttpServer()) 65 | .delete(`/${prefix}/users/${user.id}`) 66 | .expect(200) 67 | .expect(result.remove); 68 | }); 69 | 70 | it(`/${prefix}/users/${user.id} (GET)`, () => { 71 | return request(app.getHttpServer()) 72 | .get(`/${prefix}/users/${user.id}`) 73 | .expect(200) 74 | .expect(JSON.stringify(result.findOne)); 75 | }); 76 | 77 | it(`/${prefix}/users/${user.id} (PUT)`, () => { 78 | return request(app.getHttpServer()) 79 | .put(`/${prefix}/users/${user.id}`) 80 | .send(user) 81 | .expect(200) 82 | .expect(JSON.stringify(result.update)); 83 | }); 84 | 85 | it(`/${prefix}/users (POST)`, () => { 86 | return request(app.getHttpServer()) 87 | .post(`/${prefix}/users`) 88 | .send(user) 89 | .expect(201) 90 | .expect(JSON.stringify(result.create)); 91 | }); 92 | 93 | afterAll(async () => { 94 | await app.close(); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@semaphoreci/semaphore-demo-javascript-server", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "prebuild": "rimraf dist", 7 | "build": "tsc -p tsconfig.build.json", 8 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 9 | "start": "ts-node -r tsconfig-paths/register src/main.ts", 10 | "start:dev": "nodemon", 11 | "start:debug": "nodemon --config nodemon-debug.json", 12 | "prestart:prod": "npm run build", 13 | "start:prod": "node dist/main.js", 14 | "lint": "tslint -p tsconfig.json -c tslint.json", 15 | "test": "jest --reporters=default --reporters=jest-junit", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage", 18 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand --reporters=default --reporters=jest-junit", 19 | "test:e2e": "jest --reporters=default --reporters=jest-junit --config ./test/jest-e2e.json", 20 | "migrate:up": "ts-node ./node_modules/typeorm/cli.js migration:run", 21 | "migrate:revert": "ts-node ./node_modules/typeorm/cli.js migration:revert" 22 | }, 23 | "dependencies": { 24 | "@nestjs/common": "^5.4.0", 25 | "@nestjs/core": "^5.4.0", 26 | "@nestjs/microservices": "^5.4.0", 27 | "@nestjs/testing": "^5.4.0", 28 | "@nestjs/typeorm": "^5.2.2", 29 | "@nestjs/websockets": "^5.4.0", 30 | "class-transformer": "^0.3.1", 31 | "class-validator": "^0.9.1", 32 | "dotenv": "^6.2.0", 33 | "joi": "^14.3.1", 34 | "pg": "^8.7.3", 35 | "reflect-metadata": "^0.1.12", 36 | "rimraf": "^2.6.3", 37 | "rxjs": "^6.3.3", 38 | "typeorm": "^0.2.25" 39 | }, 40 | "devDependencies": { 41 | "@types/express": "^4.16.0", 42 | "@types/jest": "^23.3.13", 43 | "@types/joi": "^14.3.2", 44 | "@types/node": "^10.12.18", 45 | "@types/supertest": "^2.0.7", 46 | "jest": "^23.6.0", 47 | "jest-junit": "12.0.0", 48 | "nodemon": "^1.18.9", 49 | "prettier": "^1.15.3", 50 | "supertest": "^3.4.1", 51 | "ts-jest": "^23.10.5", 52 | "ts-node": "^7.0.1", 53 | "tsconfig-paths": "^3.7.0", 54 | "tslint": "5.12.1", 55 | "typescript": "^3.2.4" 56 | }, 57 | "jest": { 58 | "moduleFileExtensions": [ 59 | "js", 60 | "json", 61 | "ts" 62 | ], 63 | "rootDir": "src", 64 | "testRegex": ".spec.ts$", 65 | "transform": { 66 | "^.+\\.(t|j)s$": "ts-jest" 67 | }, 68 | "coverageDirectory": "../coverage", 69 | "testEnvironment": "node" 70 | }, 71 | "jest-junit": { 72 | "suiteName": "jest tests", 73 | "outputDirectory": ".", 74 | "outputName": "junit.xml", 75 | "uniqueOutputName": "false", 76 | "classNameTemplate": "{classname}-{title}", 77 | "titleTemplate": "{classname}-{title}", 78 | "ancestorSeparator": " › ", 79 | "usePathForSuiteName": "true" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/client/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ### `npm run cypress:open` 41 | 42 | Opens cypress dashboard to manage e2e tests 43 | 44 | ### `npm run start:ci` 45 | 46 | Start app con port 3030. Shortcut for ci e2e step 47 | 48 | ### `npm run cypress:run` 49 | 50 | **Note: you need to start client app to use this command** 51 | 52 | Runs e2e tests. 53 | 54 | ### `npm run test:e2e` 55 | 56 | Runs e2e tests. It also starts client app on default port `3030` before running tests using `start-server-and-test` package 57 | 58 | ### `npm run lint` 59 | 60 | Check app code stype 61 | 62 | ## Learn More 63 | 64 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 65 | 66 | To learn React, check out the [React documentation](https://reactjs.org/). 67 | 68 | ## License 69 | 70 | Copyright (c) 2019 Rendered Text 71 | 72 | Distributed under the MIT License. See the file [LICENSE.md](../../LICENSE.md) in repo root. 73 | -------------------------------------------------------------------------------- /src/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@semaphoreci/semaphore-demo-javascript", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "dependencies": { 6 | "@material-ui/core": "^3.9.2", 7 | "@material-ui/icons": "^3.0.2", 8 | "@types/enzyme": "^3.1.17", 9 | "@types/enzyme-adapter-react-16": "^1.0.3", 10 | "@types/history": "^4.7.2", 11 | "@types/jest": "24.0.0", 12 | "@types/node": "10.12.21", 13 | "@types/react": "16.8.2", 14 | "@types/react-dom": "16.8.0", 15 | "@types/react-redux": "^7.0.1", 16 | "@types/react-router": "^4.4.3", 17 | "@types/react-router-dom": "^4.3.1", 18 | "@types/redux-actions": "^2.3.1", 19 | "@types/yup": "^0.26.8", 20 | "axios": "^0.21.2", 21 | "connected-react-router": "^6.2.2", 22 | "enzyme": "^3.8.0", 23 | "enzyme-adapter-react-16": "^1.9.1", 24 | "formik": "^1.4.3", 25 | "gh-pages": "^2.0.1", 26 | "history": "^4.7.2", 27 | "react": "^16.8.1", 28 | "react-dom": "^16.8.1", 29 | "react-redux": "^6.0.0", 30 | "react-router": "^4.3.1", 31 | "react-router-dom": "^4.3.1", 32 | "react-scripts": "2.1.3", 33 | "react-test-renderer": "^16.8.1", 34 | "redux": "^4.0.1", 35 | "redux-devtools-extension": "^2.13.8", 36 | "redux-thunk": "^2.3.0", 37 | "reselect": "^4.0.0", 38 | "typesafe-actions": "^3.0.0", 39 | "yup": "^0.26.10" 40 | }, 41 | "scripts": { 42 | "start": "react-scripts start", 43 | "start:ci": "PORT=3030 react-scripts start", 44 | "build": "react-scripts build", 45 | "test": "react-scripts test --reporters=default --reporters=jest-junit", 46 | "eject": "react-scripts eject", 47 | "lint": "tslint -p tsconfig.json -c tslint.json", 48 | "cypress:open": "cypress open", 49 | "cypress:run": "cypress run --reporter junit --reporter-options \"mochaFile=junit.xml,toConsole=true\"", 50 | "test:e2e": "start-server-and-test start:ci http://localhost:3030 cypress:run", 51 | "deploy": "gh-pages -d build" 52 | }, 53 | "eslintConfig": { 54 | "extends": "react-app" 55 | }, 56 | "browserslist": [ 57 | ">0.2%", 58 | "not dead", 59 | "not ie <= 11", 60 | "not op_mini all" 61 | ], 62 | "devDependencies": { 63 | "@cypress/webpack-preprocessor": "^4.0.3", 64 | "@types/redux-mock-store": "^1.0.0", 65 | "cypress": "^3.1.5", 66 | "jest-junit": "12.0.0", 67 | "redux-mock-store": "^1.5.3", 68 | "start-server-and-test": "^1.7.11", 69 | "ts-loader": "^5.3.3", 70 | "tslint": "^5.12.1", 71 | "tslint-react": "^3.6.0", 72 | "typescript": "^3.3.3", 73 | "webpack": "^4.19.1" 74 | }, 75 | "jest-junit": { 76 | "suiteName": "jest tests", 77 | "outputDirectory": ".", 78 | "outputName": "junit.xml", 79 | "uniqueOutputName": "false", 80 | "classNameTemplate": "{classname}-{title}", 81 | "titleTemplate": "{classname}-{title}", 82 | "ancestorSeparator": " › ", 83 | "usePathForSuiteName": "true" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Semaphore demo CI/CD pipeline using JavaScript (Node.js, TypeScript, Nest.js, React) 2 | 3 | Example application and CI/CD pipeline showing how to run a JavaScript project 4 | on Semaphore 2.0. Project consists of a Node.js server based on Nest.js, and a 5 | React client. Code is written in TypeScript. 6 | 7 | ## CI/CD on Semaphore 8 | 9 | Fork this repository and use it to [create a 10 | project](https://docs.semaphoreci.com/article/63-your-first-project). 11 | 12 | The CI pipeline will look like this: 13 | 14 | ![CI pipeline on Semaphore](images/ci-pipeline-client.png) 15 | 16 | ![CI pipeline on Semaphore](images/ci-pipeline-server.png) 17 | 18 | The example pipeline contains 4 blocks: 19 | 20 | - Install Dependencies 21 | - installs and caches all npm dependencies 22 | - Run Lint 23 | - Runs tslint to check project files codestyle 24 | - Run Unit Tests 25 | - Runs Unit Tests 26 | - Run E2E Tests 27 | - Runs E2E tests through cypress on client. 28 | - Runs E2E tests through jest on server. 29 | 30 | Then, if all checks are ok, we move to build pipeline. It consists of one block 31 | 32 | - Build 33 | - Build client- build client app using create-react-app sctipts 34 | - Build server - build container and push it into google repository 35 | 36 | Then, after we've built our apps we move to deploy pipeline. 37 | It also consists of one block for client and of two for server. 38 | As you can see deploy pipelines of client and server depend only on their own build step 39 | and therefore could be run in parallel. 40 | 41 | - Deploy 42 | - Deploy client - commit and push build into Google Cloud Storage bucket 43 | - Deploy server 44 | - Deploy server to k8s, pdate k8s deployment using deployment config 45 | - Tag container if all went well 46 | 47 | ## Local project setup 48 | 49 | This project requires a PostgreSQL database. If you don't have one you can 50 | launch a Docker container to have one. 51 | 52 | ### Configuration 53 | 54 | ```bash 55 | $ cp .sample.env .env 56 | ``` 57 | 58 | ### Launch db 59 | 60 | ```bash 61 | $ docker-compose up 62 | ``` 63 | 64 | ### Stop db 65 | 66 | ```bash 67 | $ docker-compose down 68 | ``` 69 | 70 | ### Configure and launch server 71 | 72 | Steps described in server [README](src/server/README.md) 73 | 74 | ### Configure and launch client 75 | 76 | Steps described in client [README](src/client/README.md) 77 | 78 | ## Deploy configuration 79 | 80 | Check out `.semaphore/` folder - steps described there have helpful comments to help you figure out what commands are doing. 81 | Also check out `.semaphore/secrets` folder. To configure deploy you need to create and populate all those secrets. 82 | Copy each secret file into file without `.sample` in filename and populate it. All of them have useful description comments to help you out. 83 | 84 | ## License 85 | 86 | Copyright (c) 2019 Rendered Text 87 | 88 | Distributed under the MIT License. See the file [LICENSE.md](./LICENSE.md). 89 | -------------------------------------------------------------------------------- /.semaphore/client-deploy-build.yml: -------------------------------------------------------------------------------- 1 | # Use the latest stable version of Semaphore 2.0 YML syntax: 2 | version: v1.0 3 | 4 | # Name your pipeline. In case you connect multiple pipelines with promotions, 5 | # the name will help you differentiate between, for example, a CI build phase 6 | # and delivery phases. 7 | name: Semaphore JavaScript Example Pipeline 8 | 9 | # An agent defines the environment in which your code runs. 10 | # It is a combination of one of available machine types and operating 11 | # system images. 12 | # See https://docs.semaphoreci.com/article/20-machine-types 13 | # and https://docs.semaphoreci.com/article/32-ubuntu-1804-image 14 | agent: 15 | machine: 16 | type: e1-standard-2 17 | os_image: ubuntu2004 18 | 19 | # Blocks are the heart of a pipeline and are executed sequentially. 20 | # Each block has a task that defines one or more jobs. Jobs define the 21 | # commands to execute. 22 | # See https://docs.semaphoreci.com/article/62-concepts 23 | blocks: 24 | - name: Install dependencies 25 | task: 26 | # Set environment variables that your project requires. 27 | # See https://docs.semaphoreci.com/article/66-environment-variables-and-secrets 28 | env_vars: 29 | - name: NODE_ENV 30 | value: production 31 | - name: CI 32 | value: 'true' 33 | - name: REACT_APP_API_BASE_URL 34 | value: "http://YOUR_CLUSTER_URL/v1/api" 35 | # This block runs two jobs in parallel and they both share common 36 | # setup steps. We can group them in a prologue. 37 | # See https://docs.semaphoreci.com/article/50-pipeline-yaml#prologue 38 | prologue: 39 | commands: 40 | # Get the latest version of our source code from GitHub: 41 | - checkout 42 | 43 | # Use the version of Node.js specified in .nvmrc. 44 | # Semaphore provides nvm preinstalled. 45 | - nvm use 46 | - node --version 47 | - npm --version 48 | jobs: 49 | # First parallel job: 50 | - name: client npm install and cache 51 | commands: 52 | - cd src/client 53 | 54 | # Restore dependencies from cache. 55 | # For more info on caching, see https://docs.semaphoreci.com/article/68-caching-dependencies 56 | - cache restore client-node-modules-$SEMAPHORE_GIT_BRANCH-$(checksum package-lock.json),client-node-modules-$SEMAPHORE_GIT_BRANCH,client-node-modules-master 57 | - npm run build 58 | 59 | # Store the latest version of client build in cache to reuse in further blocks: 60 | - cache store client-build-$SEMAPHORE_WORKFLOW_ID build 61 | 62 | # The deployment pipeline is defined to run on manual approval from the UI. 63 | # Semaphore will the time and the name of the person who promotes each 64 | # deployment. 65 | # 66 | # You could, for example, add another promotion to a pipeline that 67 | # automatically deploys to a staging environment from branches named 68 | # after a certain pattern. 69 | # https://docs.semaphoreci.com/article/50-pipeline-yaml#promotions 70 | promotions: 71 | - name: Deploy Client 72 | pipeline_file: client-deploy.yml 73 | -------------------------------------------------------------------------------- /.semaphore/server-docker-build.yml: -------------------------------------------------------------------------------- 1 | # This pipeline runs after semaphore.yml 2 | version: v1.0 3 | name: Docker build server 4 | agent: 5 | machine: 6 | # Use a machine type with more RAM and CPU power for faster container 7 | # builds: 8 | type: e1-standard-4 9 | os_image: ubuntu2004 10 | blocks: 11 | - name: Build 12 | task: 13 | # Mount a secret which defines DOCKER_USERNAME and DOCKER_PASSWORD 14 | # environment variables. 15 | # For info on creating secrets, see: 16 | # https://docs.semaphoreci.com/article/66-environment-variables-and-secrets 17 | secrets: 18 | - name: gcr-secret 19 | - name: server-ormconfig-production 20 | - name: server-production-env 21 | prologue: 22 | commands: 23 | # Lets configure gcloud to push docker image into container registry 24 | # Authenticate using the file injected from the secret 25 | - gcloud auth activate-service-account --key-file=.secrets.gcp.json 26 | # Don't forget -q to silence confirmation prompts 27 | - gcloud auth configure-docker -q 28 | - gcloud config set project $GCP_PROJECT_ID 29 | - gcloud config set compute/zone $GCP_PROJECT_DEFAULT_ZONE 30 | - checkout 31 | jobs: 32 | - name: Docker build 33 | commands: 34 | - cd src/server 35 | 36 | # Copy production configs we linked using secrets 37 | - cp /home/semaphore/ormconfig.production.json ormconfig.json 38 | - cp /home/semaphore/server-production.env production.env 39 | 40 | # Use docker layer caching and reuse unchanged layers to build a new 41 | # container image faster. 42 | # To do that, we first need to pull a previous version of container: 43 | - docker pull "gcr.io/$GCP_PROJECT_ID/semaphore-demo-javascript-server:latest" || true 44 | 45 | # Build a new image based on pulled image, if present. 46 | # Use $SEMAPHORE_WORKFLOW_ID environment variable to produce a 47 | # unique image tag. 48 | # For a list of available environment variables on Semaphore, see: 49 | # https://docs.semaphoreci.com/article/12-environment-variables 50 | 51 | # Replace with your GCP Project ID 52 | - docker build --cache-from "gcr.io/$GCP_PROJECT_ID/semaphore-demo-javascript-server:latest" -t "gcr.io/$GCP_PROJECT_ID/semaphore-demo-javascript-server:$SEMAPHORE_WORKFLOW_ID" . 53 | - docker images 54 | 55 | # Push a new image to container registry: 56 | - docker push "gcr.io/$GCP_PROJECT_ID/semaphore-demo-javascript-server:$SEMAPHORE_WORKFLOW_ID" 57 | 58 | # The deployment pipeline is defined to run on manual approval from the UI. 59 | # Semaphore will the time and the name of the person who promotes each 60 | # deployment. 61 | # 62 | # You could, for example, add another promotion to a pipeline that 63 | # automatically deploys to a staging environment from branches named 64 | # after a certain pattern. 65 | # https://docs.semaphoreci.com/article/50-pipeline-yaml#promotions 66 | promotions: 67 | - name: Deploy server to Kubernetes 68 | pipeline_file: server-deploy-k8s.yml 69 | -------------------------------------------------------------------------------- /src/client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | React App 26 | 27 | 28 | 57 | 58 | 59 | 60 | 61 | 62 |
63 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /.semaphore/server-deploy-k8s.yml: -------------------------------------------------------------------------------- 1 | # This pipeline runs after docker-build.yml 2 | version: v1.0 3 | name: Deploy server to Kubernetes 4 | agent: 5 | machine: 6 | type: e1-standard-2 7 | os_image: ubuntu2004 8 | blocks: 9 | - name: Deploy server to Kubernetes 10 | task: 11 | # For info on creating secrets, see: 12 | # https://docs.semaphoreci.com/article/66-environment-variables-and-secrets 13 | secrets: 14 | - name: gcr-secret 15 | 16 | # Set environment variables that your project requires. 17 | # See https://docs.semaphoreci.com/article/66-environment-variables-and-secrets 18 | env_vars: 19 | - name: CLUSTER_NAME 20 | value: semaphore-demo-javascript-server 21 | prologue: 22 | commands: 23 | # Authenticate using the file injected from the secret 24 | - gcloud auth activate-service-account --key-file=.secrets.gcp.json 25 | # Don't forget -q to silence confirmation prompts 26 | - gcloud auth configure-docker -q 27 | - gcloud config set project $GCP_PROJECT_ID 28 | - gcloud config set compute/zone $GCP_PROJECT_DEFAULT_ZONE 29 | # Get kubectl config file 30 | - gcloud container clusters get-credentials $CLUSTER_NAME --zone $GCP_PROJECT_DEFAULT_ZONE --project $GCP_PROJECT_ID 31 | - checkout 32 | - cd src/server 33 | jobs: 34 | - name: Deploy 35 | commands: 36 | - kubectl get nodes 37 | - kubectl get pods 38 | 39 | # Our deployment.yml instructs Kubernetes to pull container image 40 | # named semaphoredemos/semaphore-demo-javascript-server:$SEMAPHORE_WORKFLOW_ID 41 | # 42 | # envsubst is a tool which will replace $SEMAPHORE_WORKFLOW_ID with 43 | # its current value. The same variable was used in server-docker-build.yml 44 | # pipeline to tag and push a container image. 45 | - envsubst < deployment.yml | tee helloWorld 46 | - mv helloWorld deployment.yml 47 | 48 | # Perform declarative deployment: 49 | - kubectl apply -f deployment.yml 50 | 51 | # If deployment to production succeeded, let's create a new version of 52 | # our `latest` Docker image. 53 | - name: Tag latest release 54 | task: 55 | secrets: 56 | - name: gcr-secret 57 | prologue: 58 | commands: 59 | # Authenticate using the file injected from the secret 60 | - gcloud auth activate-service-account --key-file=.secrets.gcp.json 61 | # Don't forget -q to silence confirmation prompts 62 | - gcloud auth configure-docker -q 63 | - gcloud config set project $GCP_PROJECT_ID 64 | - gcloud config set compute/zone $GCP_PROJECT_DEFAULT_ZONE 65 | - checkout 66 | - cd src/server 67 | jobs: 68 | - name: docker tag latest 69 | commands: 70 | - docker pull "gcr.io/$GCP_PROJECT_ID/semaphore-demo-javascript-server:$SEMAPHORE_WORKFLOW_ID" 71 | - docker tag "gcr.io/$GCP_PROJECT_ID/semaphore-demo-javascript-server:$SEMAPHORE_WORKFLOW_ID" "gcr.io/$GCP_PROJECT_ID/semaphore-demo-javascript-server:latest" 72 | - docker push "gcr.io/$GCP_PROJECT_ID/semaphore-demo-javascript-server:latest" 73 | -------------------------------------------------------------------------------- /src/client/src/actions/users.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import configureMockStore, { MockStore } from 'redux-mock-store'; 3 | import thunk from 'redux-thunk'; 4 | import { fetchOneUser, fetchUsers, saveUser, removeUser } from './users'; 5 | import { initialState, IRootState } from '../reducers/initialState'; 6 | import { UsersActionTypes } from '../constants'; 7 | import { AnyAction } from 'redux'; 8 | 9 | const middlewares = [thunk]; 10 | const mockStore = configureMockStore(middlewares); 11 | 12 | jest.mock('axios'); 13 | 14 | describe('users actions', () => { 15 | it('fetch one user', () => { 16 | const userId = '1'; 17 | const user = { 18 | id: 1, 19 | username: 'test', 20 | description: 'test', 21 | }; 22 | const resp = {data: {...user}}; 23 | (axios as any).mockResolvedValue(resp); 24 | 25 | const expectedActions = [ 26 | { type: UsersActionTypes.FETCH_ONE_REQUEST, payload: userId }, 27 | { type: UsersActionTypes.FETCH_ONE_SUCCESS, payload: user } 28 | ]; 29 | const store: MockStore = mockStore(initialState); 30 | return fetchOneUser(userId)(store.dispatch).then(() => { 31 | expect(store.getActions()).toEqual(expectedActions); 32 | }); 33 | }); 34 | 35 | it('fetch all users', () => { 36 | const userId = '1'; 37 | const user = { 38 | id: 1, 39 | username: 'test', 40 | description: 'test', 41 | }; 42 | const resp = {data: [{...user}]}; 43 | (axios as any).mockResolvedValue(resp); 44 | 45 | const expectedActions = [ 46 | { type: UsersActionTypes.FETCH_ALL_REQUEST }, 47 | { type: UsersActionTypes.FETCH_ALL_SUCCESS, payload: [user] } 48 | ]; 49 | const store: MockStore = mockStore(initialState); 50 | return fetchUsers()(store.dispatch).then(() => { 51 | expect(store.getActions()).toEqual(expectedActions); 52 | }); 53 | }); 54 | 55 | it('remove user', () => { 56 | const userId = '1'; 57 | const user = { 58 | id: userId, 59 | username: 'test', 60 | description: 'test', 61 | }; 62 | const resp = {data: {...user}}; 63 | (axios as any).mockResolvedValue(resp); 64 | 65 | const expectedActions = [ 66 | { type: UsersActionTypes.REMOVE_ONE_REQUEST, payload: userId }, 67 | { type: UsersActionTypes.REMOVE_ONE_SUCCESS, payload: userId } 68 | ]; 69 | const store: MockStore = mockStore(initialState); 70 | return removeUser(userId)(store.dispatch).then(() => { 71 | expect(store.getActions()).toEqual(expectedActions); 72 | }); 73 | }); 74 | 75 | it('save user', () => { 76 | const userId = '1'; 77 | const userData = { 78 | username: 'test', 79 | description: 'test', 80 | }; 81 | const user = { 82 | ...userData, 83 | id: userId, 84 | }; 85 | const resp = {data: {...user}}; 86 | (axios as any).mockResolvedValue(resp); 87 | 88 | const expectedActions = [ 89 | { type: UsersActionTypes.SAVE_ONE_REQUEST, payload: userData }, 90 | { type: UsersActionTypes.SAVE_ONE_SUCCESS, payload: user } 91 | ]; 92 | const store: MockStore = mockStore(initialState); 93 | return saveUser(userData)(store.dispatch).then(() => { 94 | expect(store.getActions()).toEqual(expectedActions); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/client/src/reducers/users.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, getType } from 'typesafe-actions'; 2 | import { initialState, IUsersState, IUsers } from './initialState'; 3 | import * as users from '../actions/users'; 4 | 5 | export type UsersActions = ActionType; 6 | 7 | export default (state: IUsersState = initialState.users, action: UsersActions) => { 8 | switch (action.type) { 9 | case getType(users.fetchAllUsersActions.request): { 10 | return { 11 | ...state, 12 | loading: true, 13 | loaded: false, 14 | }; 15 | } 16 | case getType(users.fetchAllUsersActions.success): { 17 | const items: IUsers = action.payload 18 | .reduce((result, current) => ({...result, [current.id]: current}), {}); 19 | const itemIds: string[] = Object.keys(items) 20 | .filter((id: string) => items[id]); 21 | return { 22 | ...state, 23 | itemIds, 24 | items, 25 | loading: false, 26 | loaded: true, 27 | }; 28 | } 29 | case getType(users.fetchAllUsersActions.failure): { 30 | return { 31 | ...state, 32 | loading: false, 33 | }; 34 | } 35 | case getType(users.fetchOneUserActions.request): { 36 | return { 37 | ...state, 38 | loading: true, 39 | }; 40 | } 41 | case getType(users.fetchOneUserActions.success): { 42 | const items: IUsers = { 43 | ...state.items, 44 | [action.payload.id]: action.payload, 45 | }; 46 | const itemIds = Object.keys(items) 47 | .filter((id: string) => items[id]); 48 | return { 49 | ...state, 50 | itemIds, 51 | items, 52 | loading: false, 53 | }; 54 | } 55 | case getType(users.fetchOneUserActions.failure): { 56 | return { 57 | ...state, 58 | loading: false, 59 | }; 60 | } 61 | case getType(users.saveUserActions.request): { 62 | return { 63 | ...state, 64 | loading: true, 65 | }; 66 | } 67 | case getType(users.saveUserActions.success): { 68 | const items: IUsers = { 69 | ...state.items, 70 | [action.payload.id]: action.payload, 71 | }; 72 | 73 | const itemIds = Object.keys(items) 74 | .filter((id: string) => items[id]); 75 | 76 | return { 77 | ...state, 78 | itemIds, 79 | items, 80 | loading: false, 81 | }; 82 | } 83 | case getType(users.saveUserActions.failure): { 84 | return { 85 | ...state, 86 | loading: false, 87 | }; 88 | } 89 | case getType(users.removeUserActions.request): { 90 | return { 91 | ...state, 92 | loading: true, 93 | }; 94 | } 95 | case getType(users.removeUserActions.success): { 96 | const {[action.payload]: value, ...items } = state.items; 97 | 98 | const itemIds = Object.keys(items) 99 | .filter((id: string) => items[id]); 100 | 101 | return { 102 | ...state, 103 | itemIds, 104 | items, 105 | loading: false, 106 | }; 107 | } 108 | case getType(users.removeUserActions.failure): { 109 | return { 110 | ...state, 111 | loading: false, 112 | }; 113 | } 114 | default: 115 | return state; 116 | } 117 | }; 118 | -------------------------------------------------------------------------------- /src/server/src/api/users/users.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { UsersController } from './users.controller'; 3 | import { UsersService } from './users.service'; 4 | 5 | const user = { 6 | id: 1, 7 | username: 'test', 8 | description: 'test', 9 | age: null, 10 | firstName: null, 11 | lastName: null, 12 | createdAt: null, 13 | updatedAt: null, 14 | }; 15 | const serviceResult = { 16 | findAll: [ 17 | {...user}, 18 | ], 19 | findOne: {...user}, 20 | remove: null, 21 | create: {...user}, 22 | update: {...user}, 23 | }; 24 | const MockUsersService = { 25 | async findAll() { 26 | return serviceResult.findAll; 27 | }, 28 | async findOne(id) { 29 | return serviceResult.findOne; 30 | }, 31 | async remove(id) { 32 | return serviceResult.remove; 33 | }, 34 | async create(item) { 35 | return serviceResult.create; 36 | }, 37 | async update(item) { 38 | return serviceResult.create; 39 | }, 40 | }; 41 | 42 | describe('UsersController', () => { 43 | let usersController: UsersController; 44 | let usersService: UsersService; 45 | 46 | beforeAll(async () => { 47 | const app = await Test 48 | .createTestingModule({ 49 | controllers: [UsersController], 50 | providers: [{ provide: UsersService, useValue: MockUsersService }], 51 | }) 52 | .compile(); 53 | 54 | usersService = app.get(UsersService); 55 | usersController = app.get(UsersController); 56 | }); 57 | 58 | describe('findAll', () => { 59 | it('should return users list', async () => { 60 | const expectedResult = serviceResult.findAll; 61 | jest.spyOn(usersService, 'findAll').mockImplementation(async () => expectedResult); 62 | 63 | const result = await usersController.findAll(); 64 | expect(result).toBe(expectedResult); 65 | }); 66 | }); 67 | 68 | describe('findOne', () => { 69 | it('should return users list', async () => { 70 | const expectedResult = serviceResult.findOne; 71 | jest.spyOn(usersService, 'findOne').mockImplementation(async () => expectedResult); 72 | 73 | const result = await usersController.findOne(user.id); 74 | expect(result).toBe(expectedResult); 75 | }); 76 | }); 77 | 78 | describe('remove', () => { 79 | it('should delete user and return emoty response', async () => { 80 | const expectedResult = serviceResult.remove; 81 | jest.spyOn(usersService, 'remove').mockImplementation(async () => expectedResult); 82 | 83 | const result = await usersController.remove(user.id); 84 | expect(result).toBe(expectedResult); 85 | }); 86 | }); 87 | 88 | describe('create', () => { 89 | it('should create user and return', async () => { 90 | const expectedResult = serviceResult.create; 91 | jest.spyOn(usersService, 'create').mockImplementation(async () => expectedResult); 92 | 93 | const result = await usersController.create(user); 94 | expect(result).toBe(expectedResult); 95 | }); 96 | }); 97 | 98 | describe('update', () => { 99 | it('should return updated user', async () => { 100 | const expectedResult = serviceResult.update; 101 | jest.spyOn(usersService, 'update').mockImplementation(async () => expectedResult); 102 | 103 | const result = await usersController.update(user.id, user); 104 | expect(result).toBe(expectedResult); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/client/src/actions/users.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncAction, ActionType } from 'typesafe-actions'; 2 | import { ThunkDispatch } from 'redux-thunk'; 3 | 4 | import { 5 | fetchAllUsersCall, 6 | updateUserCall, 7 | createUserCall, 8 | removeUserCall, 9 | fetchOneUserCall, 10 | } from '../api'; 11 | import { IRootState, ICreatedUser, IUser, isCreatedUser } from '../reducers/initialState'; 12 | import { AxiosError, AxiosResponse } from 'axios'; 13 | import { 14 | UsersActionTypes, 15 | } from '../constants/users'; 16 | 17 | export const fetchOneUserActions = createAsyncAction( 18 | UsersActionTypes.FETCH_ONE_REQUEST, 19 | UsersActionTypes.FETCH_ONE_SUCCESS, 20 | UsersActionTypes.FETCH_ONE_FAILURE, 21 | )(); 22 | 23 | export const fetchOneUser = ( 24 | id: string, 25 | ) => ( 26 | dispatch: ThunkDispatch>, 27 | ) => { 28 | dispatch(fetchOneUserActions.request(id)); 29 | return fetchOneUserCall(id) 30 | .then((result: AxiosResponse) => { 31 | return dispatch(fetchOneUserActions.success(result.data)); 32 | }) 33 | .catch((error: AxiosError) => { 34 | return dispatch(fetchOneUserActions.failure(error)); 35 | }); 36 | }; 37 | 38 | export const fetchAllUsersActions = createAsyncAction( 39 | UsersActionTypes.FETCH_ALL_REQUEST, 40 | UsersActionTypes.FETCH_ALL_SUCCESS, 41 | UsersActionTypes.FETCH_ALL_FAILURE, 42 | )(); 43 | 44 | export const fetchUsers = () => ( 45 | dispatch: ThunkDispatch>, 46 | ) => { 47 | dispatch(fetchAllUsersActions.request()); 48 | 49 | return fetchAllUsersCall() 50 | .then((result: AxiosResponse) => { 51 | return dispatch(fetchAllUsersActions.success(result.data)); 52 | }) 53 | .catch((error: AxiosError) => { 54 | return dispatch(fetchAllUsersActions.failure(error)); 55 | }); 56 | }; 57 | 58 | export const saveUserActions = createAsyncAction( 59 | UsersActionTypes.SAVE_ONE_REQUEST, 60 | UsersActionTypes.SAVE_ONE_SUCCESS, 61 | UsersActionTypes.SAVE_ONE_FAILURE, 62 | )(); 63 | 64 | export const saveUser = ( 65 | user: IUser|ICreatedUser, 66 | ) => ( 67 | dispatch: ThunkDispatch>, 68 | ) => { 69 | dispatch(saveUserActions.request(user)); 70 | 71 | let apiCall; 72 | if (isCreatedUser(user)) { 73 | apiCall = updateUserCall(user); 74 | } else { 75 | apiCall = createUserCall(user); 76 | } 77 | 78 | return apiCall 79 | .then((result: AxiosResponse) => { 80 | return dispatch(saveUserActions.success(result.data)); 81 | }) 82 | .catch((error: AxiosError) => { 83 | return dispatch(saveUserActions.failure(error)); 84 | }); 85 | }; 86 | 87 | export const removeUserActions = createAsyncAction( 88 | UsersActionTypes.REMOVE_ONE_REQUEST, 89 | UsersActionTypes.REMOVE_ONE_SUCCESS, 90 | UsersActionTypes.REMOVE_ONE_FAILURE, 91 | )(); 92 | 93 | export const removeUser = ( 94 | id: string, 95 | ) => ( 96 | dispatch: ThunkDispatch>, 97 | ) => { 98 | dispatch(removeUserActions.request(id)); 99 | 100 | return removeUserCall(id) 101 | .then((result: AxiosResponse) => { 102 | return dispatch(removeUserActions.success(id)); 103 | }) 104 | .catch((error: AxiosError) => { 105 | return dispatch(removeUserActions.failure(error)); 106 | }); 107 | }; 108 | -------------------------------------------------------------------------------- /src/client/src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | import { withStyles, Theme } from '@material-ui/core/styles'; 4 | import AppBar from '@material-ui/core/AppBar'; 5 | import Toolbar from '@material-ui/core/Toolbar'; 6 | import Typography from '@material-ui/core/Typography'; 7 | import IconButton from '@material-ui/core/IconButton'; 8 | import MenuIcon from '@material-ui/icons/Menu'; 9 | import AddIcon from '@material-ui/icons/Add'; 10 | import ListIcon from '@material-ui/icons/List'; 11 | import Drawer from '@material-ui/core/Drawer'; 12 | import List from '@material-ui/core/List'; 13 | import ListItem from '@material-ui/core/ListItem'; 14 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 15 | import ListItemText from '@material-ui/core/ListItemText'; 16 | import LinearProgress from '@material-ui/core/LinearProgress'; 17 | import { routes } from '../../config'; 18 | import { IUsersState } from '../../reducers/initialState'; 19 | import { ClassNameMap } from '@material-ui/core/styles/withStyles'; 20 | 21 | const styles = (theme: Theme) => ({ 22 | brand: { 23 | color: 'inherit', 24 | textDecoration: 'none', 25 | display: 'flex', 26 | alignItems: 'inherit', 27 | justifyContent: 'inherit', 28 | }, 29 | sideListItem: { 30 | color: 'inherit', 31 | textDecoration: 'none', 32 | }, 33 | grow: { 34 | flexGrow: 1, 35 | }, 36 | menuButton: { 37 | marginLeft: -12, 38 | marginRight: 20, 39 | }, 40 | list: { 41 | width: 250, 42 | }, 43 | textField: { 44 | marginLeft: theme.spacing.unit, 45 | marginRight: theme.spacing.unit, 46 | width: 400, 47 | }, 48 | fab: { 49 | position: 'absolute' as 'absolute', 50 | bottom: `${theme.spacing.unit * 2}`, 51 | right: `${theme.spacing.unit * 2}`, 52 | }, 53 | container: { 54 | marginBottom: 48, 55 | }, 56 | progress: { 57 | position: 'relative' as 'relative', 58 | top: '49', 59 | }, 60 | }); 61 | 62 | export interface IProps { 63 | users: IUsersState; 64 | classes: Partial>; 65 | } 66 | 67 | export interface IState { 68 | drawerOpen: boolean; 69 | } 70 | 71 | class Header extends Component { 72 | constructor(props: IProps) { 73 | super(props); 74 | 75 | this.state = { 76 | drawerOpen: false, 77 | }; 78 | } 79 | 80 | toggleDrawer = (open: boolean) => () => { 81 | this.setState({ 82 | drawerOpen: open, 83 | }); 84 | } 85 | 86 | render() { 87 | const { classes, users } = this.props; 88 | 89 | const sideList = ( 90 |
91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 |
106 | ); 107 | 108 | return ( 109 |
110 | 113 | 114 | 120 | 121 | 122 | 123 | 124 | Demo 125 | 126 | 127 | 128 | 129 | {(users.loading) && } 130 | 131 |
137 | {sideList} 138 |
139 |
140 |
141 | ); 142 | } 143 | } 144 | 145 | export default withStyles(styles, { withTheme: true })(Header); 146 | -------------------------------------------------------------------------------- /src/client/src/components/UserForm/Form.tsx: -------------------------------------------------------------------------------- 1 | import React, { SyntheticEvent } from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import { withStyles, Theme } from '@material-ui/core/styles'; 4 | import TextField from '@material-ui/core/TextField'; 5 | import FormControl from '@material-ui/core/FormControl'; 6 | import { IUser, } from '../../reducers/initialState'; 7 | import { FormikProps, } from 'formik'; 8 | import { ClassNameMap } from '@material-ui/core/styles/withStyles'; 9 | 10 | const styles = (theme: Theme) => ({ 11 | formControlControls: { 12 | display: 'flex', 13 | flexDirection: 'row' as 'row', 14 | justifyContent: 'space-between', 15 | }, 16 | textField: { 17 | marginLeft: theme.spacing.unit, 18 | marginRight: theme.spacing.unit, 19 | }, 20 | button: { 21 | margin: theme.spacing.unit, 22 | }, 23 | }); 24 | 25 | export interface IProps { 26 | classes: Partial>; 27 | onDelete?: (event: SyntheticEvent) => any; 28 | onGoBack: () => any; 29 | id?: string; 30 | } 31 | 32 | class Form extends React.Component & IProps> { 33 | handleChange = (name: keyof IUser) => (e: React.SyntheticEvent) => { 34 | const { 35 | handleChange, 36 | setFieldTouched 37 | } = this.props; 38 | 39 | e.persist(); 40 | 41 | handleChange(e); 42 | setFieldTouched(name, true, false); 43 | } 44 | 45 | render() { 46 | const { 47 | classes, 48 | values: { 49 | username, 50 | description, 51 | age, 52 | firstName, 53 | lastName, 54 | }, 55 | errors, 56 | touched, 57 | isValid, 58 | handleSubmit, 59 | } = this.props; 60 | 61 | return ( 62 | 68 | 80 |
81 | 93 |
94 | 106 |
107 | 119 |
120 | 132 | 133 | 140 | {this.props.id && } 148 | 157 | 158 | 159 | ); 160 | } 161 | } 162 | 163 | export default withStyles(styles, { withTheme: true })(Form); 164 | -------------------------------------------------------------------------------- /src/client/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | interface Config { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | } 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | (process as { env: { [key: string]: string } }).env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | // tslint:disable-next-line no-console 53 | console.log( 54 | 'This web app is being served cache-first by a service ' + 55 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 56 | ); 57 | }); 58 | } else { 59 | // Is not localhost. Just register service worker 60 | registerValidSW(swUrl, config); 61 | } 62 | }); 63 | } 64 | } 65 | 66 | function registerValidSW(swUrl: string, config?: Config) { 67 | navigator.serviceWorker 68 | .register(swUrl) 69 | .then(registration => { 70 | registration.onupdatefound = () => { 71 | const installingWorker = registration.installing; 72 | if (installingWorker == null) { 73 | return; 74 | } 75 | installingWorker.onstatechange = () => { 76 | if (installingWorker.state === 'installed') { 77 | if (navigator.serviceWorker.controller) { 78 | // At this point, the updated precached content has been fetched, 79 | // but the previous service worker will still serve the older 80 | // content until all client tabs are closed. 81 | // tslint:disable-next-line no-console 82 | console.log( 83 | 'New content is available and will be used when all ' + 84 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 85 | ); 86 | 87 | // Execute callback 88 | if (config && config.onUpdate) { 89 | config.onUpdate(registration); 90 | } 91 | } else { 92 | // At this point, everything has been precached. 93 | // It's the perfect time to display a 94 | // "Content is cached for offline use." message. 95 | // tslint:disable-next-line no-console 96 | console.log('Content is cached for offline use.'); 97 | 98 | // Execute callback 99 | if (config && config.onSuccess) { 100 | config.onSuccess(registration); 101 | } 102 | } 103 | } 104 | }; 105 | }; 106 | }) 107 | .catch(error => { 108 | // tslint:disable-next-line no-console 109 | console.error('Error during service worker registration:', error); 110 | }); 111 | } 112 | 113 | function checkValidServiceWorker(swUrl: string, config?: Config) { 114 | // Check if the service worker can be found. If it can't reload the page. 115 | fetch(swUrl) 116 | .then(response => { 117 | // Ensure service worker exists, and that we really are getting a JS file. 118 | const contentType = response.headers.get('content-type'); 119 | if ( 120 | response.status === 404 || 121 | (contentType != null && contentType.indexOf('javascript') === -1) 122 | ) { 123 | // No service worker found. Probably a different app. Reload the page. 124 | navigator.serviceWorker.ready.then(registration => { 125 | registration.unregister().then(() => { 126 | window.location.reload(); 127 | }); 128 | }); 129 | } else { 130 | // Service worker found. Proceed as normal. 131 | registerValidSW(swUrl, config); 132 | } 133 | }) 134 | .catch(() => { 135 | // tslint:disable-next-line no-console 136 | console.log( 137 | 'No internet connection found. App is running in offline mode.' 138 | ); 139 | }); 140 | } 141 | 142 | export function unregister() { 143 | if ('serviceWorker' in navigator) { 144 | navigator.serviceWorker.ready.then(registration => { 145 | registration.unregister(); 146 | }); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /.semaphore/semaphore.yml: -------------------------------------------------------------------------------- 1 | # Use the latest stable version of Semaphore 2.0 YML syntax: 2 | version: v1.0 3 | 4 | # Name your pipeline. In case you connect multiple pipelines with promotions, 5 | # the name will help you differentiate between, for example, a CI build phase 6 | # and delivery phases. 7 | name: Semaphore JavaScript Example Pipeline 8 | 9 | # An agent defines the environment in which your code runs. 10 | # It is a combination of one of available machine types and operating 11 | # system images. 12 | # See https://docs.semaphoreci.com/article/20-machine-types 13 | # and https://docs.semaphoreci.com/article/32-ubuntu-1804-image 14 | agent: 15 | machine: 16 | type: e1-standard-2 17 | os_image: ubuntu2004 18 | 19 | # Blocks are the heart of a pipeline and are executed sequentially. 20 | # Each block has a task that defines one or more jobs. Jobs define the 21 | # commands to execute. 22 | # See https://docs.semaphoreci.com/article/62-concepts 23 | blocks: 24 | - name: 📦 Install dependencies 25 | task: 26 | # Set environment variables that your project requires. 27 | # See https://docs.semaphoreci.com/article/66-environment-variables-and-secrets 28 | env_vars: 29 | - name: NODE_ENV 30 | value: test 31 | - name: CI 32 | value: 'true' 33 | 34 | # This block runs two jobs in parallel and they both share common 35 | # setup steps. We can group them in a prologue. 36 | # See https://docs.semaphoreci.com/article/50-pipeline-yaml#prologue 37 | prologue: 38 | commands: 39 | # Get the latest version of our source code from GitHub: 40 | - checkout 41 | 42 | # Use the version of Node.js specified in .nvmrc. 43 | # Semaphore provides nvm preinstalled. 44 | - nvm use 45 | - node --version 46 | - npm --version 47 | jobs: 48 | # First parallel job: 49 | - name: client npm install and cache 50 | commands: 51 | - cd src/client 52 | 53 | # Restore dependencies from cache. This command will not fail in 54 | # case of a cache miss. In case of a cache hit, npm install will 55 | # run very fast. 56 | # For more info on caching, see https://docs.semaphoreci.com/article/68-caching-dependencies 57 | - cache restore client-node-modules-$SEMAPHORE_GIT_BRANCH-$(checksum package-lock.json),client-node-modules-$SEMAPHORE_GIT_BRANCH,client-node-modules-master 58 | - npm install 59 | 60 | # Store the latest version of node modules in cache to reuse in 61 | # further blocks: 62 | - cache store client-node-modules-$SEMAPHORE_GIT_BRANCH-$(checksum package-lock.json) node_modules 63 | 64 | # Second parallel job: 65 | - name: server npm install and cache 66 | commands: 67 | - cd src/server 68 | - cache restore server-node-modules-$SEMAPHORE_GIT_BRANCH-$(checksum package-lock.json),server-node-modules-$SEMAPHORE_GIT_BRANCH,server-node-modules-master 69 | - npm install --legacy-peer-deps 70 | - cache store server-node-modules-$SEMAPHORE_GIT_BRANCH-$(checksum package-lock.json) node_modules 71 | 72 | - name: 🔍 Lint 73 | task: 74 | env_vars: 75 | - name: NODE_ENV 76 | value: test 77 | - name: CI 78 | value: 'true' 79 | prologue: 80 | commands: 81 | - checkout 82 | - nvm use 83 | - node --version 84 | - npm --version 85 | jobs: 86 | - name: Client Lint 87 | commands: 88 | - cd src/client 89 | # At this point we can assume 100% cache hit rate of node modules: 90 | - cache restore client-node-modules-$SEMAPHORE_GIT_BRANCH-$(checksum package-lock.json),client-node-modules-$SEMAPHORE_GIT_BRANCH,client-node-modules-master 91 | 92 | # Run task as defined in package.json: 93 | - npm run lint 94 | - name: Server Lint 95 | commands: 96 | - cd src/server 97 | - cache restore server-node-modules-$SEMAPHORE_GIT_BRANCH-$(checksum package-lock.json),server-node-modules-$SEMAPHORE_GIT_BRANCH,server-node-modules-master 98 | - npm run lint 99 | 100 | - name: 🧪 Unit Tests 101 | task: 102 | env_vars: 103 | - name: NODE_ENV 104 | value: test 105 | - name: CI 106 | value: 'true' 107 | prologue: 108 | commands: 109 | - checkout 110 | - nvm use 111 | - node --version 112 | - npm --version 113 | jobs: 114 | - name: Client Unit Tests 115 | commands: 116 | - export NAME="Client tests" 117 | - cd src/client 118 | - cache restore client-node-modules-$SEMAPHORE_GIT_BRANCH-$(checksum package-lock.json),client-node-modules-$SEMAPHORE_GIT_BRANCH,client-node-modules-master 119 | - npm test 120 | - name: Server Unit Tests 121 | commands: 122 | - export NAME="Server tests" 123 | - cd src/server 124 | - cache restore server-node-modules-$SEMAPHORE_GIT_BRANCH-$(checksum package-lock.json),server-node-modules-$SEMAPHORE_GIT_BRANCH,server-node-modules-master 125 | - npm test 126 | epilogue: 127 | always: 128 | commands: 129 | - "[[ -f junit.xml ]] && test-results publish --name \"🧪 $NAME\" junit.xml" 130 | 131 | - name: 🔄 E2E Tests 132 | task: 133 | env_vars: 134 | - name: NODE_ENV 135 | value: test 136 | - name: CI 137 | value: 'true' 138 | prologue: 139 | commands: 140 | - checkout 141 | - nvm use 142 | - node --version 143 | - npm --version 144 | # Start a Postgres database. On Semaphore, databases run in the same 145 | # environment as your code. 146 | # See https://docs.semaphoreci.com/article/32-ubuntu-1804-image#databases-and-services 147 | - sem-service start postgres 148 | # With unrestricted sudo access, you can install any additional 149 | # system package: 150 | - sudo apt-get install -y libgtk2.0-0 151 | jobs: 152 | - name: Client E2E Tests 153 | commands: 154 | - export NAME="Client tests" 155 | - cd src/client 156 | - cache restore client-node-modules-$SEMAPHORE_GIT_BRANCH-$(checksum package-lock.json),client-node-modules-$SEMAPHORE_GIT_BRANCH,client-node-modules-master 157 | - npx cypress install 158 | - npm run test:e2e 159 | - name: Server E2E Tests 160 | commands: 161 | - export NAME="Server tests" 162 | - cd src/server 163 | - cache restore server-node-modules-$SEMAPHORE_GIT_BRANCH-$(checksum package-lock.json),server-node-modules-$SEMAPHORE_GIT_BRANCH,server-node-modules-master 164 | - cp ormconfig.ci.json ormconfig.json 165 | - npm run migrate:up 166 | - npm run test:e2e 167 | epilogue: 168 | always: 169 | commands: 170 | - "[[ -f junit.xml ]] && test-results publish --name \"🏗️ $NAME\" junit.xml" 171 | 172 | after_pipeline: 173 | task: 174 | jobs: 175 | - name: Publish Results 176 | commands: 177 | - test-results gen-pipeline-report 178 | 179 | # If all tests pass, we move on to build a Docker image. 180 | # This is a job for a separate pipeline which we link with a promotion. 181 | # 182 | # What happens outside semaphore.yml will not appear in GitHub pull 183 | # request status report. 184 | # 185 | # In this example we run docker build automatically on every branch. 186 | # You may want to limit it by branch name, or trigger it manually. 187 | # For more on such options, see: 188 | # https://docs.semaphoreci.com/article/50-pipeline-yaml#promotions 189 | promotions: 190 | - name: Dockerize server 191 | pipeline_file: server-docker-build.yml 192 | - name: Deploy client 193 | pipeline_file: client-deploy-build.yml 194 | -------------------------------------------------------------------------------- /src/server/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff: 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/dictionaries 10 | 11 | # Sensitive or high-churn files: 12 | .idea/**/dataSources/ 13 | .idea/**/dataSources.ids 14 | .idea/**/dataSources.xml 15 | .idea/**/dataSources.local.xml 16 | .idea/**/sqlDataSources.xml 17 | .idea/**/dynamic.xml 18 | .idea/**/uiDesigner.xml 19 | 20 | # Gradle: 21 | .idea/**/gradle.xml 22 | .idea/**/libraries 23 | 24 | # CMake 25 | cmake-build-debug/ 26 | 27 | # Mongo Explorer plugin: 28 | .idea/**/mongoSettings.xml 29 | 30 | ## File-based project format: 31 | *.iws 32 | 33 | ## Plugin-specific files: 34 | 35 | # IntelliJ 36 | out/ 37 | 38 | # mpeltonen/sbt-idea plugin 39 | .idea_modules/ 40 | 41 | # JIRA plugin 42 | atlassian-ide-plugin.xml 43 | 44 | # Cursive Clojure plugin 45 | .idea/replstate.xml 46 | 47 | # Crashlytics plugin (for Android Studio and IntelliJ) 48 | com_crashlytics_export_strings.xml 49 | crashlytics.properties 50 | crashlytics-build.properties 51 | fabric.properties 52 | ### VisualStudio template 53 | ## Ignore Visual Studio temporary files, build results, and 54 | ## files generated by popular Visual Studio add-ons. 55 | ## 56 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 57 | 58 | # User-specific files 59 | *.suo 60 | *.user 61 | *.userosscache 62 | *.sln.docstates 63 | 64 | # User-specific files (MonoDevelop/Xamarin Studio) 65 | *.userprefs 66 | 67 | # Build results 68 | [Dd]ebug/ 69 | [Dd]ebugPublic/ 70 | [Rr]elease/ 71 | [Rr]eleases/ 72 | x64/ 73 | x86/ 74 | bld/ 75 | [Bb]in/ 76 | [Oo]bj/ 77 | [Ll]og/ 78 | 79 | # Visual Studio 2015 cache/options directory 80 | .vs/ 81 | # Uncomment if you have tasks that create the project's static files in wwwroot 82 | #wwwroot/ 83 | 84 | # MSTest test Results 85 | [Tt]est[Rr]esult*/ 86 | [Bb]uild[Ll]og.* 87 | 88 | # NUNIT 89 | *.VisualState.xml 90 | TestResult.xml 91 | 92 | # Build Results of an ATL Project 93 | [Dd]ebugPS/ 94 | [Rr]eleasePS/ 95 | dlldata.c 96 | 97 | # Benchmark Results 98 | BenchmarkDotNet.Artifacts/ 99 | 100 | # .NET Core 101 | project.lock.json 102 | project.fragment.lock.json 103 | artifacts/ 104 | **/Properties/launchSettings.json 105 | 106 | *_i.c 107 | *_p.c 108 | *_i.h 109 | *.ilk 110 | *.meta 111 | *.obj 112 | *.pch 113 | *.pdb 114 | *.pgc 115 | *.pgd 116 | *.rsp 117 | *.sbr 118 | *.tlb 119 | *.tli 120 | *.tlh 121 | *.tmp 122 | *.tmp_proj 123 | *.log 124 | *.vspscc 125 | *.vssscc 126 | .builds 127 | *.pidb 128 | *.svclog 129 | *.scc 130 | 131 | # Chutzpah Test files 132 | _Chutzpah* 133 | 134 | # Visual C++ cache files 135 | ipch/ 136 | *.aps 137 | *.ncb 138 | *.opendb 139 | *.opensdf 140 | *.sdf 141 | *.cachefile 142 | *.VC.db 143 | *.VC.VC.opendb 144 | 145 | # Visual Studio profiler 146 | *.psess 147 | *.vsp 148 | *.vspx 149 | *.sap 150 | 151 | # Visual Studio Trace Files 152 | *.e2e 153 | 154 | # TFS 2012 Local Workspace 155 | $tf/ 156 | 157 | # Guidance Automation Toolkit 158 | *.gpState 159 | 160 | # ReSharper is a .NET coding add-in 161 | _ReSharper*/ 162 | *.[Rr]e[Ss]harper 163 | *.DotSettings.user 164 | 165 | # JustCode is a .NET coding add-in 166 | .JustCode 167 | 168 | # TeamCity is a build add-in 169 | _TeamCity* 170 | 171 | # DotCover is a Code Coverage Tool 172 | *.dotCover 173 | 174 | # AxoCover is a Code Coverage Tool 175 | .axoCover/* 176 | !.axoCover/settings.json 177 | 178 | # Visual Studio code coverage results 179 | *.coverage 180 | *.coveragexml 181 | 182 | # NCrunch 183 | _NCrunch_* 184 | .*crunch*.local.xml 185 | nCrunchTemp_* 186 | 187 | # MightyMoose 188 | *.mm.* 189 | AutoTest.Net/ 190 | 191 | # Web workbench (sass) 192 | .sass-cache/ 193 | 194 | # Installshield output folder 195 | [Ee]xpress/ 196 | 197 | # DocProject is a documentation generator add-in 198 | DocProject/buildhelp/ 199 | DocProject/Help/*.HxT 200 | DocProject/Help/*.HxC 201 | DocProject/Help/*.hhc 202 | DocProject/Help/*.hhk 203 | DocProject/Help/*.hhp 204 | DocProject/Help/Html2 205 | DocProject/Help/html 206 | 207 | # Click-Once directory 208 | publish/ 209 | 210 | # Publish Web Output 211 | *.[Pp]ublish.xml 212 | *.azurePubxml 213 | # Note: Comment the next line if you want to checkin your web deploy settings, 214 | # but database connection strings (with potential passwords) will be unencrypted 215 | *.pubxml 216 | *.publishproj 217 | 218 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 219 | # checkin your Azure Web App publish settings, but sensitive information contained 220 | # in these scripts will be unencrypted 221 | PublishScripts/ 222 | 223 | # NuGet Packages 224 | *.nupkg 225 | # The packages folder can be ignored because of Package Restore 226 | **/[Pp]ackages/* 227 | # except build/, which is used as an MSBuild target. 228 | !**/[Pp]ackages/build/ 229 | # Uncomment if necessary however generally it will be regenerated when needed 230 | #!**/[Pp]ackages/repositories.config 231 | # NuGet v3's project.json files produces more ignorable files 232 | *.nuget.props 233 | *.nuget.targets 234 | 235 | # Microsoft Azure Build Output 236 | csx/ 237 | *.build.csdef 238 | 239 | # Microsoft Azure Emulator 240 | ecf/ 241 | rcf/ 242 | 243 | # Windows Store app package directories and files 244 | AppPackages/ 245 | BundleArtifacts/ 246 | Package.StoreAssociation.xml 247 | _pkginfo.txt 248 | *.appx 249 | 250 | # Visual Studio cache files 251 | # files ending in .cache can be ignored 252 | *.[Cc]ache 253 | # but keep track of directories ending in .cache 254 | !*.[Cc]ache/ 255 | 256 | # Others 257 | ClientBin/ 258 | ~$* 259 | *~ 260 | *.dbmdl 261 | *.dbproj.schemaview 262 | *.jfm 263 | *.pfx 264 | *.publishsettings 265 | orleans.codegen.cs 266 | 267 | # Since there are multiple workflows, uncomment next line to ignore bower_components 268 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 269 | #bower_components/ 270 | 271 | # RIA/Silverlight projects 272 | Generated_Code/ 273 | 274 | # Backup & report files from converting an old project file 275 | # to a newer Visual Studio version. Backup files are not needed, 276 | # because we have git ;-) 277 | _UpgradeReport_Files/ 278 | Backup*/ 279 | UpgradeLog*.XML 280 | UpgradeLog*.htm 281 | 282 | # SQL Server files 283 | *.mdf 284 | *.ldf 285 | *.ndf 286 | 287 | # Business Intelligence projects 288 | *.rdl.data 289 | *.bim.layout 290 | *.bim_*.settings 291 | 292 | # Microsoft Fakes 293 | FakesAssemblies/ 294 | 295 | # GhostDoc plugin setting file 296 | *.GhostDoc.xml 297 | 298 | # Node.js Tools for Visual Studio 299 | .ntvs_analysis.dat 300 | node_modules/ 301 | 302 | # Typescript v1 declaration files 303 | typings/ 304 | 305 | # Visual Studio 6 build log 306 | *.plg 307 | 308 | # Visual Studio 6 workspace options file 309 | *.opt 310 | 311 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 312 | *.vbw 313 | 314 | # Visual Studio LightSwitch build output 315 | **/*.HTMLClient/GeneratedArtifacts 316 | **/*.DesktopClient/GeneratedArtifacts 317 | **/*.DesktopClient/ModelManifest.xml 318 | **/*.Server/GeneratedArtifacts 319 | **/*.Server/ModelManifest.xml 320 | _Pvt_Extensions 321 | 322 | # Paket dependency manager 323 | .paket/paket.exe 324 | paket-files/ 325 | 326 | # FAKE - F# Make 327 | .fake/ 328 | 329 | # JetBrains Rider 330 | .idea/ 331 | *.sln.iml 332 | 333 | # CodeRush 334 | .cr/ 335 | 336 | # Python Tools for Visual Studio (PTVS) 337 | __pycache__/ 338 | *.pyc 339 | 340 | # Cake - Uncomment if you are using it 341 | # tools/** 342 | # !tools/packages.config 343 | 344 | # Tabs Studio 345 | *.tss 346 | 347 | # Telerik's JustMock configuration file 348 | *.jmconfig 349 | 350 | # BizTalk build output 351 | *.btp.cs 352 | *.btm.cs 353 | *.odx.cs 354 | *.xsd.cs 355 | 356 | # OpenCover UI analysis results 357 | OpenCover/ 358 | coverage/ 359 | 360 | ### macOS template 361 | # General 362 | .DS_Store 363 | .AppleDouble 364 | .LSOverride 365 | 366 | # Icon must end with two \r 367 | Icon 368 | 369 | # Thumbnails 370 | ._* 371 | 372 | # Files that might appear in the root of a volume 373 | .DocumentRevisions-V100 374 | .fseventsd 375 | .Spotlight-V100 376 | .TemporaryItems 377 | .Trashes 378 | .VolumeIcon.icns 379 | .com.apple.timemachine.donotpresent 380 | 381 | # Directories potentially created on remote AFP share 382 | .AppleDB 383 | .AppleDesktop 384 | Network Trash Folder 385 | Temporary Items 386 | .apdisk 387 | 388 | ======= 389 | # Local 390 | docker-compose.yml 391 | .env 392 | dist 393 | ormconfig.json 394 | ormconfig.production.json 395 | production.env 396 | --------------------------------------------------------------------------------