├── .gitignore ├── ui ├── src │ ├── react-app-env.d.ts │ ├── views │ │ ├── components │ │ │ ├── application │ │ │ │ ├── LoadingPane.module.scss │ │ │ │ ├── AppLoadFailed.tsx │ │ │ │ ├── Shell.module.scss │ │ │ │ ├── LoadingPane.tsx │ │ │ │ ├── PageLayout.module.scss │ │ │ │ ├── ErrorBoundary.tsx │ │ │ │ ├── Shell.tsx │ │ │ │ └── PageLayout.tsx │ │ │ └── ScenarioSelector │ │ │ │ ├── scenario-models.ts │ │ │ │ ├── scenarioLocalStorageProvider.ts │ │ │ │ ├── ScenarioLoader.module.scss │ │ │ │ └── ScenarioLoader.tsx │ │ └── routes │ │ │ ├── quotes │ │ │ ├── Quote.module.scss │ │ │ ├── QuoteSection.module.scss │ │ │ ├── CustomerSection.module.scss │ │ │ ├── QuotesList.module.scss │ │ │ ├── QuoteStatus.tsx │ │ │ ├── Quote.tsx │ │ │ ├── QuotesList.tsx │ │ │ ├── QuoteSection.tsx │ │ │ └── CustomerSection.tsx │ │ │ ├── NotFound.tsx │ │ │ └── Routes.tsx │ ├── api │ │ ├── user.api.ts │ │ ├── quotes.api.ts │ │ ├── http.ts │ │ └── api-models.ts │ ├── setupTests.ts │ ├── App.test.tsx │ ├── index.css │ ├── App.css │ ├── index.tsx │ ├── App.tsx │ ├── logo.svg │ └── serviceWorker.ts ├── public │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── robots.txt │ ├── manifest.json │ └── index.html ├── cypress.json ├── cypress │ ├── fixtures │ │ ├── example.json │ │ └── quotes │ │ │ └── quotes.json │ ├── tsconfig.json │ ├── support │ │ ├── index.d.ts │ │ ├── commands.js │ │ └── index.js │ ├── plugins │ │ └── index.js │ └── integration │ │ └── quotes │ │ └── quotes.spec.ts ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── package.json └── README.md ├── mockApi ├── mockData │ ├── index.ts │ ├── user.ts │ └── quotes.ts ├── tsconfig.json ├── package.json ├── mock-models.ts ├── responses.ts ├── .gitignore ├── renderHelpers.ts ├── server.ts ├── api-models.ts └── package-lock.json └── swagger └── swagger.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | .vs 2 | -------------------------------------------------------------------------------- /ui/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahulpnath/quotes/HEAD/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahulpnath/quotes/HEAD/ui/public/logo192.png -------------------------------------------------------------------------------- /ui/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahulpnath/quotes/HEAD/ui/public/logo512.png -------------------------------------------------------------------------------- /ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /ui/src/views/components/application/LoadingPane.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | text-align: center; 3 | } 4 | -------------------------------------------------------------------------------- /ui/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignoreTestFiles": "**/examples/*.js", 3 | "baseUrl": "http://localhost:3000" 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/views/routes/quotes/Quote.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | display: grid; 3 | grid-row-gap: 16px; 4 | } 5 | -------------------------------------------------------------------------------- /mockApi/mockData/index.ts: -------------------------------------------------------------------------------- 1 | import quotes from "./quotes"; 2 | import user from "./user"; 3 | 4 | const data = { 5 | quotes, 6 | user, 7 | }; 8 | 9 | export default data; 10 | -------------------------------------------------------------------------------- /ui/src/views/routes/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const NotFound: React.FC = function() { 4 | return

The page you were looking for cannot be found.

; 5 | }; 6 | -------------------------------------------------------------------------------- /ui/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 | } -------------------------------------------------------------------------------- /ui/src/api/user.api.ts: -------------------------------------------------------------------------------- 1 | import { UserProfileDto } from './api-models'; 2 | import http from './http'; 3 | 4 | export async function getUser(): Promise { 5 | const response = await http.get('/api/users/me'); 6 | return response.data; 7 | } 8 | -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "jsxBracketSameLine": true, 5 | "trailingComma": "es5", 6 | "overrides": [ 7 | { 8 | "files": ".prettierrc", 9 | "options": { "parser": "json" } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /ui/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": "../node_modules", 5 | "target": "es5", 6 | "lib": ["es5", "dom"], 7 | "types": ["cypress", "@types/testing-library__cypress"] 8 | }, 9 | "include": ["**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /ui/src/views/components/ScenarioSelector/scenario-models.ts: -------------------------------------------------------------------------------- 1 | export interface ScenarioGroup { 2 | name: string; 3 | scenarios: string[]; 4 | } 5 | 6 | export const availableScenarios: string[] = [ 7 | 'draft', 8 | 'phone', 9 | 'open', 10 | 'error-quotes', 11 | 'error-user', 12 | 'no-quotes', 13 | ]; 14 | -------------------------------------------------------------------------------- /ui/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /mockApi/mockData/user.ts: -------------------------------------------------------------------------------- 1 | import { UserProfileDto } from "api-models"; 2 | 3 | const user: UserProfileDto = { 4 | id: "1", 5 | name: "Rahul Nath", 6 | userPictureUrl: 7 | "https://www.gravatar.com/avatar/90b159431e7aba9b8ffdafd5a83e446f?d=https://ui-avatars.com/api/Rahul%20Nath/128/0D8ABC/fff/2", 8 | }; 9 | 10 | export default user; 11 | -------------------------------------------------------------------------------- /mockApi/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "baseUrl": "./", 5 | "target": "es6", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "lib": ["es6"], 9 | "allowJs": true, 10 | "forceConsistentCasingInFileNames": true, 11 | }, 12 | "include": [ 13 | "**.ts" 14 | ], 15 | } -------------------------------------------------------------------------------- /mockApi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mockapi", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "start": "npx ts-node server.ts" 7 | }, 8 | "author": "", 9 | "license": "ISC", 10 | "dependencies": { 11 | "json-server": "^0.16.1", 12 | "@types/json-server": "^0.14.2", 13 | "typescript": "^3.8.3" 14 | }, 15 | "devDependencies": {} 16 | } 17 | -------------------------------------------------------------------------------- /mockApi/mock-models.ts: -------------------------------------------------------------------------------- 1 | import { QuoteDto } from "api-models"; 2 | 3 | export const scenariosForEndpoint = { 4 | "/api/quotes": ["phone", "no-phone", "draft", "open", "no-quotes"], 5 | }; 6 | 7 | export type QuoteScenario = 8 | | "phone" 9 | | "no-phone" 10 | | "draft" 11 | | "open" 12 | | "no-quotes"; 13 | 14 | export interface QuoteDtoSceanrio extends QuoteDto { 15 | scenarios: QuoteScenario[]; 16 | } 17 | -------------------------------------------------------------------------------- /ui/cypress/support/index.d.ts: -------------------------------------------------------------------------------- 1 | // in cypress/support/index.d.ts 2 | // load type definitions that come with Cypress module 3 | /// 4 | 5 | declare namespace Cypress { 6 | interface Chainable { 7 | /** 8 | * Custom command to set sceanrios. 9 | * @example cy.setScenarios('draft open') 10 | */ 11 | setScenarios(scenarios: string): Chainable; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/views/components/application/AppLoadFailed.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const AppLoadFailed: React.FC = function() { 4 | return ( 5 |
6 |

:(

7 |

8 | Unfortunately something went wrong. Please refresh the browser to try again or contact your 9 | system administrator. 10 |

11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /ui/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/views/components/application/Shell.module.scss: -------------------------------------------------------------------------------- 1 | .shell { 2 | height: 100%; 3 | } 4 | 5 | .main { 6 | padding: 1rem; 7 | } 8 | 9 | .logo { 10 | display: flex; 11 | 12 | > img { 13 | height: 1.8rem; 14 | } 15 | } 16 | 17 | .title { 18 | flex-grow: 1; 19 | margin-left: 0.5rem; 20 | border-left: 1px solid; 21 | padding-left: 0.5rem; 22 | } 23 | 24 | .menu-img { 25 | height: 1.8em; 26 | border-radius: 50%; 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/views/routes/quotes/QuoteSection.module.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | font-weight: bold; 3 | font-size: 110%; 4 | } 5 | 6 | .expand { 7 | color: red; 8 | } 9 | 10 | .spacer { 11 | flex-grow: 1; 12 | } 13 | 14 | .actions { 15 | display: flex; 16 | flex-wrap: wrap; 17 | flex-direction: row-reverse; 18 | justify-content: flex-start; 19 | padding: 8px 24px 16px 24px; 20 | 21 | > :not(.spacer) { 22 | min-width: 7em; 23 | margin: 4px 8px; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/views/routes/quotes/CustomerSection.module.scss: -------------------------------------------------------------------------------- 1 | .fields { 2 | display: grid; 3 | max-width: 40em; 4 | grid-template-columns: 2fr 1fr; 5 | grid-template-areas: 6 | 'name phone' 7 | 'address email'; 8 | column-gap: 16px; 9 | 10 | .name { 11 | grid-area: name; 12 | } 13 | 14 | .phone { 15 | grid-area: phone; 16 | } 17 | 18 | .address { 19 | grid-area: address; 20 | } 21 | 22 | .email { 23 | grid-area: email; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ui/.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 | #cypress 15 | cypress/integration/examples 16 | cypress.env.json 17 | 18 | # misc 19 | .DS_Store 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | -------------------------------------------------------------------------------- /ui/src/views/routes/quotes/QuotesList.module.scss: -------------------------------------------------------------------------------- 1 | .actions { 2 | margin: 0; 3 | display: flex; 4 | justify-content: space-between; 5 | } 6 | 7 | .newquote { 8 | width: 12rem; 9 | } 10 | 11 | .table { 12 | margin-top: 1rem; 13 | } 14 | 15 | .tablehead { 16 | background-color: lightgray; 17 | } 18 | 19 | .row { 20 | cursor: pointer; 21 | 22 | &:hover { 23 | td { 24 | color: darkslateblue; 25 | } 26 | } 27 | } 28 | 29 | .noquotes { 30 | text-align: center; 31 | } 32 | -------------------------------------------------------------------------------- /mockApi/responses.ts: -------------------------------------------------------------------------------- 1 | const responses = [ 2 | { 3 | urls: ["/api/quotes"], 4 | code: "error", 5 | httpStatus: 500, 6 | respone: { 7 | code: "error-quotes", 8 | message: "Unable to get data. ", 9 | }, 10 | }, 11 | { 12 | urls: ["/api/users/me"], 13 | code: "error-user", 14 | httpStatus: 500, 15 | respone: { 16 | code: "error-user", 17 | message: "Unable to get user data. ", 18 | }, 19 | }, 20 | ]; 21 | 22 | export default responses; 23 | -------------------------------------------------------------------------------- /mockApi/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /node_modules/@types 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | #cypress 16 | cypress/integration/examples 17 | cypress.env.json 18 | 19 | # misc 20 | .DS_Store 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | -------------------------------------------------------------------------------- /ui/src/views/components/application/LoadingPane.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './LoadingPane.module.scss'; 3 | import { CircularProgress } from '@material-ui/core'; 4 | 5 | interface ILoadingPaneProps { 6 | isLoading: boolean; 7 | } 8 | 9 | export const LoadingPane: React.FC = function({ isLoading, children }) { 10 | return isLoading ? ( 11 |
12 | 13 |
14 | ) : ( 15 | <>{children || null} 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "downlevelIteration": true, 16 | "noEmit": true, 17 | "jsx": "react", 18 | "baseUrl": "src" 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /ui/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 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ui/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /ui/src/views/components/application/PageLayout.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | grid-template-columns: auto auto 1fr; // Make the title auto, so that it breaks after the audit when on small screens 4 | grid-template-rows: auto auto 1fr; 5 | grid-template-areas: 6 | 'breadcrumb breadcrumb breadcrumb' 7 | 'title subtitle audit' 8 | 'content content content'; 9 | } 10 | 11 | .breadcrumb { 12 | grid-area: breadcrumb; 13 | margin-bottom: 0.5em; 14 | 15 | a { 16 | display: flex; // Show icons and text in line 17 | } 18 | } 19 | 20 | .title { 21 | grid-area: title; 22 | margin-right: 1rem; 23 | } 24 | 25 | .subtitle { 26 | grid-area: subtitle; 27 | align-self: center; 28 | } 29 | 30 | .audit { 31 | grid-area: audit; 32 | align-self: end; 33 | text-align: right; 34 | } 35 | 36 | .content { 37 | grid-area: content; 38 | padding: 1em 0; 39 | } 40 | -------------------------------------------------------------------------------- /ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { App } from 'App'; 2 | import { createBrowserHistory } from 'history'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { AppLoadFailed } from 'views/components/application/AppLoadFailed'; 6 | import './index.css'; 7 | import * as serviceWorker from './serviceWorker'; 8 | 9 | async function init() { 10 | const history = createBrowserHistory(); 11 | ReactDOM.render(, document.getElementById('root')); 12 | } 13 | 14 | init().catch(e => { 15 | console.error('App initialization failed', e); 16 | ReactDOM.render(, 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: https://bit.ly/CRA-PWA 22 | serviceWorker.unregister(); 23 | -------------------------------------------------------------------------------- /ui/src/views/components/application/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IErrorBoundaryState { 4 | hasError: boolean; 5 | } 6 | 7 | export class ErrorBoundary extends React.Component<{}, IErrorBoundaryState> { 8 | constructor(props: {}) { 9 | super(props); 10 | this.state = { hasError: false }; 11 | } 12 | 13 | static getDerivedStateFromError(error: unknown) { 14 | return { hasError: true }; 15 | } 16 | 17 | componentDidCatch(error: Error, info: React.ErrorInfo) { 18 | console.error('Rendering failed below Error Boundary: ', error); 19 | } 20 | 21 | render() { 22 | if (this.state.hasError) { 23 | return ( 24 |
25 |

:(

26 |

Unfortunately something went wrong. Please contact your system administrator."

27 |
28 | ); 29 | } 30 | 31 | return this.props.children; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ui/src/api/quotes.api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateQuoteCommand, 3 | CreateQuoteResponse, 4 | QuoteDto, 5 | QuoteSummaryDto, 6 | UpdateQuoteCustomerCommand, 7 | } from './api-models'; 8 | import http from './http'; 9 | 10 | export async function loadAllQuotes(): Promise { 11 | const response = await http.get('/api/quotes'); 12 | return response.data; 13 | } 14 | 15 | export async function loadQuote(id: string): Promise { 16 | const response = await http.get(`/api/quotes/${id}`); 17 | return response.data; 18 | } 19 | 20 | export async function createQuote(command: CreateQuoteCommand): Promise { 21 | const response = await http.post('/api/quotes', command); 22 | return response.data; 23 | } 24 | 25 | export async function updateQuoteCustomer(command: UpdateQuoteCustomerCommand): Promise { 26 | await http.put(`/api/quotes/${command.quoteId}/customer`, command); 27 | } 28 | -------------------------------------------------------------------------------- /ui/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 will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | import '@testing-library/cypress/add-commands'; 27 | -------------------------------------------------------------------------------- /ui/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 | 22 | Cypress.Commands.add('setScenarios', scenarios => { 23 | const scenarioItem = { 24 | name: 'Test', 25 | scenarios: scenarios.split(' '), 26 | }; 27 | window.localStorage.setItem('selectedScenarioGroup', JSON.stringify(scenarioItem)); 28 | }); 29 | -------------------------------------------------------------------------------- /ui/cypress/fixtures/quotes/quotes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "statusCode": "Draft", 5 | "lastModifiedAt": "2-Mar-2020", 6 | "customerName": "Rahul Nath" 7 | }, 8 | { 9 | "id": "2", 10 | "statusCode": "Draft", 11 | "lastModifiedAt": "2-Mar-2020", 12 | "customerName": "Vincent Zhao", 13 | "mobilePhoneDescription": "iPhone X" 14 | }, 15 | { 16 | "id": "3", 17 | "quoteNumber": "ABC123", 18 | "statusCode": "Open", 19 | "lastModifiedAt": "20-Mar-2020", 20 | "customerName": "Peter Stanbrige", 21 | "mobilePhoneDescription": "iPhone X" 22 | }, 23 | { 24 | "id": "4", 25 | "quoteNumber": "EFG123", 26 | "statusCode": "Accepted", 27 | "lastModifiedAt": "20-Mar-2020", 28 | "customerName": "Bob Martin", 29 | "mobilePhoneDescription": "iPhone X" 30 | }, 31 | { 32 | "id": "5", 33 | "quoteNumber": "XYZ123", 34 | "statusCode": "Expired", 35 | "lastModifiedAt": "20-Jan-2020", 36 | "customerName": "John Stone", 37 | "mobilePhoneDescription": "iPhone X" 38 | } 39 | ] 40 | -------------------------------------------------------------------------------- /ui/src/views/components/ScenarioSelector/scenarioLocalStorageProvider.ts: -------------------------------------------------------------------------------- 1 | import { ScenarioGroup } from './scenario-models'; 2 | 3 | const SCENARIO_GROUPS = 'scenarioGroups'; 4 | const SELECTED_SCENARIO_GROUP = 'selectedScenarioGroup'; 5 | 6 | export const getSelectedScenario = () => { 7 | const selectedScenarioGroup = window.localStorage.getItem(SELECTED_SCENARIO_GROUP); 8 | return selectedScenarioGroup ? (JSON.parse(selectedScenarioGroup) as ScenarioGroup) : null; 9 | }; 10 | 11 | export const addScenario = (scenario: ScenarioGroup) => { 12 | let allSceanrios = getScenarios(); 13 | allSceanrios.push(scenario); 14 | window.localStorage.setItem(SCENARIO_GROUPS, JSON.stringify(allSceanrios)); 15 | }; 16 | 17 | export const setCurrentScenario = (scenario: ScenarioGroup) => { 18 | window.localStorage.setItem(SELECTED_SCENARIO_GROUP, JSON.stringify(scenario)); 19 | }; 20 | 21 | export const getScenarios = () => { 22 | const allSceanrionString = window.localStorage.getItem(SCENARIO_GROUPS) || '[]'; 23 | return JSON.parse(allSceanrionString) as ScenarioGroup[]; 24 | }; 25 | -------------------------------------------------------------------------------- /ui/src/views/routes/quotes/QuoteStatus.tsx: -------------------------------------------------------------------------------- 1 | import { Chip } from '@material-ui/core'; 2 | import { QuoteStatusCode } from 'api/api-models'; 3 | import React from 'react'; 4 | 5 | export interface IQuoteStatusProps { 6 | statusCode: QuoteStatusCode; 7 | className?: string; 8 | } 9 | 10 | const getStatusColor = (statusCode: QuoteStatusCode) => { 11 | switch (statusCode) { 12 | case QuoteStatusCode.Open: 13 | return 'orange'; 14 | case QuoteStatusCode.Accepted: 15 | return 'seagreen'; 16 | case QuoteStatusCode.Expired: 17 | return 'red'; 18 | case QuoteStatusCode.Draft: 19 | return 'grey'; 20 | default: 21 | return 'grey'; 22 | } 23 | }; 24 | 25 | export const QuoteStatus: React.FC = ({ statusCode, className }) => { 26 | const statusColor = getStatusColor(statusCode); 27 | return ( 28 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /mockApi/renderHelpers.ts: -------------------------------------------------------------------------------- 1 | import { scenariosForEndpoint } from "./mock-models"; 2 | import responses from "./responses"; 3 | 4 | export const getCustomReponse = (url, scenarios) => { 5 | if (!scenarios || scenarios.length === 0) return null; 6 | 7 | return responses.find( 8 | (response) => 9 | scenarios.includes(response.code) && response.urls.includes(url) 10 | ); 11 | }; 12 | 13 | export const toQuoteSummary = (quote) => ({ 14 | id: quote.id, 15 | scenarios: quote.scenarios, 16 | quoteNumber: quote.quoteNumber, 17 | statusCode: quote.statusCode, 18 | lastModifiedAt: quote.lastModifiedAt, 19 | customerName: quote.customer && quote.customer.name, 20 | mobilePhoneDescription: quote.mobilePhone && quote.mobilePhone.serialNo, 21 | }); 22 | 23 | export const removeTrailingSlashes = (url) => url.replace(/\/+$/, ""); 24 | 25 | export const getScenariosApplicableToEndpoint = ( 26 | endpoint: string, 27 | scenarios: string[] 28 | ) => { 29 | const endpointScenarios = (scenariosForEndpoint[endpoint] as string[]) || []; 30 | return scenarios.filter((a) => endpointScenarios.includes(a)); 31 | }; 32 | -------------------------------------------------------------------------------- /ui/src/views/routes/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; 3 | import { ErrorBoundary } from 'views/components/application/ErrorBoundary'; 4 | import { Shell } from 'views/components/application/Shell'; 5 | import { NotFound } from './NotFound'; 6 | import { Quote } from './quotes/Quote'; 7 | import { QuotesList } from './quotes/QuotesList'; 8 | 9 | export const Routes: React.FC = function() { 10 | const location = useLocation(); 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /ui/src/api/http.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { toast } from 'react-toastify'; 3 | import { getSelectedScenario } from 'views/components/ScenarioSelector/scenarioLocalStorageProvider'; 4 | 5 | const http = axios.create(); 6 | 7 | if (process.env.NODE_ENV === 'development') { 8 | http.interceptors.request.use( 9 | async request => { 10 | const storage = window.localStorage; 11 | const selectedScenario = getSelectedScenario(); 12 | if (storage) 13 | request.headers['scenarios'] = selectedScenario ? selectedScenario.scenarios.join(' ') : ''; 14 | return request; 15 | }, 16 | error => Promise.reject(error) 17 | ); 18 | } 19 | 20 | http.interceptors.response.use( 21 | async response => response, 22 | error => { 23 | const response = error.response; 24 | let errorMsg = 'An unknown error has occured. Please try again.'; 25 | if (response && response.data && response.data.message) { 26 | errorMsg = response.data.message; 27 | } 28 | toast.error(errorMsg, { toastId: errorMsg }); 29 | 30 | return Promise.reject(error); 31 | } 32 | ); 33 | 34 | export default http; 35 | -------------------------------------------------------------------------------- /ui/src/views/components/ScenarioSelector/ScenarioLoader.module.scss: -------------------------------------------------------------------------------- 1 | .scenarioLoader { 2 | position: fixed; 3 | top: 15px; 4 | left: 25px; 5 | background-color: transparent; 6 | z-index: 10; 7 | 8 | > button { 9 | border: none; 10 | } 11 | 12 | .scenarioLoaderPanel { 13 | display: grid; 14 | align-items: center; 15 | margin-left: 20px; 16 | grid-gap: 10px; 17 | grid-template-columns: min-content auto; 18 | grid-template-rows: 1fr 1fr 1fr 1fr; 19 | background-color: white; 20 | border: 0.5px solid darkgray; 21 | box-shadow: 0px 3px 6px #00000029; 22 | grid-template-areas: 23 | 'title title' 24 | 'selectCreate selectCreate' 25 | 'scenariosTitle scenarios' 26 | 'actions actions'; 27 | min-width: 300px; 28 | padding: 1rem; 29 | } 30 | .title { 31 | grid-area: title; 32 | margin: auto auto; 33 | } 34 | .selectCreate { 35 | grid-area: selectCreate; 36 | } 37 | 38 | .scenariosTitle { 39 | grid-area: scenariosTitle; 40 | } 41 | 42 | .scenarios { 43 | grid-area: scenarios; 44 | } 45 | 46 | .actions { 47 | grid-area: actions; 48 | display: flex; 49 | flex-direction: row-reverse; 50 | > * { 51 | margin: 5px; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /mockApi/mockData/quotes.ts: -------------------------------------------------------------------------------- 1 | import { QuoteStatusCode } from "../api-models"; 2 | import { QuoteDtoSceanrio } from "../mock-models"; 3 | 4 | const quotes: QuoteDtoSceanrio[] = [ 5 | { 6 | scenarios: ["draft", "no-phone"], 7 | id: "1", 8 | statusCode: QuoteStatusCode.Draft, 9 | lastModifiedAt: new Date("2-Mar-2020"), 10 | customer: { 11 | name: "Rahul", 12 | email: "test@test.com", 13 | address: "Fake Address", 14 | }, 15 | mobilePhone: null, 16 | accessories: [], 17 | }, 18 | { 19 | scenarios: ["draft", "phone"], 20 | id: "2", 21 | statusCode: QuoteStatusCode.Draft, 22 | lastModifiedAt: new Date("2-Mar-2020"), 23 | customer: { 24 | name: "Rahul", 25 | email: "test@test.com", 26 | address: "Fake Address", 27 | }, 28 | mobilePhone: { 29 | serialNo: "iPhone X", 30 | model: "X", 31 | price: 1000, 32 | }, 33 | accessories: [], 34 | }, 35 | { 36 | scenarios: ["open", "phone"], 37 | id: "3", 38 | statusCode: QuoteStatusCode.Open, 39 | lastModifiedAt: new Date("20-Mar-2020"), 40 | customer: { 41 | name: "Vincent", 42 | email: "test@test.com", 43 | address: "Fake Address", 44 | }, 45 | mobilePhone: { 46 | serialNo: "iPhone X", 47 | model: "X", 48 | price: 1300, 49 | }, 50 | accessories: [], 51 | }, 52 | ]; 53 | 54 | export default quotes; 55 | -------------------------------------------------------------------------------- /ui/src/views/components/application/Shell.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, Container, IconButton, Toolbar } from '@material-ui/core'; 2 | import { UserProfileDto } from 'api/api-models'; 3 | import * as UserApi from 'api/user.api'; 4 | import React, { useEffect, useState } from 'react'; 5 | import { Link } from 'react-router-dom'; 6 | import styles from './Shell.module.scss'; 7 | 8 | export const Shell: React.FC = ({ children }) => { 9 | const [user, setUser] = useState(undefined); 10 | 11 | const loadUser = async () => { 12 | setUser(await UserApi.getUser()); 13 | }; 14 | useEffect(() => { 15 | loadUser(); 16 | }, []); 17 | return ( 18 |
19 | 20 | 21 | 22 | 23 | Quotes 24 | 25 | {user && ( 26 | {user.name} 27 | )} 28 | 29 | 30 | 31 | 32 |
33 | {children} 34 |
35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import CssBaseline from '@material-ui/core/CssBaseline'; 2 | import { History } from 'history'; 3 | import React from 'react'; 4 | import { Router } from 'react-router-dom'; 5 | import { ToastContainer } from 'react-toastify'; 6 | import 'react-toastify/dist/ReactToastify.min.css'; 7 | import { availableScenarios } from 'views/components/ScenarioSelector/scenario-models'; 8 | import ScenarioLoader from 'views/components/ScenarioSelector/ScenarioLoader'; 9 | import { 10 | addScenario, 11 | getScenarios, 12 | setCurrentScenario, 13 | getSelectedScenario, 14 | } from 'views/components/ScenarioSelector/scenarioLocalStorageProvider'; 15 | import { Routes } from 'views/routes/Routes'; 16 | import './App.css'; 17 | 18 | interface IAppProps { 19 | history: History; 20 | } 21 | 22 | export const App: React.FC = ({ history }) => { 23 | return ( 24 | <> 25 | 26 | 27 | { 33 | setCurrentScenario(scenario); 34 | window.location.reload(); 35 | }} 36 | onScenarioDeleted={() => {}} 37 | /> 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /ui/src/views/components/application/PageLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Breadcrumbs, Container, Link, Typography } from '@material-ui/core'; 2 | import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; 3 | import React from 'react'; 4 | import { Link as RouterLink } from 'react-router-dom'; 5 | import styles from './PageLayout.module.scss'; 6 | 7 | interface IPageLayoutProps { 8 | title: React.ReactNode; 9 | subtitle?: React.ReactNode; 10 | audit?: React.ReactNode; 11 | parent: [string, string] | 'none'; 12 | } 13 | 14 | export const PageLayout: React.FC = function({ 15 | title, 16 | subtitle, 17 | audit, 18 | parent, 19 | children, 20 | }) { 21 | return ( 22 |
23 | 24 | {title} 25 | 26 | 27 | {subtitle} 28 | 29 | {audit} 30 | 31 | {children} 32 | 33 | {parent !== 'none' && ( 34 | 35 | 36 | {parent[0]} 37 | 38 | {title} 39 | 40 | )} 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quotes", 3 | "version": "0.1.0", 4 | "private": true, 5 | "proxy": "http://localhost:5000/", 6 | "scripts": { 7 | "start": "react-scripts start", 8 | "build": "react-scripts build", 9 | "test": "react-scripts test", 10 | "eject": "react-scripts eject", 11 | "cypress": "cypress open", 12 | "mockapi": "node mockApi/server.js" 13 | }, 14 | "dependencies": { 15 | "@material-ui/core": "^4.9.8", 16 | "@material-ui/icons": "^4.9.1", 17 | "@material-ui/lab": "^4.0.0-alpha.47", 18 | "axios": "^0.21.1", 19 | "nan": "^2.14.0", 20 | "react": "^16.13.1", 21 | "react-dom": "^16.13.1", 22 | "react-hook-form": "^5.2.0", 23 | "react-router-dom": "^5.1.2", 24 | "react-scripts": "3.4.1", 25 | "react-toastify": "^5.5.0", 26 | "typescript": "^3.7.5" 27 | }, 28 | "devDependencies": { 29 | "@testing-library/cypress": "^6.0.0", 30 | "@testing-library/jest-dom": "^4.2.4", 31 | "@testing-library/react": "^9.5.0", 32 | "@testing-library/user-event": "^7.2.1", 33 | "@types/jest": "^24.9.1", 34 | "@types/node": "^12.12.31", 35 | "@types/react": "^16.9.26", 36 | "@types/react-dom": "^16.9.5", 37 | "@types/react-router-dom": "^5.1.3", 38 | "@types/testing-library__cypress": "^5.0.3", 39 | "cypress": "^4.2.0", 40 | "json-server": "^0.16.1", 41 | "node-sass": "^4.13.1" 42 | }, 43 | "eslintConfig": { 44 | "extends": "react-app" 45 | }, 46 | "browserslist": { 47 | "production": [ 48 | ">0.2%", 49 | "not dead", 50 | "not op_mini all" 51 | ], 52 | "development": [ 53 | "last 1 chrome version", 54 | "last 1 firefox version", 55 | "last 1 safari version" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /mockApi/server.ts: -------------------------------------------------------------------------------- 1 | import jsonServer from "json-server"; 2 | import data from "./mockData"; 3 | import * as renderHelpers from "./renderHelpers"; 4 | import { getScenariosApplicableToEndpoint } from "./renderHelpers"; 5 | 6 | const server = jsonServer.create(); 7 | const router = jsonServer.router(data); 8 | const middlewares = jsonServer.defaults(); 9 | 10 | server.use(middlewares); 11 | 12 | server.use( 13 | jsonServer.rewriter({ 14 | "/api/*": "/$1", 15 | "/users/me": "/user", 16 | }) 17 | ); 18 | 19 | // @ts-ignore 20 | router.render = (req, res) => { 21 | const scenariosHeaderString = req.headers["scenarios"]; 22 | const scenariosFromHeader = scenariosHeaderString 23 | ? scenariosHeaderString.split(" ") 24 | : []; 25 | const url = renderHelpers.removeTrailingSlashes(req.originalUrl); 26 | 27 | let customResponse = renderHelpers.getCustomReponse(url, scenariosFromHeader); 28 | 29 | if (customResponse) { 30 | res.status(customResponse.httpStatus).jsonp(customResponse.respone); 31 | } else { 32 | let data = res.locals.data; 33 | 34 | if (url === "/api/quotes" && req.method === "GET") { 35 | data = data.map(renderHelpers.toQuoteSummary); 36 | } 37 | 38 | if (scenariosHeaderString && Array.isArray(data) && data.length > 0) { 39 | const scenariosApplicableToEndPoint = getScenariosApplicableToEndpoint( 40 | url, 41 | scenariosFromHeader 42 | ); 43 | 44 | const filteredByScenario = data.filter((d) => 45 | scenariosApplicableToEndPoint.every( 46 | (scenario) => d.scenarios && d.scenarios.includes(scenario) 47 | ) 48 | ); 49 | res.jsonp(filteredByScenario); 50 | } else res.jsonp(data); 51 | } 52 | }; 53 | 54 | server.use(router); 55 | 56 | server.listen(5000, () => { 57 | console.log("JSON Server is running"); 58 | }); 59 | -------------------------------------------------------------------------------- /ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /ui/src/views/routes/quotes/Quote.tsx: -------------------------------------------------------------------------------- 1 | import { QuoteDto, QuoteStatusCode } from 'api/api-models'; 2 | import * as QuotesApi from 'api/quotes.api'; 3 | import React, { useState } from 'react'; 4 | import { useParams } from 'react-router-dom'; 5 | import { LoadingPane } from 'views/components/application/LoadingPane'; 6 | import { PageLayout } from 'views/components/application/PageLayout'; 7 | import { CustomerSection } from './CustomerSection'; 8 | import styles from './Quote.module.scss'; 9 | import { QuoteStatus } from './QuoteStatus'; 10 | 11 | export interface IQuoteContext { 12 | quote?: QuoteDto; 13 | isLoading: boolean; 14 | } 15 | 16 | export const QuoteContext = React.createContext({ isLoading: true }); 17 | 18 | export const Quote: React.FC = () => { 19 | const { quoteId } = useParams(); 20 | const [isLoading, setLoading] = useState(true); 21 | const [quote, setQuote] = useState(undefined); 22 | 23 | const loadQuote = async (id: string) => { 24 | setQuote(await QuotesApi.loadQuote(id || '')); 25 | setLoading(false); 26 | }; 27 | 28 | React.useEffect(() => { 29 | !!quoteId ? loadQuote(quoteId) : setLoading(false); 30 | }, [quoteId]); 31 | 32 | const title = quoteId 33 | ? quote?.statusCode === QuoteStatusCode.Draft 34 | ? 'Draft Quote' 35 | : `Quote ${quote?.quoteNumber}` 36 | : 'New Quote'; 37 | 38 | const quoteStatus = quoteId && quote && ; 39 | 40 | const audit = quote ? ( 41 | 42 | Last Updated: 43 | {quote.lastModifiedAt.toLocaleString()} 44 | 45 | ) : null; 46 | 47 | return ( 48 | 49 | 54 | 55 |
56 | 57 |
58 |
59 |
60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /ui/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 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | -------------------------------------------------------------------------------- /ui/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ui/src/api/api-models.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | //---------------------- 4 | // 5 | // Generated using the NSwag toolchain v13.3.0.0 (NJsonSchema v10.1.11.0 (Newtonsoft.Json v11.0.0.0)) (http://NSwag.org) 6 | // 7 | //---------------------- 8 | // ReSharper disable InconsistentNaming 9 | 10 | export interface CustomerDto { 11 | name: string; 12 | address: string; 13 | phone?: string | undefined; 14 | email: string; 15 | } 16 | 17 | export interface QuoteDto { 18 | id: string; 19 | quoteNumber?: number; 20 | statusCode: QuoteStatusCode; 21 | lastModifiedAt: Date; 22 | customer: CustomerDto; 23 | mobilePhone?: QuoteMobilePhoneDto; 24 | accessories?: QuoteAccessoryDto[]; 25 | note?: string | undefined; 26 | } 27 | 28 | export enum QuoteStatusCode { 29 | Draft = 'Draft', 30 | Open = 'Open', 31 | Accepted = 'Accepted', 32 | Expired = 'Expired', 33 | } 34 | 35 | export interface QuoteMobilePhoneDto { 36 | serialNo: string; 37 | model: string; 38 | price: number; 39 | discount?: number | undefined; 40 | } 41 | 42 | export interface QuoteAccessoryDto { 43 | id: number; 44 | description: string; 45 | quantity: number; 46 | unitPrice: number; 47 | } 48 | 49 | export interface CreateQuoteResponse { 50 | id: string; 51 | } 52 | 53 | export interface CreateQuoteCommand { 54 | customerName: string; 55 | customerAddress: string; 56 | customerPhone?: string | undefined; 57 | customerEmail: string; 58 | } 59 | 60 | export interface UpdateQuoteCustomerCommand { 61 | quoteId: string; 62 | customerName: string; 63 | customerAddress: string; 64 | customerPhone?: string | undefined; 65 | customerEmail: string; 66 | } 67 | 68 | export interface UpdateQuoteMobilePhoneCommand { 69 | quoteId: string; 70 | serialNo: string; 71 | model: string; 72 | price: number; 73 | discount?: number | undefined; 74 | } 75 | 76 | export interface QuoteSummaryDto { 77 | id: string; 78 | quoteNumber?: number; 79 | statusCode: QuoteStatusCode; 80 | lastModifiedAt: Date; 81 | customerName: string; 82 | mobilePhoneDescription?: string; 83 | } 84 | 85 | export interface UpsertQuoteAccessoriesCommand { 86 | quoteId: string; 87 | accessories?: QuoteAccessoryDto[]; 88 | } 89 | 90 | export interface UpdateQuoteNoteCommand { 91 | quoteId: string; 92 | note?: string | undefined; 93 | } 94 | 95 | export interface AcceptQuoteCommand { 96 | quoteId: string; 97 | } 98 | 99 | export interface UserProfileDto { 100 | id: string; 101 | name: string; 102 | userPictureUrl: string | undefined; 103 | } 104 | -------------------------------------------------------------------------------- /mockApi/api-models.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | //---------------------- 4 | // 5 | // Generated using the NSwag toolchain v13.3.0.0 (NJsonSchema v10.1.11.0 (Newtonsoft.Json v11.0.0.0)) (http://NSwag.org) 6 | // 7 | //---------------------- 8 | // ReSharper disable InconsistentNaming 9 | 10 | export interface CustomerDto { 11 | name: string; 12 | address: string | undefined; 13 | phone?: string | undefined; 14 | email: string | undefined; 15 | } 16 | 17 | export interface QuoteDto { 18 | id: string; 19 | quoteNumber?: number; 20 | statusCode: QuoteStatusCode; 21 | lastModifiedAt: Date; 22 | customer: CustomerDto; 23 | mobilePhone?: QuoteMobilePhoneDto; 24 | accessories?: QuoteAccessoryDto[]; 25 | note?: string | undefined; 26 | } 27 | 28 | export enum QuoteStatusCode { 29 | Draft = 'Draft', 30 | Open = 'Open', 31 | Accepted = 'Accepted', 32 | Expired = 'Expired', 33 | } 34 | 35 | export interface QuoteMobilePhoneDto { 36 | serialNo: string; 37 | model: string; 38 | price: number; 39 | discount?: number | undefined; 40 | } 41 | 42 | export interface QuoteAccessoryDto { 43 | id: number; 44 | description: string; 45 | quantity: number; 46 | unitPrice: number; 47 | } 48 | 49 | export interface AddQuoteResponse { 50 | id: string; 51 | } 52 | 53 | export interface AddQuoteCommand { 54 | customerName: string; 55 | customerAddress: string; 56 | customerPhone?: string | undefined; 57 | customerEmail: string; 58 | mobilePhoneMake?: string | undefined; 59 | mobilePhoneModel?: string | undefined; 60 | price?: number | undefined; 61 | discount?: number | undefined; 62 | } 63 | 64 | export interface UpdateQuoteCustomerCommand { 65 | quoteId: string; 66 | customerName: string; 67 | customerAddress: string; 68 | customerPhone?: string | undefined; 69 | customerEmail: string; 70 | } 71 | 72 | export interface UpdateQuoteMobilePhoneCommand { 73 | quoteId: string; 74 | serialNo: string; 75 | model: string; 76 | price: number; 77 | discount?: number | undefined; 78 | } 79 | 80 | export interface QuoteSummaryDto { 81 | id: string; 82 | quoteNumber?: number; 83 | statusCode: QuoteStatusCode; 84 | lastModifiedAt: Date; 85 | customerName: string; 86 | mobilePhoneDescription?: string; 87 | } 88 | 89 | export interface UpsertQuoteAccessoriesCommand { 90 | quoteId: string; 91 | accessories?: QuoteAccessoryDto[]; 92 | } 93 | 94 | export interface UpdateQuoteNoteCommand { 95 | quoteId: string; 96 | note?: string | undefined; 97 | } 98 | 99 | export interface AcceptQuoteCommand { 100 | quoteId: string; 101 | } 102 | 103 | export interface UserProfileDto { 104 | id: string; 105 | name: string; 106 | userPictureUrl: string | undefined; 107 | } 108 | -------------------------------------------------------------------------------- /ui/cypress/integration/quotes/quotes.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Quotes', () => { 2 | describe('Mock Api', () => { 3 | it('Loads home page', () => { 4 | cy.setScenarios('draft'); 5 | cy.visit('/'); 6 | cy.findByText('All Quotes'); 7 | cy.findByText('Create Quote'); 8 | }); 9 | 10 | it('No quotes shows empty message', () => { 11 | cy.setScenarios('noquotes'); 12 | cy.visit('/'); 13 | cy.get('[data-cy=noquotes]'); 14 | }); 15 | 16 | it('Error getting quotes shows error message', () => { 17 | cy.setScenarios('error'); 18 | cy.visit('/'); 19 | cy.get('.Toastify'); 20 | cy.findByText('Unable to get data.'); 21 | }); 22 | }); 23 | 24 | describe('Fixture Data', () => { 25 | it('Renders quotes as expected', () => { 26 | cy.server(); 27 | cy.fixture('quotes/quotes.json') 28 | .as('quotes') 29 | .then(quotes => { 30 | cy.route('GET', '/api/quotes', '@quotes'); 31 | cy.visit('/'); 32 | const renderedQuotes = cy.get('tbody > tr'); 33 | renderedQuotes.should('have.length', quotes.length); 34 | renderedQuotes.each((renderedQuote, index) => { 35 | cy.wrap(renderedQuote).within(() => { 36 | const quote = quotes[index]; 37 | 38 | cy.get('[data-cy=quoteNumber]') 39 | .invoke('text') 40 | .should('eq', quote.quoteNumber || ''); 41 | 42 | cy.get('[data-cy=customerName]') 43 | .invoke('text') 44 | .should('eq', quote.customerName || ''); 45 | 46 | cy.get('[data-cy=mobilePhoneDescription]') 47 | .invoke('text') 48 | .should('eq', quote.mobilePhoneDescription || ''); 49 | cy.get('[data-cy=statusCode]') 50 | .invoke('text') 51 | .should('eq', quote.statusCode || ''); 52 | cy.get('[data-cy=lastModifiedAt]') 53 | .invoke('text') 54 | .should('eq', quote.lastModifiedAt); 55 | }); 56 | }); 57 | }); 58 | }); 59 | 60 | it('Draft Quote Status is Grey', () => { 61 | assertStatusHasColor('Draft', 'rgb(128, 128, 128)'); 62 | }); 63 | 64 | it('Open Quote Status is Orange', () => { 65 | assertStatusHasColor('Open', 'rgb(255, 165, 0)'); 66 | }); 67 | 68 | it('Accepted Quote Status is SeaGreen', () => { 69 | assertStatusHasColor('Accepted', 'rgb(46, 139, 87)'); 70 | }); 71 | 72 | it('Expired Quote Status is Red', () => { 73 | assertStatusHasColor('Expired', 'rgb(255, 0, 0)'); 74 | }); 75 | }); 76 | }); 77 | 78 | function assertStatusHasColor(status, color) { 79 | cy.server(); 80 | cy.fixture('quotes/quotes.json') 81 | .as('quotes') 82 | .then(quotes => { 83 | cy.route('GET', '/api/quotes', '@quotes'); 84 | cy.visit('/'); 85 | cy.findAllByText(status) 86 | .first() 87 | .parent() 88 | .should('have.css', 'background-color') 89 | .and('eq', color); 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /ui/src/views/routes/quotes/QuotesList.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Table, TableBody, TableCell, TableHead, TableRow } from '@material-ui/core'; 2 | import { QuoteSummaryDto } from 'api/api-models'; 3 | import * as QuotesApi from 'api/quotes.api'; 4 | import React, { useEffect, useState } from 'react'; 5 | import { Link, useHistory } from 'react-router-dom'; 6 | import { LoadingPane } from 'views/components/application/LoadingPane'; 7 | import { PageLayout } from 'views/components/application/PageLayout'; 8 | import styles from './QuotesList.module.scss'; 9 | import { QuoteStatus } from './QuoteStatus'; 10 | 11 | export const QuotesList: React.FC = () => { 12 | const history = useHistory(); 13 | const [quotes, setQuotes] = useState([]); 14 | const loadQuotes = async () => { 15 | setQuotes(await QuotesApi.loadAllQuotes()); 16 | }; 17 | 18 | useEffect(() => { 19 | loadQuotes(); 20 | }, []); 21 | 22 | return ( 23 | 24 |
25 | 33 |
34 | 35 | 36 | 37 | 38 | Quote # 39 | Customer Name 40 | Mobile Phone Description 41 | Status 42 | Last Modified 43 | 44 | 45 | 46 | {quotes.map(q => { 47 | const openQuote = () => history.push(`/quotes/${q.id}`); 48 | return ( 49 | 50 | 51 | {q.quoteNumber} 52 | 53 | 54 | {q.customerName} 55 | 56 | 57 | {q.mobilePhoneDescription} 58 | 59 | 60 | 61 | 62 | 63 | {q.lastModifiedAt.toLocaleString()} 64 | 65 | 66 | ); 67 | })} 68 | 69 |
70 | {quotes.length === 0 && ( 71 |

72 | There are no matching Quotes. 73 |

74 | )} 75 |
76 |
77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /ui/src/views/routes/quotes/QuoteSection.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | ExpansionPanel, 4 | ExpansionPanelActions, 5 | ExpansionPanelDetails, 6 | ExpansionPanelSummary, 7 | Typography, 8 | } from '@material-ui/core'; 9 | import AddIcon from '@material-ui/icons/Add'; 10 | import CloseIcon from '@material-ui/icons/Close'; 11 | import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; 12 | import React from 'react'; 13 | import { FormContextValues } from 'react-hook-form'; 14 | import styles from './QuoteSection.module.scss'; 15 | 16 | export interface IQuoteSectionProps { 17 | sectionTitle: string; 18 | sectionSummary?: string; 19 | isEmpty: boolean; 20 | expandedDefault: boolean; 21 | editableDefault: boolean; 22 | onSubmit: (data: T) => void | Promise; 23 | formMethods: FormContextValues; 24 | children: (editable: boolean) => React.ReactNode; 25 | } 26 | 27 | export function QuoteSection({ 28 | sectionTitle, 29 | sectionSummary, 30 | isEmpty, 31 | expandedDefault, 32 | editableDefault, 33 | onSubmit, 34 | formMethods, 35 | children, 36 | }: IQuoteSectionProps) { 37 | const { handleSubmit, reset } = formMethods; 38 | const formSubmit = async (data: T) => { 39 | await onSubmit(data); 40 | setEditable(false); 41 | }; 42 | 43 | const [expanded, setExpanded] = React.useState(expandedDefault); 44 | const [editable, setEditable] = React.useState(editableDefault); 45 | 46 | const IconComponent = !isEmpty ? ExpandMoreIcon : expanded ? CloseIcon : AddIcon; 47 | const icon = ; 48 | 49 | return ( 50 |
51 | { 54 | setExpanded(e); 55 | if (editable) { 56 | reset(); 57 | setEditable(false); 58 | } 59 | }}> 60 | 61 | 62 | {sectionTitle} 63 | 64 | {sectionSummary && ( 65 | <> 66 | 73 | {children(editable)} 74 | 75 | {editable ? ( 76 | <> 77 | 80 |