├── .all-contributorsrc ├── .circleci └── config.yml ├── .env ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .node-version ├── .prettierignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── .yarnrc ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── backend ├── app.ts ├── auth.ts ├── bankaccount-routes.ts ├── banktransfer-routes.ts ├── comment-routes.ts ├── contact-routes.ts ├── database.ts ├── helpers.ts ├── like-routes.ts ├── notification-routes.ts ├── testdata-routes.ts ├── transaction-routes.ts ├── types.ts ├── user-routes.ts └── validators.ts ├── buildspec-types-unit.yml ├── buildspec.yml ├── codecov.yml ├── cypress.json ├── cypress ├── fixtures │ └── public-transactions.json ├── global.d.ts ├── plugins │ └── index.ts ├── support │ ├── commands.ts │ ├── index.ts │ └── utils.ts ├── tests │ ├── api │ │ ├── api-bankaccounts.spec.ts │ │ ├── api-banktransfers.spec.ts │ │ ├── api-comments.spec.ts │ │ ├── api-contacts.spec.ts │ │ ├── api-likes.spec.ts │ │ ├── api-notifications.spec.ts │ │ ├── api-testdata.spec.ts │ │ ├── api-transactions.spec.ts │ │ └── api-users.spec.ts │ ├── demo │ │ └── cypress-studio.spec.ts │ └── ui │ │ ├── auth.spec.ts │ │ ├── bankaccounts.spec.ts │ │ ├── new-transaction.spec.ts │ │ ├── notifications.spec.ts │ │ ├── transaction-feeds.spec.ts │ │ ├── transaction-view.spec.ts │ │ └── user-settings.spec.ts └── tsconfig.json ├── data ├── database-seed.json ├── database.json └── empty-seed.json ├── index.html ├── package.json ├── public ├── favicon.ico ├── img │ └── rwa-readme-screenshot.png ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── renovate.json ├── sandbox.config.json ├── scripts ├── generateSeedData.ts ├── seedDataUtils.ts ├── testServer.ts └── tsconfig.json ├── src ├── __tests__ │ ├── bankaccounts.test.ts │ ├── comments.test.ts │ ├── contacts.test.ts │ ├── generateSeedData.test.ts │ ├── likes.test.ts │ ├── notifications.test.ts │ ├── transactions.test.ts │ └── users.test.ts ├── components │ ├── AlertBar.tsx │ ├── BankAccountForm.tsx │ ├── BankAccountItem.tsx │ ├── BankAccountList.tsx │ ├── CommentForm.tsx │ ├── CommentList.tsx │ ├── CommentListItem.tsx │ ├── EmptyList.tsx │ ├── Footer.tsx │ ├── MainLayout.tsx │ ├── NavBar.tsx │ ├── NavDrawer.tsx │ ├── NotificationList.tsx │ ├── NotificationListItem.tsx │ ├── PrivateRoute.tsx │ ├── SignInForm.tsx │ ├── SignUpForm.tsx │ ├── SkeletonList.tsx │ ├── TransactionAmount.tsx │ ├── TransactionContactsList.tsx │ ├── TransactionCreateStepOne.tsx │ ├── TransactionCreateStepThree.tsx │ ├── TransactionCreateStepTwo.tsx │ ├── TransactionDateRangeFilter.tsx │ ├── TransactionDetail.tsx │ ├── TransactionInfiniteList.tsx │ ├── TransactionItem.tsx │ ├── TransactionList.tsx │ ├── TransactionListAmountRangeFilter.tsx │ ├── TransactionListFilters.tsx │ ├── TransactionNavTabs.tsx │ ├── TransactionPersonalList.tsx │ ├── TransactionPublicList.tsx │ ├── TransactionTitle.tsx │ ├── UserListItem.tsx │ ├── UserListSearchForm.tsx │ ├── UserSettingsForm.tsx │ └── UsersList.tsx ├── containers │ ├── App.tsx │ ├── BankAccountsContainer.tsx │ ├── NotificationsContainer.tsx │ ├── PrivateRoutesContainer.tsx │ ├── TransactionCreateContainer.tsx │ ├── TransactionDetailContainer.tsx │ ├── TransactionsContainer.tsx │ ├── UserOnboardingContainer.tsx │ └── UserSettingsContainer.tsx ├── index.tsx ├── machines │ ├── authMachine.ts │ ├── bankAccountsMachine.ts │ ├── contactsTransactionsMachine.ts │ ├── createTransactionMachine.ts │ ├── dataMachine.ts │ ├── drawerMachine.ts │ ├── notificationsMachine.ts │ ├── personalTransactionsMachine.ts │ ├── publicTransactionsMachine.ts │ ├── snackbarMachine.ts │ ├── transactionDetailMachine.ts │ ├── transactionFiltersMachine.ts │ ├── userOnboardingMachine.ts │ └── usersMachine.ts ├── models │ ├── bankaccount.ts │ ├── banktransfer.ts │ ├── comment.ts │ ├── contact.ts │ ├── db-schema.ts │ ├── index.ts │ ├── like.ts │ ├── notification.ts │ ├── transaction.ts │ └── user.ts ├── react-app-env.d.ts ├── setupProxy.js ├── svgs │ ├── built-by-cypress.svg │ ├── cypress-logo.svg │ ├── rwa-icon-logo.svg │ ├── rwa-logo.svg │ ├── undraw_navigator_a479.svg │ ├── undraw_personal_finance_tqcd.svg │ ├── undraw_personal_settings_kihd.svg │ ├── undraw_reminders_697p.svg │ └── undraw_transfer_money_rywa.svg └── utils │ ├── __tests__ │ └── transactionUtils.test.ts │ ├── asyncUtils.ts │ ├── historyUtils.ts │ └── transactionUtils.ts ├── tsconfig.json ├── tsconfig.tsnode.json ├── vite.config.js └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [{ 8 | "login": "kevinold", 9 | "name": "Kevin Old", 10 | "avatar_url": "https://avatars0.githubusercontent.com/u/21967?v=4", 11 | "profile": "http://www.kevinold.com", 12 | "contributions": [] 13 | }, 14 | { 15 | "login": "amirrustam", 16 | "name": "Amir Rustamzadeh", 17 | "avatar_url": "https://avatars0.githubusercontent.com/u/334337?v=4", 18 | "profile": "https://twitter.com/amirrustam", 19 | "contributions": [] 20 | }, 21 | { 22 | "login": "brian-mann", 23 | "name": "Brian Mann", 24 | "avatar_url": "https://avatars2.githubusercontent.com/u/1268976?v=4", 25 | "profile": "https://cypress.io", 26 | "contributions": [] 27 | }, 28 | { 29 | "login": "bahmutov", 30 | "name": "Gleb Bahmutov", 31 | "avatar_url": "https://avatars1.githubusercontent.com/u/2212006?v=4", 32 | "profile": "https://glebbahmutov.com/", 33 | "contributions": [] 34 | }, 35 | { 36 | "login": "bencodezen", 37 | "name": "Ben Hong", 38 | "avatar_url": "https://avatars0.githubusercontent.com/u/4836334?v=4", 39 | "profile": "http://www.bencodezen.io", 40 | "contributions": [] 41 | }, 42 | { 43 | "login": "davidkpiano", 44 | "name": "David Khourshid", 45 | "avatar_url": "https://avatars2.githubusercontent.com/u/1093738?v=4", 46 | "profile": "https://github.com/davidkpiano", 47 | "contributions": [] 48 | } 49 | ], 50 | "contributorsPerLine": 7, 51 | "projectName": "cypress-realworld-app", 52 | "projectOwner": "cypress-io", 53 | "repoType": "github", 54 | "repoHost": "https://github.com", 55 | "skipCi": true 56 | } -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | SEED_USERBASE_SIZE=5 2 | SEED_CONTACTS_PER_USER=3 3 | SEED_PAYMENTS_PER_USER=15 4 | SEED_REQUESTS_PER_USER=10 5 | SEED_BANK_ACCOUNTS_PER_USER=1 6 | SEED_LIKES_PER_USER=2 7 | SEED_COMMENTS_PER_USER=2 8 | SEED_NOTIFICATIONS_PER_USER=5 9 | SEED_BANK_TRANSFERS_PER_USER=5 10 | SEED_DEFAULT_USER_PASSWORD=s3cret 11 | PAGINATION_PAGE_SIZE=10 -------------------------------------------------------------------------------- /.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 | .idea 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | cypress/videos 23 | cypress/screenshots 24 | .nyc_output 25 | 26 | dist -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 12.20 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | data 2 | build 3 | coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug CRA Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/react-scripts", 9 | "args": ["test", "--runInBand", "--no-cache", "--env=jsdom"], 10 | "cwd": "${workspaceRoot}", 11 | "protocol": "inspector", 12 | "console": "integratedTerminal", 13 | "internalConsoleOptions": "neverOpen" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | network-timeout 500000 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [hello@cypress.io](mailto:hello@cypress.io). All 38 | complaints will be reviewed and investigated and will result in a response that 39 | is deemed necessary and appropriate to the circumstances. The project team is 40 | obligated to maintain confidentiality with regard to the reporter of an incident. 41 | Further details of specific enforcement policies may be posted separately. 42 | 43 | Project maintainers who do not follow or enforce the Code of Conduct in good 44 | faith may face temporary or permanent repercussions as determined by other 45 | members of the project's leadership. 46 | 47 | ## Attribution 48 | 49 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 50 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 51 | 52 | [homepage]: https://www.contributor-covenant.org 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2020 Cypress.io 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /backend/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import path from "path"; 3 | import logger from "morgan"; 4 | import passport from "passport"; 5 | import session from "express-session"; 6 | import bodyParser from "body-parser"; 7 | import cors from "cors"; 8 | import paginate from "express-paginate"; 9 | 10 | import auth from "./auth"; 11 | import userRoutes from "./user-routes"; 12 | import contactRoutes from "./contact-routes"; 13 | import bankAccountRoutes from "./bankaccount-routes"; 14 | import transactionRoutes from "./transaction-routes"; 15 | import likeRoutes from "./like-routes"; 16 | import commentRoutes from "./comment-routes"; 17 | import notificationRoutes from "./notification-routes"; 18 | import bankTransferRoutes from "./banktransfer-routes"; 19 | import testDataRoutes from "./testdata-routes"; 20 | 21 | require("dotenv").config(); 22 | 23 | const corsOption = { 24 | origin: "http://localhost:3000", 25 | credentials: true, 26 | }; 27 | 28 | const app = express(); 29 | 30 | /* istanbul ignore next */ 31 | // @ts-ignore 32 | if (global.__coverage__) { 33 | require("@cypress/code-coverage/middleware/express")(app); 34 | } 35 | 36 | app.use(cors(corsOption)); 37 | app.use(logger("dev")); 38 | app.use(bodyParser.urlencoded({ extended: false })); 39 | app.use(bodyParser.json()); 40 | 41 | app.use( 42 | session({ 43 | secret: "session secret", 44 | resave: false, 45 | saveUninitialized: false, 46 | unset: "destroy", 47 | }) 48 | ); 49 | app.use(passport.initialize()); 50 | app.use(passport.session()); 51 | 52 | app.use(paginate.middleware(+process.env.PAGINATION_PAGE_SIZE!)); 53 | 54 | app.use(auth); 55 | app.use("/users", userRoutes); 56 | app.use("/contacts", contactRoutes); 57 | app.use("/bankAccounts", bankAccountRoutes); 58 | app.use("/transactions", transactionRoutes); 59 | app.use("/likes", likeRoutes); 60 | app.use("/comments", commentRoutes); 61 | app.use("/notifications", notificationRoutes); 62 | app.use("/bankTransfers", bankTransferRoutes); 63 | 64 | /* istanbul ignore next */ 65 | if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") { 66 | app.use("/testData", testDataRoutes); 67 | } 68 | 69 | app.use(express.static(path.join(__dirname, "../public"))); 70 | 71 | app.listen(3001); 72 | -------------------------------------------------------------------------------- /backend/auth.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcryptjs"; 2 | import passport from "passport"; 3 | import express, { Request, Response } from "express"; 4 | import { User } from "../src/models/user"; 5 | import { getUserBy, getUserById } from "./database"; 6 | 7 | const LocalStrategy = require("passport-local").Strategy; 8 | const router = express.Router(); 9 | 10 | // configure passport for local strategy 11 | passport.use( 12 | new LocalStrategy(function (username: string, password: string, done: Function) { 13 | const user = getUserBy("username", username); 14 | 15 | const failureMessage = "Incorrect username or password."; 16 | if (!user) { 17 | return done(null, false, { message: failureMessage }); 18 | } 19 | 20 | // validate password 21 | if (!bcrypt.compareSync(password, user.password)) { 22 | return done(null, false, { message: failureMessage }); 23 | } 24 | 25 | return done(null, user); 26 | }) 27 | ); 28 | 29 | passport.serializeUser(function (user: User, done) { 30 | done(null, user.id); 31 | }); 32 | 33 | passport.deserializeUser(function (id: string, done) { 34 | const user = getUserById(id); 35 | done(null, user); 36 | }); 37 | 38 | // authentication routes 39 | router.post("/login", passport.authenticate("local"), (req: Request, res: Response): void => { 40 | if (req.body.remember) { 41 | req.session!.cookie.maxAge = 24 * 60 * 60 * 1000 * 30; // Expire in 30 days 42 | } else { 43 | req.session!.cookie.expires = undefined; 44 | } 45 | 46 | res.send({ user: req.user }); 47 | }); 48 | 49 | router.post("/logout", (req: Request, res: Response): void => { 50 | res.clearCookie("connect.sid"); 51 | req.logout(); 52 | req.session!.destroy(function (err) { 53 | res.redirect("/"); 54 | }); 55 | }); 56 | 57 | router.get("/checkAuth", (req, res) => { 58 | /* istanbul ignore next */ 59 | if (!req.user) { 60 | res.status(401).json({ error: "User is unauthorized" }); 61 | } else { 62 | res.status(200).json({ user: req.user }); 63 | } 64 | }); 65 | 66 | export default router; 67 | -------------------------------------------------------------------------------- /backend/bankaccount-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | 5 | import { 6 | getBankAccountsByUserId, 7 | getBankAccountById, 8 | createBankAccountForUser, 9 | removeBankAccountById, 10 | } from "./database"; 11 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 12 | import { shortIdValidation, isBankAccountValidator } from "./validators"; 13 | const router = express.Router(); 14 | 15 | // Routes 16 | 17 | //GET /bankAccounts (scoped-user) 18 | router.get("/", ensureAuthenticated, (req, res) => { 19 | /* istanbul ignore next */ 20 | const accounts = getBankAccountsByUserId(req.user?.id!); 21 | 22 | res.status(200); 23 | res.json({ results: accounts }); 24 | }); 25 | 26 | //GET /bankAccounts/:bankAccountId (scoped-user) 27 | router.get( 28 | "/:bankAccountId", 29 | ensureAuthenticated, 30 | validateMiddleware([shortIdValidation("bankAccountId")]), 31 | (req, res) => { 32 | const { bankAccountId } = req.params; 33 | 34 | const account = getBankAccountById(bankAccountId); 35 | 36 | res.status(200); 37 | res.json({ account }); 38 | } 39 | ); 40 | 41 | //POST /bankAccounts (scoped-user) 42 | router.post("/", ensureAuthenticated, validateMiddleware(isBankAccountValidator), (req, res) => { 43 | /* istanbul ignore next */ 44 | const account = createBankAccountForUser(req.user?.id!, req.body); 45 | 46 | res.status(200); 47 | res.json({ account }); 48 | }); 49 | 50 | //DELETE (soft) /bankAccounts (scoped-user) 51 | router.delete( 52 | "/:bankAccountId", 53 | ensureAuthenticated, 54 | validateMiddleware([shortIdValidation("bankAccountId")]), 55 | (req, res) => { 56 | const { bankAccountId } = req.params; 57 | 58 | const account = removeBankAccountById(bankAccountId); 59 | 60 | res.status(200); 61 | res.json({ account }); 62 | } 63 | ); 64 | 65 | export default router; 66 | -------------------------------------------------------------------------------- /backend/banktransfer-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | 5 | import { getBankTransfersByUserId } from "./database"; 6 | import { ensureAuthenticated } from "./helpers"; 7 | const router = express.Router(); 8 | 9 | // Routes 10 | 11 | //GET /bankTransfers (scoped-user) 12 | router.get("/", ensureAuthenticated, (req, res) => { 13 | /* istanbul ignore next */ 14 | const transfers = getBankTransfersByUserId(req.user?.id!); 15 | 16 | res.status(200); 17 | res.json({ transfers }); 18 | }); 19 | 20 | export default router; 21 | -------------------------------------------------------------------------------- /backend/comment-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | import { getCommentsByTransactionId, createComments } from "./database"; 5 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 6 | import { shortIdValidation, isCommentValidator } from "./validators"; 7 | const router = express.Router(); 8 | 9 | // Routes 10 | 11 | //GET /comments/:transactionId 12 | router.get( 13 | "/:transactionId", 14 | ensureAuthenticated, 15 | validateMiddleware([shortIdValidation("transactionId")]), 16 | (req, res) => { 17 | const { transactionId } = req.params; 18 | const comments = getCommentsByTransactionId(transactionId); 19 | 20 | res.status(200); 21 | res.json({ comments }); 22 | } 23 | ); 24 | 25 | //POST /comments/:transactionId 26 | router.post( 27 | "/:transactionId", 28 | ensureAuthenticated, 29 | validateMiddleware([shortIdValidation("transactionId"), isCommentValidator]), 30 | (req, res) => { 31 | const { transactionId } = req.params; 32 | const { content } = req.body; 33 | 34 | /* istanbul ignore next */ 35 | createComments(req.user?.id!, transactionId, content); 36 | 37 | res.sendStatus(200); 38 | } 39 | ); 40 | 41 | export default router; 42 | -------------------------------------------------------------------------------- /backend/contact-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | 5 | import { getContactsByUsername, removeContactById, createContactForUser } from "./database"; 6 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 7 | import { shortIdValidation } from "./validators"; 8 | const router = express.Router(); 9 | 10 | // Routes 11 | //GET /contacts/:username 12 | router.get("/:username", (req, res) => { 13 | const { username } = req.params; 14 | 15 | const contacts = getContactsByUsername(username); 16 | 17 | res.status(200); 18 | res.json({ contacts }); 19 | }); 20 | 21 | //POST /contacts (scoped-user) 22 | router.post( 23 | "/", 24 | ensureAuthenticated, 25 | validateMiddleware([shortIdValidation("contactUserId")]), 26 | (req, res) => { 27 | const { contactUserId } = req.body; 28 | /* istanbul ignore next */ 29 | const contact = createContactForUser(req.user?.id!, contactUserId); 30 | 31 | res.status(200); 32 | res.json({ contact }); 33 | } 34 | ); 35 | //DELETE /contacts/:contactId (scoped-user) 36 | router.delete( 37 | "/:contactId", 38 | ensureAuthenticated, 39 | validateMiddleware([shortIdValidation("contactId")]), 40 | (req, res) => { 41 | const { contactId } = req.params; 42 | 43 | const contacts = removeContactById(contactId); 44 | 45 | res.status(200); 46 | res.json({ contacts }); 47 | } 48 | ); 49 | 50 | export default router; 51 | -------------------------------------------------------------------------------- /backend/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { validationResult } from "express-validator"; 3 | export const ensureAuthenticated = (req: Request, res: Response, next: NextFunction) => { 4 | if (req.isAuthenticated()) { 5 | return next(); 6 | } 7 | /* istanbul ignore next */ 8 | res.status(401).send({ 9 | error: "Unauthorized", 10 | }); 11 | }; 12 | 13 | export const validateMiddleware = (validations: any[]) => { 14 | return async (req: Request, res: Response, next: NextFunction) => { 15 | await Promise.all(validations.map((validation: any) => validation.run(req))); 16 | 17 | const errors = validationResult(req); 18 | if (errors.isEmpty()) { 19 | return next(); 20 | } 21 | 22 | res.status(422).json({ errors: errors.array() }); 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /backend/like-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | import { getLikesByTransactionId, createLikes } from "./database"; 5 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 6 | import { shortIdValidation } from "./validators"; 7 | const router = express.Router(); 8 | 9 | // Routes 10 | 11 | //GET /likes/:transactionId 12 | router.get( 13 | "/:transactionId", 14 | ensureAuthenticated, 15 | validateMiddleware([shortIdValidation("transactionId")]), 16 | (req, res) => { 17 | const { transactionId } = req.params; 18 | const likes = getLikesByTransactionId(transactionId); 19 | 20 | res.status(200); 21 | res.json({ likes }); 22 | } 23 | ); 24 | 25 | //POST /likes/:transactionId 26 | router.post( 27 | "/:transactionId", 28 | ensureAuthenticated, 29 | validateMiddleware([shortIdValidation("transactionId")]), 30 | (req, res) => { 31 | const { transactionId } = req.params; 32 | /* istanbul ignore next */ 33 | createLikes(req.user?.id!, transactionId); 34 | 35 | res.sendStatus(200); 36 | } 37 | ); 38 | 39 | export default router; 40 | -------------------------------------------------------------------------------- /backend/notification-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | import { 5 | createNotifications, 6 | updateNotificationById, 7 | getUnreadNotificationsByUserId, 8 | } from "./database"; 9 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 10 | import { 11 | isNotificationsBodyValidator, 12 | shortIdValidation, 13 | isNotificationPatchValidator, 14 | } from "./validators"; 15 | const router = express.Router(); 16 | 17 | // Routes 18 | 19 | //GET /notifications/ 20 | router.get("/", ensureAuthenticated, (req, res) => { 21 | /* istanbul ignore next */ 22 | const notifications = getUnreadNotificationsByUserId(req.user?.id!); 23 | 24 | res.status(200); 25 | res.json({ results: notifications }); 26 | }); 27 | 28 | //POST /notifications/bulk 29 | router.post( 30 | "/bulk", 31 | ensureAuthenticated, 32 | validateMiddleware([...isNotificationsBodyValidator]), 33 | (req, res) => { 34 | const { items } = req.body; 35 | /* istanbul ignore next */ 36 | const notifications = createNotifications(req.user?.id!, items); 37 | 38 | res.status(200); 39 | // @ts-ignore 40 | res.json({ results: notifications }); 41 | } 42 | ); 43 | 44 | //PATCH /notifications/:notificationId - scoped-user 45 | router.patch( 46 | "/:notificationId", 47 | ensureAuthenticated, 48 | validateMiddleware([shortIdValidation("notificationId"), ...isNotificationPatchValidator]), 49 | (req, res) => { 50 | const { notificationId } = req.params; 51 | /* istanbul ignore next */ 52 | updateNotificationById(req.user?.id!, notificationId, req.body); 53 | 54 | res.sendStatus(204); 55 | } 56 | ); 57 | 58 | export default router; 59 | -------------------------------------------------------------------------------- /backend/testdata-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | import { getAllForEntity, seedDatabase } from "./database"; 5 | import { validateMiddleware } from "./helpers"; 6 | import { isValidEntityValidator } from "./validators"; 7 | import { DbSchema } from "../src/models/db-schema"; 8 | const router = express.Router(); 9 | 10 | // Routes 11 | 12 | //POST /testData/seed 13 | router.post("/seed", (req, res) => { 14 | seedDatabase(); 15 | res.sendStatus(200); 16 | }); 17 | 18 | //GET /testData/:entity 19 | router.get("/:entity", validateMiddleware([...isValidEntityValidator]), (req, res) => { 20 | const { entity } = req.params; 21 | const results = getAllForEntity(entity as keyof DbSchema); 22 | 23 | res.status(200); 24 | res.json({ results }); 25 | }); 26 | 27 | export default router; 28 | -------------------------------------------------------------------------------- /backend/types.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | import { User as IUser } from "../src/models/user"; 3 | 4 | declare global { 5 | // eslint-disable-next-line @typescript-eslint/no-namespace 6 | namespace Express { 7 | interface User extends IUser {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/user-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | import { isEqual, pick } from "lodash/fp"; 5 | 6 | import { 7 | getAllUsers, 8 | createUser, 9 | updateUserById, 10 | getUserById, 11 | getUserByUsername, 12 | searchUsers, 13 | removeUserFromResults, 14 | } from "./database"; 15 | import { User } from "../src/models/user"; 16 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 17 | import { 18 | shortIdValidation, 19 | searchValidation, 20 | userFieldsValidator, 21 | isUserValidator, 22 | } from "./validators"; 23 | const router = express.Router(); 24 | 25 | // Routes 26 | router.get("/", ensureAuthenticated, (req, res) => { 27 | /* istanbul ignore next */ 28 | const users = removeUserFromResults(req.user?.id!, getAllUsers()); 29 | res.status(200).json({ results: users }); 30 | }); 31 | 32 | router.get("/search", ensureAuthenticated, validateMiddleware([searchValidation]), (req, res) => { 33 | const { q } = req.query; 34 | 35 | /* istanbul ignore next */ 36 | const users = removeUserFromResults(req.user?.id!, searchUsers(q)); 37 | 38 | res.status(200).json({ results: users }); 39 | }); 40 | 41 | router.post("/", userFieldsValidator, validateMiddleware(isUserValidator), (req, res) => { 42 | const userDetails: User = req.body; 43 | 44 | const user = createUser(userDetails); 45 | 46 | res.status(201); 47 | res.json({ user: user }); 48 | }); 49 | 50 | router.get( 51 | "/:userId", 52 | ensureAuthenticated, 53 | validateMiddleware([shortIdValidation("userId")]), 54 | (req, res) => { 55 | const { userId } = req.params; 56 | 57 | // Permission: account owner 58 | /* istanbul ignore next */ 59 | if (!isEqual(userId, req.user?.id)) { 60 | return res.status(401).send({ 61 | error: "Unauthorized", 62 | }); 63 | } 64 | 65 | const user = getUserById(userId); 66 | 67 | res.status(200); 68 | res.json({ user }); 69 | } 70 | ); 71 | 72 | router.get("/profile/:username", (req, res) => { 73 | const { username } = req.params; 74 | 75 | const user = pick(["firstName", "lastName", "avatar"], getUserByUsername(username)); 76 | 77 | res.status(200); 78 | res.json({ user }); 79 | }); 80 | 81 | router.patch( 82 | "/:userId", 83 | ensureAuthenticated, 84 | userFieldsValidator, 85 | validateMiddleware([shortIdValidation("userId"), ...isUserValidator]), 86 | (req, res) => { 87 | const { userId } = req.params; 88 | 89 | const edits: User = req.body; 90 | 91 | updateUserById(userId, edits); 92 | 93 | res.sendStatus(204); 94 | } 95 | ); 96 | 97 | export default router; 98 | -------------------------------------------------------------------------------- /backend/validators.ts: -------------------------------------------------------------------------------- 1 | import { body, check, oneOf, query, sanitizeQuery } from "express-validator"; 2 | import { isValid } from "shortid"; 3 | import { 4 | TransactionStatus, 5 | TransactionRequestStatus, 6 | DefaultPrivacyLevel, 7 | NotificationsType, 8 | } from "../src/models"; 9 | import { includes } from "lodash/fp"; 10 | 11 | const TransactionStatusValues = Object.values(TransactionStatus); 12 | const RequestStatusValues = Object.values(TransactionRequestStatus); 13 | const DefaultPrivacyLevelValues = Object.values(DefaultPrivacyLevel); 14 | const NotificationsTypeValues = Object.values(NotificationsType); 15 | 16 | // Validators 17 | 18 | const isShortId = (value: string) => isValid(value); 19 | 20 | export const shortIdValidation = (key: string) => check(key).custom(isShortId); 21 | 22 | export const searchValidation = query("q").exists(); 23 | 24 | export const userFieldsValidator = oneOf([ 25 | check("firstName").exists(), 26 | check("lastName").exists(), 27 | check("password").exists(), 28 | check("balance").exists(), 29 | check("avatar").exists(), 30 | check("defaultPrivacyLevel").exists(), 31 | ]); 32 | 33 | export const isBankAccountValidator = [ 34 | body("bankName").isString().trim(), 35 | body("accountNumber").isString().trim(), 36 | body("routingNumber").isString().trim(), 37 | ]; 38 | 39 | export const isUserValidator = [ 40 | check("firstName").optional({ checkFalsy: true }).isString().trim(), 41 | check("lastName").optional({ checkFalsy: true }).isString().trim(), 42 | check("username").optional({ checkFalsy: true }).isString().trim(), 43 | check("password").optional({ checkFalsy: true }).isString().trim(), 44 | check("email").optional({ checkFalsy: true }).isString().trim(), 45 | check("phoneNumber").optional({ checkFalsy: true }).isString().trim(), 46 | check("balance").optional({ checkFalsy: true }).isNumeric().trim(), 47 | check("avatar").optional({ checkFalsy: true }).isURL().trim(), 48 | check("defaultPrivacyLevel") 49 | .optional({ checkFalsy: true }) 50 | .isIn(["public", "private", "contacts"]), 51 | ]; 52 | 53 | export const sanitizeTransactionStatus = sanitizeQuery("status").customSanitizer((value) => { 54 | /* istanbul ignore if*/ 55 | if (includes(value, TransactionStatusValues)) { 56 | return value; 57 | } 58 | return; 59 | }); 60 | 61 | // default request status to undefined if not provided 62 | export const sanitizeRequestStatus = sanitizeQuery("requestStatus").customSanitizer((value) => { 63 | /* istanbul ignore if*/ 64 | if (includes(value, RequestStatusValues)) { 65 | return value; 66 | } 67 | return; 68 | }); 69 | 70 | export const isTransactionQSValidator = [ 71 | query("status").isIn(TransactionStatusValues).optional().trim(), 72 | query("requestStatus").optional({ checkFalsy: true }).isIn(RequestStatusValues).trim(), 73 | query("receiverId").optional({ checkFalsy: true }).isString().trim(), 74 | query("senderId").optional({ checkFalsy: true }).isString().trim(), 75 | query("rangeStartTs").optional({ checkFalsy: true }).isString().trim(), 76 | query("rangeEndTs").optional({ checkFalsy: true }).isString().trim(), 77 | query("amountMax").optional({ checkFalsy: true }).isNumeric().trim(), 78 | query("amountMin").optional({ checkFalsy: true }).isNumeric().trim(), 79 | ]; 80 | 81 | export const isTransactionPayloadValidator = [ 82 | body("transactionType").isIn(["payment", "request"]).trim(), 83 | body("privacyLevel").optional().isIn(DefaultPrivacyLevelValues).trim(), 84 | body("source").optional().isString().trim(), 85 | body("receiverId").isString().trim(), 86 | body("description").isString().trim(), 87 | body("amount").isNumeric().trim().toInt(), 88 | ]; 89 | 90 | export const isTransactionPatchValidator = [body("requestStatus").isIn(RequestStatusValues)]; 91 | 92 | export const isTransactionPublicQSValidator = [ 93 | query("order").optional({ checkFalsy: true }).isIn(["default"]), 94 | ]; 95 | 96 | export const isCommentValidator = body("content").isString().trim(); 97 | 98 | export const isNotificationsBodyValidator = [ 99 | body("items.*.type").isIn(NotificationsTypeValues).trim(), 100 | body("items.*.transactionId").custom(isShortId), 101 | ]; 102 | 103 | export const isNotificationPatchValidator = [body("isRead").isBoolean()]; 104 | 105 | export const isValidEntityValidator = [ 106 | check("entity") 107 | .isIn([ 108 | "users", 109 | "contacts", 110 | "bankaccounts", 111 | "notifications", 112 | "transactions", 113 | "likes", 114 | "comments", 115 | "banktransfers", 116 | ]) 117 | .trim(), 118 | ]; 119 | -------------------------------------------------------------------------------- /buildspec-types-unit.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | commands: 6 | - yarn install --frozen-lockfile 7 | build: 8 | commands: 9 | - yarn types 10 | - yarn test:unit:ci -------------------------------------------------------------------------------- /buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | batch: 4 | fast-fail: false 5 | build-matrix: 6 | static: 7 | ignore-failure: false 8 | env: 9 | type: LINUX_CONTAINER 10 | privileged-mode: true 11 | compute-type: BUILD_GENERAL1_MEDIUM 12 | dynamic: 13 | env: 14 | compute-type: 15 | - BUILD_GENERAL1_MEDIUM 16 | image: 17 | - public.ecr.aws/s9l6w2o6/cypress-browsers-node14.15.0-chrome86-ff82 18 | variables: 19 | CY_GROUP_SPEC: 20 | - "UI - Chrome|chrome|cypress/tests/ui/*" 21 | - "UI - Chrome - Mobile|chrome|cypress/tests/ui/*|viewportWidth=375,viewportHeight=667" 22 | - "API|chrome|cypress/tests/api/*" 23 | - "UI - Firefox|firefox|cypress/tests/ui/*" 24 | - "UI - Firefox - Mobile|firefox|cypress/tests/ui/*|viewportWidth=375,viewportHeight=667" 25 | WORKERS: 26 | - 1 27 | - 2 28 | - 3 29 | - 4 30 | - 5 31 | 32 | phases: 33 | install: 34 | commands: 35 | - echo $CODEBUILD_INITIATOR 36 | - echo $CY_GROUP_SPEC 37 | - CY_GROUP=$(echo $CY_GROUP_SPEC | cut -d'|' -f1) 38 | - CY_BROWSER=$(echo $CY_GROUP_SPEC | cut -d'|' -f2) 39 | - CY_SPEC=$(echo $CY_GROUP_SPEC | cut -d'|' -f3) 40 | - CY_CONFIG=$(echo $CY_GROUP_SPEC | cut -d'|' -f4) 41 | - echo $CY_GROUP 42 | - echo $CY_BROWSER 43 | - echo $CY_SPEC 44 | - echo $CY_CONFIG 45 | - yarn install --frozen-lockfile 46 | pre_build: 47 | commands: 48 | - yarn run build 49 | build: 50 | commands: 51 | - yarn start:ci & npx wait-on http://localhost:3000 52 | - npx cypress run --record --parallel --browser $CY_BROWSER --ci-build-id $CODEBUILD_INITIATOR --group "$CY_GROUP" --spec "$CY_SPEC" --config "$CY_CONFIG" -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: nearest 7 | range: "90...100" 8 | status: 9 | project: 10 | default: 11 | base: auto 12 | target: auto 13 | threshold: 0.1% 14 | if_not_found: success 15 | backend: 16 | paths: 17 | - backend 18 | frontend: 19 | paths: 20 | - src 21 | 22 | comment: 23 | layout: "reach,diff,flags,tree" 24 | behavior: default 25 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "projectId": "7s5okt", 4 | "integrationFolder": "cypress/tests", 5 | "viewportHeight": 1000, 6 | "viewportWidth": 1280, 7 | "firefoxGcInterval": null, 8 | "retries": { 9 | "runMode": 2, 10 | "openMode": 1 11 | }, 12 | "env": { 13 | "apiUrl": "http://localhost:3001", 14 | "mobileViewportWidthBreakpoint": 414, 15 | "coverage": false, 16 | "codeCoverage": { 17 | "url": "http://localhost:3001/__coverage__" 18 | } 19 | }, 20 | "experimentalStudio": true 21 | } 22 | -------------------------------------------------------------------------------- /cypress/fixtures/public-transactions.json: -------------------------------------------------------------------------------- 1 | { 2 | "pageData": { 3 | "hasNextPages": false, 4 | "limit": 10, 5 | "page": 1, 6 | "totalPages": 1 7 | }, 8 | "results": [ 9 | { 10 | "amount": 8647, 11 | "balanceAtCompletion": 8958, 12 | "createdAt": "2019-12-10T21:38:16.311Z", 13 | "description": "Payment: db4uxOm7d to IMbeyzHTj9", 14 | "id": "si_aNEMbyCA", 15 | "modifiedAt": "2020-05-06T08:15:48.263Z", 16 | "privacyLevel": "private", 17 | "receiverId": "IMbeyzHTj9", 18 | "requestResolvedAt": "2020-06-09T19:01:15.675Z", 19 | "requestStatus": "", 20 | "senderId": "db4uxOm7d", 21 | "source": "GYDJUNEaOK7", 22 | "status": "complete", 23 | "uuid": "41754166-ea5b-448a-9a8a-374ce387c714", 24 | "receiverName": "Kevin", 25 | "senderName": "Amir", 26 | "likes": [], 27 | "comments": [] 28 | }, 29 | { 30 | "amount": 12724, 31 | "balanceAtCompletion": 45008, 32 | "createdAt": "2020-04-20T15:12:01.340Z", 33 | "description": "Request: IMbeyzHTj9 to db4uxOm7d", 34 | "id": "k5NjJY43WPG", 35 | "modifiedAt": "2020-05-06T02:36:04.844Z", 36 | "privacyLevel": "contacts", 37 | "receiverId": "IMbeyzHTj9", 38 | "requestResolvedAt": "2020-11-22T00:37:01.540Z", 39 | "requestStatus": "accepted", 40 | "senderId": "db4uxOm7d", 41 | "source": "GYDJUNEaOK7", 42 | "status": "complete", 43 | "uuid": "5cdc1625-c937-4ac5-a6ac-eb5c55e93576", 44 | "receiverName": "Kevin", 45 | "senderName": "Amir", 46 | "likes": [], 47 | "comments": [] 48 | }, 49 | { 50 | "amount": 17183, 51 | "balanceAtCompletion": 35847, 52 | "createdAt": "2020-04-19T13:29:13.910Z", 53 | "description": "Request: IMbeyzHTj9 to db4uxOm7d", 54 | "id": "C8f1XzufOzM", 55 | "modifiedAt": "2020-05-06T13:25:01.255Z", 56 | "privacyLevel": "private", 57 | "receiverId": "IMbeyzHTj9", 58 | "requestResolvedAt": "", 59 | "requestStatus": "pending", 60 | "senderId": "db4uxOm7d", 61 | "source": "GYDJUNEaOK7", 62 | "status": "pending", 63 | "uuid": "da9bcbd6-df80-4499-87ba-9a29927ea0c7", 64 | "receiverName": "Kevin", 65 | "senderName": "Amir", 66 | "likes": [], 67 | "comments": [] 68 | } 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /cypress/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace Cypress { 4 | import { authService } from "../src/machines/authMachine"; 5 | import { createTransactionService } from "../src/machines/createTransactionMachine"; 6 | import { publicTransactionService } from "../src/machines/publicTransactionsMachine"; 7 | import { contactsTransactionService } from "../src/machines/contactsTransactionsMachine"; 8 | import { personalTransactionService } from "../src/machines/personalTransactionsMachine"; 9 | import { 10 | User, 11 | BankAccount, 12 | Like, 13 | Comment, 14 | Transaction, 15 | BankTransfer, 16 | Contact, 17 | } from "../src/models"; 18 | 19 | interface CustomWindow extends Window { 20 | authService: typeof authService; 21 | createTransactionService: typeof createTransactionService; 22 | publicTransactionService: typeof publicTransactionService; 23 | contactTransactionService: typeof contactsTransactionService; 24 | personalTransactionService: typeof personalTransactionService; 25 | } 26 | 27 | type dbQueryArg = { 28 | entity: string; 29 | query: object | [object]; 30 | }; 31 | 32 | interface Chainable { 33 | /** 34 | * Window object with additional properties used during test. 35 | */ 36 | window(options?: Partial): Chainable; 37 | 38 | /** 39 | * Custom command to make taking Percy snapshots with full name formed from the test title + suffix easier 40 | */ 41 | visualSnapshot(maybeName?): Chainable; 42 | 43 | getBySel(dataTestAttribute: string, args?: any): Chainable; 44 | getBySelLike(dataTestPrefixAttribute: string, args?: any): Chainable; 45 | 46 | /** 47 | * Cypress task for directly querying to the database within tests 48 | */ 49 | task( 50 | event: "filter:database", 51 | arg: dbQueryArg, 52 | options?: Partial 53 | ): Chainable; 54 | 55 | /** 56 | * Cypress task for directly querying to the database within tests 57 | */ 58 | task( 59 | event: "find:database", 60 | arg?: any, 61 | options?: Partial 62 | ): Chainable; 63 | 64 | /** 65 | * Find a single entity via database query 66 | */ 67 | database(operation: "find", entity: string, query?: object, log?: boolean): Chainable; 68 | 69 | /** 70 | * Filter for data entities via database query 71 | */ 72 | database(operation: "filter", entity: string, query?: object, log?: boolean): Chainable; 73 | 74 | /** 75 | * Fetch React component instance associated with received element subject 76 | */ 77 | reactComponent(): Chainable; 78 | 79 | /** 80 | * Select data range within date range picker component 81 | */ 82 | pickDateRange(startDate: Date, endDate: Date): Chainable; 83 | 84 | /** 85 | * Select transaction amount range 86 | */ 87 | setTransactionAmountRange(min: number, max: number): Chainable; 88 | 89 | /** 90 | * Paginate to the next page in transaction infinite-scroll pagination view 91 | */ 92 | nextTransactionFeedPage(service: string, page: number): Chainable; 93 | 94 | /** 95 | * Logs-in user by using UI 96 | */ 97 | login(username: string, password: string, rememberUser?: boolean): void; 98 | 99 | /** 100 | * Logs-in user by using API request 101 | */ 102 | loginByApi(username: string, password?: string): Chainable; 103 | 104 | /** 105 | * Logs in bypassing UI by triggering XState login event 106 | */ 107 | loginByXstate(username: string, password?: string): Chainable; 108 | 109 | /** 110 | * Logs out via bypassing UI by triggering XState logout event 111 | */ 112 | logoutByXstate(): Chainable; 113 | 114 | /** 115 | * Switch current user by logging out current user and logging as user with specified username 116 | */ 117 | switchUser(username: string): Chainable; 118 | 119 | /** 120 | * Create Transaction via bypassing UI and using XState createTransactionService 121 | */ 122 | createTransaction(payload): Chainable; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import axios from "axios"; 3 | import dotenv from "dotenv"; 4 | import Promise from "bluebird"; 5 | import { percyHealthCheck } from "@percy/cypress/task"; 6 | import codeCoverageTask from "@cypress/code-coverage/task"; 7 | 8 | dotenv.config(); 9 | 10 | export default (on, config) => { 11 | config.env.defaultPassword = process.env.SEED_DEFAULT_USER_PASSWORD; 12 | config.env.paginationPageSize = process.env.PAGINATION_PAGE_SIZE; 13 | 14 | const testDataApiEndpoint = `${config.env.apiUrl}/testData`; 15 | 16 | const queryDatabase = ({ entity, query }, callback) => { 17 | const fetchData = async (attrs) => { 18 | const { data } = await axios.get(`${testDataApiEndpoint}/${entity}`); 19 | return callback(data, attrs); 20 | }; 21 | 22 | return Array.isArray(query) ? Promise.map(query, fetchData) : fetchData(query); 23 | }; 24 | 25 | on("task", { 26 | percyHealthCheck, 27 | async "db:seed"() { 28 | // seed database with test data 29 | const { data } = await axios.post(`${testDataApiEndpoint}/seed`); 30 | return data; 31 | }, 32 | 33 | // fetch test data from a database (MySQL, PostgreSQL, etc...) 34 | "filter:database"(queryPayload) { 35 | return queryDatabase(queryPayload, (data, attrs) => _.filter(data.results, attrs)); 36 | }, 37 | "find:database"(queryPayload) { 38 | return queryDatabase(queryPayload, (data, attrs) => _.find(data.results, attrs)); 39 | }, 40 | }); 41 | 42 | codeCoverageTask(on, config); 43 | return config; 44 | }; 45 | -------------------------------------------------------------------------------- /cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import "@cypress/code-coverage/support"; 3 | import "@percy/cypress"; 4 | import "./commands"; 5 | -------------------------------------------------------------------------------- /cypress/support/utils.ts: -------------------------------------------------------------------------------- 1 | export const isMobile = () => { 2 | return Cypress.config("viewportWidth") < Cypress.env("mobileViewportWidthBreakpoint"); 3 | }; 4 | -------------------------------------------------------------------------------- /cypress/tests/api/api-bankaccounts.spec.ts: -------------------------------------------------------------------------------- 1 | // check this file using TypeScript if available 2 | // @ts-check 3 | 4 | import faker from "faker"; 5 | import { User, BankAccount } from "../../../src/models"; 6 | 7 | const apiBankAccounts = `${Cypress.env("apiUrl")}/bankAccounts`; 8 | 9 | type TestBankAccountsCtx = { 10 | allUsers?: User[]; 11 | authenticatedUser?: User; 12 | bankAccounts?: BankAccount[]; 13 | }; 14 | 15 | describe("Bank Accounts API", function () { 16 | let ctx: TestBankAccountsCtx = {}; 17 | 18 | beforeEach(function () { 19 | cy.task("db:seed"); 20 | 21 | cy.database("filter", "users").then((users: User[]) => { 22 | ctx.authenticatedUser = users[0]; 23 | ctx.allUsers = users; 24 | 25 | return cy.loginByApi(ctx.authenticatedUser.username); 26 | }); 27 | 28 | cy.database("filter", "bankaccounts").then((bankAccounts: BankAccount[]) => { 29 | ctx.bankAccounts = bankAccounts; 30 | }); 31 | }); 32 | 33 | context("GET /bankAccounts", function () { 34 | it("gets a list of bank accounts for user", function () { 35 | const { id: userId } = ctx.authenticatedUser!; 36 | cy.request("GET", `${apiBankAccounts}`).then((response) => { 37 | expect(response.status).to.eq(200); 38 | expect(response.body.results[0].userId).to.eq(userId); 39 | }); 40 | }); 41 | }); 42 | 43 | context("GET /bankAccounts/:bankAccountId", function () { 44 | it("gets a bank account", function () { 45 | const { id: userId } = ctx.authenticatedUser!; 46 | const { id: bankAccountId } = ctx.bankAccounts![0]; 47 | cy.request("GET", `${apiBankAccounts}/${bankAccountId}`).then((response) => { 48 | expect(response.status).to.eq(200); 49 | expect(response.body.account.userId).to.eq(userId); 50 | }); 51 | }); 52 | }); 53 | 54 | context("POST /bankAccounts", function () { 55 | it("creates a new bank account", function () { 56 | const { id: userId } = ctx.authenticatedUser!; 57 | 58 | cy.request("POST", `${apiBankAccounts}`, { 59 | bankName: `${faker.company.companyName()} Bank`, 60 | accountNumber: faker.finance.account(10), 61 | routingNumber: faker.finance.account(9), 62 | }).then((response) => { 63 | expect(response.status).to.eq(200); 64 | expect(response.body.account.id).to.be.a("string"); 65 | expect(response.body.account.userId).to.eq(userId); 66 | }); 67 | }); 68 | }); 69 | 70 | context("DELETE /contacts/:bankAccountId", function () { 71 | it("deletes a bank account", function () { 72 | const { id: bankAccountId } = ctx.bankAccounts![0]; 73 | cy.request("DELETE", `${apiBankAccounts}/${bankAccountId}`).then((response) => { 74 | expect(response.status).to.eq(200); 75 | }); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /cypress/tests/api/api-banktransfers.spec.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../../../src/models"; 2 | 3 | const apiBankTransfer = `${Cypress.env("apiUrl")}/bankTransfers`; 4 | 5 | type TestBankTransferCtx = { 6 | authenticatedUser?: User; 7 | }; 8 | 9 | describe("Bank Transfer API", function () { 10 | let ctx: TestBankTransferCtx = {}; 11 | 12 | beforeEach(function () { 13 | cy.task("db:seed"); 14 | 15 | cy.database("find", "users").then((user: User) => { 16 | ctx.authenticatedUser = user; 17 | 18 | return cy.loginByApi(ctx.authenticatedUser.username); 19 | }); 20 | }); 21 | 22 | context("GET /bankTransfer", function () { 23 | it("gets a list of bank transfers for user", function () { 24 | const { id: userId } = ctx.authenticatedUser!; 25 | cy.request("GET", `${apiBankTransfer}`).then((response) => { 26 | expect(response.status).to.eq(200); 27 | expect(response.body.transfers[0].userId).to.eq(userId); 28 | }); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /cypress/tests/api/api-comments.spec.ts: -------------------------------------------------------------------------------- 1 | // check this file using TypeScript if available 2 | // @ts-check 3 | 4 | import { User, Comment } from "../../../src/models"; 5 | 6 | const apiComments = `${Cypress.env("apiUrl")}/comments`; 7 | 8 | type TestCommentsCtx = { 9 | authenticatedUser?: User; 10 | transactionId?: string; 11 | }; 12 | 13 | describe("Comments API", function () { 14 | let ctx: TestCommentsCtx = {}; 15 | 16 | beforeEach(function () { 17 | cy.task("db:seed"); 18 | 19 | cy.database("filter", "users").then((users: User[]) => { 20 | ctx.authenticatedUser = users[0]; 21 | 22 | return cy.loginByApi(ctx.authenticatedUser.username); 23 | }); 24 | 25 | cy.database("find", "comments").then((comment: Comment) => { 26 | ctx.transactionId = comment.transactionId; 27 | }); 28 | }); 29 | 30 | context("GET /comments/:transactionId", function () { 31 | it("gets a list of comments for a transaction", function () { 32 | cy.request("GET", `${apiComments}/${ctx.transactionId}`).then((response) => { 33 | expect(response.status).to.eq(200); 34 | expect(response.body.comments.length).to.eq(1); 35 | }); 36 | }); 37 | }); 38 | 39 | context("POST /comments/:transactionId", function () { 40 | it("creates a new comment for a transaction", function () { 41 | cy.request("POST", `${apiComments}/${ctx.transactionId}`, { 42 | content: "This is my comment", 43 | }).then((response) => { 44 | expect(response.status).to.eq(200); 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /cypress/tests/api/api-contacts.spec.ts: -------------------------------------------------------------------------------- 1 | import { User, Contact } from "../../../src/models"; 2 | 3 | const apiContacts = `${Cypress.env("apiUrl")}/contacts`; 4 | 5 | type TestContactsCtx = { 6 | allUsers?: User[]; 7 | authenticatedUser?: User; 8 | contact?: Contact; 9 | }; 10 | describe("Contacts API", function () { 11 | let ctx: TestContactsCtx = {}; 12 | 13 | beforeEach(function () { 14 | cy.task("db:seed"); 15 | 16 | cy.database("filter", "users").then((users: User[]) => { 17 | ctx.authenticatedUser = users[0]; 18 | ctx.allUsers = users; 19 | 20 | return cy.loginByApi(ctx.authenticatedUser.username); 21 | }); 22 | 23 | cy.database("find", "contacts").then((contact: Contact) => { 24 | ctx.contact = contact; 25 | }); 26 | }); 27 | 28 | context("GET /contacts/:username", function () { 29 | it("gets a list of contacts by username", function () { 30 | const { username } = ctx.authenticatedUser!; 31 | cy.request("GET", `${apiContacts}/${username}`).then((response) => { 32 | expect(response.status).to.eq(200); 33 | expect(response.body.contacts[0]).to.have.property("userId"); 34 | }); 35 | }); 36 | }); 37 | 38 | context("POST /contacts", function () { 39 | it("creates a new contact", function () { 40 | const { id: userId } = ctx.authenticatedUser!; 41 | 42 | cy.request("POST", `${apiContacts}`, { 43 | contactUserId: ctx.contact!.id, 44 | }).then((response) => { 45 | expect(response.status).to.eq(200); 46 | expect(response.body.contact.id).to.be.a("string"); 47 | expect(response.body.contact.userId).to.eq(userId); 48 | }); 49 | }); 50 | 51 | it("error when invalid contactUserId", function () { 52 | cy.request({ 53 | method: "POST", 54 | url: `${apiContacts}`, 55 | failOnStatusCode: false, 56 | body: { 57 | contactUserId: "1234", 58 | }, 59 | }).then((response) => { 60 | expect(response.status).to.eq(422); 61 | expect(response.body.errors.length).to.eq(1); 62 | }); 63 | }); 64 | }); 65 | context("DELETE /contacts/:contactId", function () { 66 | it("deletes a contact", function () { 67 | cy.request("DELETE", `${apiContacts}/${ctx.contact!.id}`).then((response) => { 68 | expect(response.status).to.eq(200); 69 | }); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /cypress/tests/api/api-likes.spec.ts: -------------------------------------------------------------------------------- 1 | // check this file using TypeScript if available 2 | // @ts-check 3 | 4 | import { User, Like } from "../../../src/models"; 5 | 6 | const apiLikes = `${Cypress.env("apiUrl")}/likes`; 7 | 8 | type TestLikesCtx = { 9 | authenticatedUser?: User; 10 | transactionId?: string; 11 | }; 12 | 13 | describe("Likes API", function () { 14 | let ctx: TestLikesCtx = {}; 15 | 16 | beforeEach(function () { 17 | cy.task("db:seed"); 18 | 19 | cy.database("filter", "users").then((users: User[]) => { 20 | ctx.authenticatedUser = users[0]; 21 | 22 | return cy.loginByApi(ctx.authenticatedUser.username); 23 | }); 24 | 25 | cy.database("find", "likes").then((like: Like) => { 26 | ctx.transactionId = like.transactionId; 27 | }); 28 | }); 29 | 30 | context("GET /likes/:transactionId", function () { 31 | it("gets a list of likes for a transaction", function () { 32 | cy.request("GET", `${apiLikes}/${ctx.transactionId}`).then((response) => { 33 | expect(response.status).to.eq(200); 34 | expect(response.body.likes.length).to.eq(1); 35 | }); 36 | }); 37 | }); 38 | 39 | context("POST /likes/:transactionId", function () { 40 | it("creates a new like for a transaction", function () { 41 | cy.request("POST", `${apiLikes}/${ctx.transactionId}`, { 42 | transactionId: ctx.transactionId, 43 | }).then((response) => { 44 | expect(response.status).to.eq(200); 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /cypress/tests/api/api-notifications.spec.ts: -------------------------------------------------------------------------------- 1 | import { User, NotificationType, Like, Comment, Transaction } from "../../../src/models"; 2 | 3 | const apiNotifications = `${Cypress.env("apiUrl")}/notifications`; 4 | 5 | type TestNotificationsCtx = { 6 | authenticatedUser?: User; 7 | transactionId?: string; 8 | notificationId?: string; 9 | likeId?: string; 10 | commentId?: string; 11 | }; 12 | 13 | describe("Notifications API", function () { 14 | let ctx: TestNotificationsCtx = {}; 15 | 16 | beforeEach(function () { 17 | cy.task("db:seed"); 18 | 19 | cy.database("filter", "users").then((users: User[]) => { 20 | ctx.authenticatedUser = users[0]; 21 | 22 | return cy.loginByApi(ctx.authenticatedUser.username); 23 | }); 24 | 25 | cy.database("find", "transactions").then((transaction: Transaction) => { 26 | ctx.transactionId = transaction.id; 27 | }); 28 | 29 | cy.database("find", "notifications").then((notification: NotificationType) => { 30 | ctx.notificationId = notification.id; 31 | }); 32 | 33 | cy.database("find", "likes").then((like: Like) => { 34 | ctx.likeId = like.transactionId; 35 | }); 36 | 37 | cy.database("find", "comments").then((comment: Comment) => { 38 | ctx.commentId = comment.transactionId; 39 | }); 40 | }); 41 | 42 | context("GET /notifications", function () { 43 | it("gets a list of notifications for a user", function () { 44 | cy.request("GET", `${apiNotifications}`).then((response) => { 45 | expect(response.status).to.eq(200); 46 | expect(response.body.results.length).to.be.greaterThan(0); 47 | }); 48 | }); 49 | }); 50 | 51 | context("POST /notifications", function () { 52 | it("creates notifications for transaction, like and comment", function () { 53 | cy.request("POST", `${apiNotifications}/bulk`, { 54 | items: [ 55 | { 56 | type: "payment", 57 | transactionId: ctx.transactionId, 58 | status: "received", 59 | }, 60 | { 61 | type: "like", 62 | transactionId: ctx.transactionId, 63 | likeId: ctx.likeId, 64 | }, 65 | { 66 | type: "comment", 67 | transactionId: ctx.transactionId, 68 | commentId: ctx.commentId, 69 | }, 70 | ], 71 | }).then((response) => { 72 | expect(response.status).to.eq(200); 73 | expect(response.body.results.length).to.equal(3); 74 | expect(response.body.results[0].transactionId).to.equal(ctx.transactionId); 75 | }); 76 | }); 77 | }); 78 | 79 | context("PATCH /notifications/:notificationId", function () { 80 | it("updates a notification", function () { 81 | cy.request("PATCH", `${apiNotifications}/${ctx.notificationId}`, { 82 | isRead: true, 83 | }).then((response) => { 84 | expect(response.status).to.eq(204); 85 | }); 86 | }); 87 | 88 | it("error when invalid field sent", function () { 89 | cy.request({ 90 | method: "PATCH", 91 | url: `${apiNotifications}/${ctx.notificationId}`, 92 | failOnStatusCode: false, 93 | body: { 94 | notANotificationField: "not a notification field", 95 | }, 96 | }).then((response) => { 97 | expect(response.status).to.eq(422); 98 | expect(response.body.errors.length).to.eq(1); 99 | }); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /cypress/tests/api/api-testdata.spec.ts: -------------------------------------------------------------------------------- 1 | // check this file using TypeScript if available 2 | // @ts-check 3 | 4 | import { User } from "../../../src/models"; 5 | 6 | const apiTestData = `${Cypress.env("apiUrl")}/testData`; 7 | 8 | type TestDataCtx = { 9 | authenticatedUser?: User; 10 | }; 11 | 12 | describe("Test Data API", function () { 13 | let ctx: TestDataCtx = {}; 14 | 15 | beforeEach(function () { 16 | cy.task("db:seed"); 17 | 18 | cy.database("filter", "users").then((users: User[]) => { 19 | ctx.authenticatedUser = users[0]; 20 | 21 | return cy.loginByApi(ctx.authenticatedUser.username); 22 | }); 23 | }); 24 | 25 | context("GET /testData/:entity", function () { 26 | Cypress._.each( 27 | [ 28 | "users", 29 | "contacts", 30 | "bankaccounts", 31 | "notifications", 32 | "transactions", 33 | "likes", 34 | "comments", 35 | "banktransfers", 36 | ], 37 | (entity) => { 38 | it(`gets remote mock data for ${entity}`, function () { 39 | cy.request("GET", `${apiTestData}/${entity}`).then((response) => { 40 | expect(response.status).to.eq(200); 41 | expect(response.body.results.length).to.be.greaterThan(1); 42 | }); 43 | }); 44 | } 45 | ); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /cypress/tests/demo/cypress-studio.spec.ts: -------------------------------------------------------------------------------- 1 | import { User } from "models"; 2 | 3 | describe("Cypress Studio Demo", function () { 4 | beforeEach(function () { 5 | cy.task("db:seed"); 6 | 7 | cy.database("find", "users").then((user: User) => { 8 | cy.login(user.username, "s3cret", true); 9 | }); 10 | }); 11 | it("create new transaction", function () { 12 | // Extend test with Cypress Studio 13 | }); 14 | it("create new bank account", function () { 15 | // Extend test with Cypress Studio 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /cypress/tests/ui/transaction-view.spec.ts: -------------------------------------------------------------------------------- 1 | import { User, Transaction } from "../../../src/models"; 2 | 3 | type NewTransactionCtx = { 4 | transactionRequest?: Transaction; 5 | authenticatedUser?: User; 6 | }; 7 | 8 | describe("Transaction View", function () { 9 | const ctx: NewTransactionCtx = {}; 10 | 11 | beforeEach(function () { 12 | cy.task("db:seed"); 13 | 14 | cy.server(); 15 | cy.route("GET", "/transactions").as("personalTransactions"); 16 | cy.route("GET", "/transactions/public").as("publicTransactions"); 17 | cy.route("GET", "/transactions/*").as("getTransaction"); 18 | cy.route("PATCH", "/transactions/*").as("updateTransaction"); 19 | 20 | cy.route("GET", "/checkAuth").as("userProfile"); 21 | cy.route("GET", "/notifications").as("getNotifications"); 22 | cy.route("GET", "/bankAccounts").as("getBankAccounts"); 23 | 24 | cy.database("find", "users").then((user: User) => { 25 | ctx.authenticatedUser = user; 26 | 27 | cy.loginByXstate(ctx.authenticatedUser.username); 28 | 29 | cy.database("find", "transactions", { 30 | receiverId: ctx.authenticatedUser.id, 31 | status: "pending", 32 | requestStatus: "pending", 33 | requestResolvedAt: "", 34 | }).then((transaction: Transaction) => { 35 | ctx.transactionRequest = transaction; 36 | }); 37 | }); 38 | 39 | cy.getBySel("nav-personal-tab").click(); 40 | cy.wait("@personalTransactions"); 41 | }); 42 | 43 | it("transactions navigation tabs are hidden on a transaction view page", function () { 44 | cy.getBySelLike("transaction-item").first().click(); 45 | cy.location("pathname").should("include", "/transaction"); 46 | cy.getBySel("nav-transaction-tabs").should("not.exist"); 47 | cy.getBySel("transaction-detail-header").should("be.visible"); 48 | cy.visualSnapshot("Transaction Navigation Tabs Hidden"); 49 | }); 50 | 51 | it("likes a transaction", function () { 52 | cy.getBySelLike("transaction-item").first().click(); 53 | cy.wait("@getTransaction"); 54 | 55 | cy.getBySelLike("like-button").click(); 56 | cy.getBySelLike("like-count").should("contain", 1); 57 | cy.getBySelLike("like-button").should("be.disabled"); 58 | cy.visualSnapshot("Transaction after Liked"); 59 | }); 60 | 61 | it("comments on a transaction", function () { 62 | cy.getBySelLike("transaction-item").first().click(); 63 | cy.wait("@getTransaction"); 64 | 65 | const comments = ["Thank you!", "Appreciate it."]; 66 | 67 | comments.forEach((comment, index) => { 68 | cy.getBySelLike("comment-input").type(`${comment}{enter}`); 69 | cy.getBySelLike("comments-list").children().eq(index).contains(comment); 70 | }); 71 | 72 | cy.getBySelLike("comments-list").children().should("have.length", comments.length); 73 | cy.visualSnapshot("Comment on Transaction"); 74 | }); 75 | 76 | it("accepts a transaction request", function () { 77 | cy.visit(`/transaction/${ctx.transactionRequest!.id}`); 78 | cy.wait("@getTransaction"); 79 | 80 | cy.getBySelLike("accept-request").click(); 81 | cy.wait("@updateTransaction").should("have.property", "status", 204); 82 | cy.getBySelLike("accept-request").should("not.exist"); 83 | cy.getBySel("transaction-detail-header").should("be.visible"); 84 | cy.visualSnapshot("Transaction Accepted"); 85 | }); 86 | 87 | it("rejects a transaction request", function () { 88 | cy.visit(`/transaction/${ctx.transactionRequest!.id}`); 89 | cy.wait("@getTransaction"); 90 | 91 | cy.getBySelLike("reject-request").click(); 92 | cy.wait("@updateTransaction").should("have.property", "status", 204); 93 | cy.getBySelLike("reject-request").should("not.exist"); 94 | cy.getBySel("transaction-detail-header").should("be.visible"); 95 | cy.visualSnapshot("Transaction Rejected"); 96 | }); 97 | 98 | it("does not display accept/reject buttons on completed request", function () { 99 | cy.database("find", "transactions", { 100 | receiverId: ctx.authenticatedUser!.id, 101 | status: "complete", 102 | requestStatus: "accepted", 103 | }).then((transactionRequest) => { 104 | cy.visit(`/transaction/${transactionRequest!.id}`); 105 | 106 | cy.wait("@getNotifications"); 107 | cy.getBySel("transaction-detail-header").should("be.visible"); 108 | cy.getBySel("transaction-accept-request").should("not.exist"); 109 | cy.getBySel("transaction-reject-request").should("not.exist"); 110 | cy.getBySel("transaction-detail-header").should("be.visible"); 111 | cy.visualSnapshot("Transaction Completed (not able to accept or reject)"); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /cypress/tests/ui/user-settings.spec.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../../../src/models"; 2 | import { isMobile } from "../../support/utils"; 3 | 4 | describe("User Settings", function () { 5 | beforeEach(function () { 6 | cy.task("db:seed"); 7 | 8 | cy.server(); 9 | cy.route("PATCH", "/users/*").as("updateUser"); 10 | cy.route("GET", "/notifications").as("getNotifications"); 11 | 12 | cy.database("find", "users").then((user: User) => { 13 | cy.loginByXstate(user.username); 14 | }); 15 | 16 | if (isMobile()) { 17 | cy.getBySel("sidenav-toggle").click(); 18 | } 19 | 20 | cy.getBySel("sidenav-user-settings").click(); 21 | }); 22 | 23 | it("renders the user settings form", function () { 24 | cy.wait("@getNotifications"); 25 | cy.getBySel("user-settings-form").should("be.visible"); 26 | cy.location("pathname").should("include", "/user/settings"); 27 | 28 | cy.visualSnapshot("User Settings Form"); 29 | }); 30 | 31 | it("should display user setting form errors", function () { 32 | ["first", "last"].forEach((field) => { 33 | cy.getBySelLike(`${field}Name-input`).type("Abc").clear().blur(); 34 | cy.get(`#user-settings-${field}Name-input-helper-text`) 35 | .should("be.visible") 36 | .and("contain", `Enter a ${field} name`); 37 | }); 38 | 39 | cy.getBySelLike("email-input").type("abc").clear().blur(); 40 | cy.get("#user-settings-email-input-helper-text") 41 | .should("be.visible") 42 | .and("contain", "Enter an email address"); 43 | 44 | cy.getBySelLike("email-input").type("abc@bob.").blur(); 45 | cy.get("#user-settings-email-input-helper-text") 46 | .should("be.visible") 47 | .and("contain", "Must contain a valid email address"); 48 | 49 | cy.getBySelLike("phoneNumber-input").type("abc").clear().blur(); 50 | cy.get("#user-settings-phoneNumber-input-helper-text") 51 | .should("be.visible") 52 | .and("contain", "Enter a phone number"); 53 | 54 | cy.getBySelLike("phoneNumber-input").type("615-555-").blur(); 55 | cy.get("#user-settings-phoneNumber-input-helper-text") 56 | .should("be.visible") 57 | .and("contain", "Phone number is not valid"); 58 | 59 | cy.getBySelLike("submit").should("be.disabled"); 60 | cy.visualSnapshot("User Settings Form Errors and Submit Disabled"); 61 | }); 62 | 63 | it("updates first name, last name, email and phone number", function () { 64 | cy.getBySelLike("firstName").clear().type("New First Name"); 65 | cy.getBySelLike("lastName").clear().type("New Last Name"); 66 | cy.getBySelLike("email").clear().type("email@email.com"); 67 | cy.getBySelLike("phoneNumber-input").clear().type("6155551212").blur(); 68 | 69 | cy.getBySelLike("submit").should("not.be.disabled"); 70 | cy.getBySelLike("submit").click(); 71 | 72 | cy.wait("@updateUser").its("status").should("equal", 204); 73 | 74 | if (isMobile()) { 75 | cy.getBySel("sidenav-toggle").click(); 76 | } 77 | 78 | cy.getBySel("sidenav-user-full-name").should("contain", "New First Name"); 79 | cy.visualSnapshot("User Settings Update Profile"); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./**/*.ts"], 4 | "exclude": [], 5 | "compilerOptions": { 6 | "types": ["cypress", "@percy/cypress"], 7 | "lib": ["es2015", "dom"], 8 | "isolatedModules": false, 9 | "allowJs": true, 10 | "noEmit": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /data/empty-seed.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": [], 3 | "contacts": [], 4 | "bankaccounts": [], 5 | "transactions": [], 6 | "likes": [], 7 | "comments": [], 8 | "notifications": [], 9 | "banktransfers": [] 10 | } 11 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | React App 25 | 26 | 27 | 28 |
29 | 30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cypress-realworld-app", 3 | "version": "1.0.0", 4 | "description": "A payment application to demonstrate **real-world** usage of Cypress testing methods, patterns, and workflows. For a full reference of our documentation, go to https://docs.cypress.io", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/cypress-io/cypress-realworld-app.git" 8 | }, 9 | "author": "Cypress DX Team", 10 | "license": "MIT", 11 | "bugs": { 12 | "url": "https://github.com/cypress-io/cypress-realworld-app/issues" 13 | }, 14 | "engines": { 15 | "node": ">=12" 16 | }, 17 | "dependencies": { 18 | "@material-ui/core": "4.11.2", 19 | "@material-ui/icons": "4.11.2", 20 | "@material-ui/lab": "4.0.0-alpha.57", 21 | "@xstate/react": "1.2.2", 22 | "axios": "0.21.1", 23 | "clsx": "1.1.1", 24 | "date-fns": "2.16.1", 25 | "dinero.js": "1.8.1", 26 | "faker": "^5.1.0", 27 | "formik": "2.2.6", 28 | "history": "4.10.1", 29 | "lodash": "^4.17.20", 30 | "react": "17.0.1", 31 | "react-dom": "17.0.1", 32 | "react-infinite-calendar": "2.3.1", 33 | "react-number-format": "4.4.1", 34 | "react-router": "5.2.0", 35 | "react-router-dom": "5.2.0", 36 | "react-virtualized": "9.22.3", 37 | "shortid": "2.2.16", 38 | "uuid": "8.3.2", 39 | "xstate": "4.15.1", 40 | "yup": "0.32.8" 41 | }, 42 | "scripts": { 43 | "dev": "vite", 44 | "build": "vite build" 45 | }, 46 | "eslintConfig": { 47 | "env": { 48 | "cypress/globals": true 49 | }, 50 | "extends": [ 51 | "react-app", 52 | "plugin:prettier/recommended", 53 | "plugin:cypress/recommended" 54 | ], 55 | "plugins": [ 56 | "cypress", 57 | "prettier" 58 | ], 59 | "rules": { 60 | "no-unused-expressions": 0 61 | } 62 | }, 63 | "jest": { 64 | "watchPathIgnorePatterns": [ 65 | "/data/database.json", 66 | "/data/database-seed.json" 67 | ] 68 | }, 69 | "husky": { 70 | "hooks": { 71 | "pre-push": "yarn types" 72 | } 73 | }, 74 | "browserslist": { 75 | "production": [ 76 | ">0.2%", 77 | "not dead", 78 | "not op_mini all" 79 | ], 80 | "development": [ 81 | "last 1 chrome version", 82 | "last 1 firefox version", 83 | "last 1 safari version" 84 | ] 85 | }, 86 | "percy": { 87 | "version": 1, 88 | "snapshot": { 89 | "widths": [ 90 | 1280 91 | ] 92 | } 93 | }, 94 | "nyc": { 95 | "exclude": [ 96 | "src/models/*.ts" 97 | ], 98 | "reporter": [ 99 | "html", 100 | "json" 101 | ] 102 | }, 103 | "devDependencies": { 104 | "@vitejs/plugin-react-refresh": "^1.1.2", 105 | "vite": "^2.0.0-beta.38" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yyx990803/cypress-realworld-app/e06faa9a5b0eab4eb384b86354f7923b63300c82/public/favicon.ico -------------------------------------------------------------------------------- /public/img/rwa-readme-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yyx990803/cypress-realworld-app/e06faa9a5b0eab4eb384b86354f7923b63300c82/public/img/rwa-readme-screenshot.png -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yyx990803/cypress-realworld-app/e06faa9a5b0eab4eb384b86354f7923b63300c82/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yyx990803/cypress-realworld-app/e06faa9a5b0eab4eb384b86354f7923b63300c82/public/logo512.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "baseBranch": "develop", 4 | "automerge": false, 5 | "commitMessage": "{{semanticPrefix}}Update {{depName}} to {{newVersion}} 🌟", 6 | "prTitle": "{{semanticPrefix}}{{#if isPin}}Pin{{else}}Update{{/if}} dependency {{depName}} to version {{#if isRange}}{{newVersion}}{{else}}{{#if isMajor}}{{newVersionMajor}}.x{{else}}{{newVersion}}{{/if}}{{/if}} 🌟", 7 | "major": { 8 | "automerge": false 9 | }, 10 | "minor": { 11 | "automerge": false 12 | }, 13 | "prHourlyLimit": 1, 14 | "updateNotScheduled": false, 15 | "timezone": "America/New_York", 16 | "lockFileMaintenance": { 17 | "enabled": true 18 | }, 19 | "masterIssue": true, 20 | "schedule": ["every weekend"], 21 | "packageRules": [ 22 | { 23 | "packageNames": ["cypress"], 24 | "schedule": ["every weekday"] 25 | }, 26 | { 27 | "packageNames": ["@babel/compat-data"], 28 | "allowedVersions": "7.9.0" 29 | }, 30 | { 31 | "packageNames": ["babel-loader"], 32 | "allowedVersions": "8.0.6" 33 | }, 34 | { 35 | "packageNames": ["react-scripts"], 36 | "allowedVersions": "3.4.0" 37 | }, 38 | { 39 | "packageNames": ["@types/express"], 40 | "allowedVersions": "4.17.2" 41 | }, 42 | { 43 | "packageNames": ["@types/express-serve-static-core"], 44 | "allowedVersions": "4.17.2" 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "infiniteLoopProtection": true, 3 | "hardReloadOnChange": false, 4 | "template": "node", 5 | "container": { 6 | "port": 3000, 7 | "startScript": "start:codesandbox" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /scripts/generateSeedData.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import { buildDatabase } from "./seedDataUtils"; 4 | import { TDatabase } from "../backend/database"; 5 | const testSeed: TDatabase = buildDatabase(); 6 | 7 | const fileData = JSON.stringify(testSeed, null, 2); 8 | 9 | fs.writeFile(path.join(process.cwd(), "data", "database-seed.json"), fileData, (err) => { 10 | if (err) { 11 | console.error(err); 12 | return; 13 | } 14 | console.log("test seed generated"); 15 | }); 16 | -------------------------------------------------------------------------------- /scripts/testServer.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import express from "express"; 3 | import history from "connect-history-api-fallback"; 4 | import setupProxy from "../src/setupProxy"; 5 | 6 | const app = express(); 7 | 8 | setupProxy(app); 9 | 10 | app.use(history()); 11 | app.use(express.static(path.join(__dirname, "../build"))); 12 | 13 | app.listen(3000); 14 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./*/*.ts", "../src", "../models"], 4 | "exclude": [], 5 | "compilerOptions": { 6 | "types": ["node"], 7 | "isolatedModules": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/__tests__/bankaccounts.test.ts: -------------------------------------------------------------------------------- 1 | import faker from "faker"; 2 | import { 3 | getBankAccountById, 4 | getBankAccountsByUserId, 5 | getRandomUser, 6 | seedDatabase, 7 | createBankAccountForUser, 8 | removeBankAccountById, 9 | } from "../../backend/database"; 10 | import { User } from "../../src/models/user"; 11 | import { BankAccount } from "../../src/models/bankaccount"; 12 | describe("BankAccounts", () => { 13 | beforeEach(() => { 14 | seedDatabase(); 15 | }); 16 | 17 | it("should retrieve a list of bank accounts for a user", () => { 18 | const userToLookup: User = getRandomUser(); 19 | 20 | const result = getBankAccountsByUserId(userToLookup.id); 21 | expect(result[0].userId).toBe(userToLookup.id); 22 | }); 23 | 24 | it("should retrieve a bank accounts by id", () => { 25 | const userToLookup: User = getRandomUser(); 26 | 27 | const accounts = getBankAccountsByUserId(userToLookup.id); 28 | const bankAccountId = accounts[0].id; 29 | 30 | const account = getBankAccountById(bankAccountId); 31 | 32 | expect(account.id).toEqual(bankAccountId); 33 | }); 34 | 35 | it("should create a bank account for user", () => { 36 | const user: User = getRandomUser(); 37 | const accountNumber = faker.finance.account(10); 38 | 39 | const accountDetails: Partial = { 40 | bankName: `${faker.company.companyName()} Bank`, 41 | accountNumber, 42 | routingNumber: faker.finance.account(9), 43 | }; 44 | const result = createBankAccountForUser(user.id, accountDetails); 45 | expect(result.userId).toBe(user.id); 46 | }); 47 | 48 | it("should delete a bank account", () => { 49 | const userToLookup: User = getRandomUser(); 50 | 51 | const accounts = getBankAccountsByUserId(userToLookup.id); 52 | const bankAccountId = accounts[0].id; 53 | 54 | removeBankAccountById(bankAccountId); 55 | 56 | const updatedBankAccounts = getBankAccountsByUserId(userToLookup.id); 57 | expect(updatedBankAccounts[0].isDeleted).toBe(true); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/__tests__/comments.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | seedDatabase, 3 | getTransactionsForUserContacts, 4 | getAllUsers, 5 | getTransactionsByUserId, 6 | createComment, 7 | getCommentsByTransactionId, 8 | } from "../../backend/database"; 9 | 10 | import { User, Transaction } from "../../src/models"; 11 | 12 | describe("Comments", () => { 13 | beforeEach(() => { 14 | seedDatabase(); 15 | }); 16 | 17 | it("should comment a transaction for a contact", () => { 18 | const user: User = getAllUsers()[0]; 19 | const transactions: Transaction[] = getTransactionsForUserContacts(user.id); 20 | 21 | const content = "This is my comment content"; 22 | const comment = createComment(user.id, transactions[0].id, content); 23 | 24 | expect(comment.transactionId).toBe(transactions[0].id); 25 | expect(comment.content).toBe(content); 26 | }); 27 | 28 | it("should get a list of comments for a transaction", () => { 29 | const user: User = getAllUsers()[0]; 30 | const transactions: Transaction[] = getTransactionsByUserId(user.id); 31 | const transaction = transactions[0]; 32 | 33 | createComment(user.id, transaction.id, "This is my comment"); 34 | 35 | const comments = getCommentsByTransactionId(transaction.id); 36 | 37 | expect(comments[0].transactionId).toBe(transaction.id); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/__tests__/contacts.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContactForUser, 3 | getContactsByUsername, 4 | getAllContacts, 5 | getAllUsers, 6 | getRandomUser, 7 | seedDatabase, 8 | removeContactById, 9 | getContactsByUserId, 10 | } from "../../backend/database"; 11 | import { User } from "../../src/models/user"; 12 | import { totalContacts, contactsPerUser } from "../../scripts/seedDataUtils"; 13 | describe("Contacts", () => { 14 | beforeEach(() => { 15 | seedDatabase(); 16 | }); 17 | 18 | it("should retrieve a list of contacts", () => { 19 | expect(getAllContacts().length).toEqual(totalContacts); 20 | }); 21 | 22 | it("should retrieve a list of contacts for a username", () => { 23 | const userToLookup: User = getAllUsers()[0]; 24 | 25 | const result = getContactsByUsername(userToLookup.username); 26 | expect(result.length).toBeGreaterThanOrEqual(contactsPerUser); 27 | expect(result[0].userId).toBe(userToLookup.id); 28 | }); 29 | 30 | it("should retrieve a list of contacts for a userId", () => { 31 | const userToLookup: User = getAllUsers()[0]; 32 | 33 | const result = getContactsByUserId(userToLookup.id); 34 | expect(result.length).toBeGreaterThanOrEqual(3); 35 | expect(result[0].userId).toBe(userToLookup.id); 36 | }); 37 | 38 | it("should create a contact for user", () => { 39 | const user: User = getRandomUser(); 40 | const contactToBe: User = getRandomUser(); 41 | 42 | const result = createContactForUser(user.id, contactToBe.id); 43 | expect(result.userId).toBe(user.id); 44 | }); 45 | 46 | it("should delete a contact", () => { 47 | const userToLookup: User = getRandomUser(); 48 | 49 | const contacts = getContactsByUsername(userToLookup.username); 50 | 51 | const contactId = contacts[0].id; 52 | 53 | removeContactById(contactId); 54 | 55 | const updatedContacts = getContactsByUsername(userToLookup.username); 56 | expect(updatedContacts.length).toBeLessThan(contacts.length); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/__tests__/generateSeedData.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | buildDatabase, 3 | userbaseSize, 4 | contactsPerUser, 5 | totalTransactions, 6 | bankAccountsPerUser, 7 | totalLikes, 8 | totalComments, 9 | totalNotifications, 10 | totalBankTransfers, 11 | } from "../../scripts/seedDataUtils"; 12 | import { TDatabase } from "../../backend/database"; 13 | 14 | describe.skip("Seed Database", () => { 15 | let database: TDatabase; 16 | beforeEach(() => { 17 | database = buildDatabase(); 18 | }); 19 | 20 | it("should contain a list of users", () => { 21 | expect(database).toHaveProperty("users"); 22 | expect(database.users.length).toBe(userbaseSize); 23 | }); 24 | 25 | it("should contain a list of contacts", () => { 26 | expect(database).toHaveProperty("contacts"); 27 | expect(database.contacts.length).toBe(contactsPerUser * userbaseSize); 28 | }); 29 | 30 | it("should contain a list of bankaccounts", () => { 31 | expect(database).toHaveProperty("bankaccounts"); 32 | expect(database.bankaccounts.length).toBe(bankAccountsPerUser * userbaseSize); 33 | }); 34 | 35 | it("should contain a list of transactions", () => { 36 | expect(database).toHaveProperty("transactions"); 37 | expect(database.transactions.length).toBe(totalTransactions); 38 | }); 39 | 40 | it("should contain a list of likes", () => { 41 | expect(database).toHaveProperty("likes"); 42 | expect(database.likes.length).toBe(totalLikes); 43 | }); 44 | 45 | it("should contain a list of comments", () => { 46 | expect(database).toHaveProperty("comments"); 47 | expect(database.comments.length).toBe(totalComments); 48 | }); 49 | 50 | it("should contain a list of notifications", () => { 51 | expect(database).toHaveProperty("notifications"); 52 | expect(database.notifications.length).toBeGreaterThanOrEqual(totalNotifications); 53 | }); 54 | 55 | it("should contain a list of bank transfers", () => { 56 | expect(database).toHaveProperty("banktransfers"); 57 | expect(database.banktransfers.length).toBeLessThanOrEqual(totalBankTransfers); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/__tests__/likes.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | seedDatabase, 3 | getTransactionsForUserContacts, 4 | getAllUsers, 5 | getTransactionsByUserId, 6 | createLike, 7 | getLikesByTransactionId, 8 | } from "../../backend/database"; 9 | 10 | import { User, Transaction } from "../../src/models"; 11 | 12 | describe("Transactions", () => { 13 | beforeEach(() => { 14 | seedDatabase(); 15 | }); 16 | 17 | it("should like a transaction for a contact", () => { 18 | const user: User = getAllUsers()[0]; 19 | const transactions: Transaction[] = getTransactionsForUserContacts(user.id); 20 | 21 | const like = createLike(user.id, transactions[0].id); 22 | 23 | expect(like.transactionId).toBe(transactions[0].id); 24 | }); 25 | 26 | it("should get a list of likes for a transaction", () => { 27 | const user: User = getAllUsers()[0]; 28 | const transactions: Transaction[] = getTransactionsByUserId(user.id); 29 | const transaction = transactions[0]; 30 | 31 | createLike(user.id, transaction.id); 32 | 33 | const likes = getLikesByTransactionId(transaction.id); 34 | 35 | expect(likes[0].transactionId).toBe(transaction.id); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/__tests__/notifications.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | seedDatabase, 3 | getTransactionsForUserContacts, 4 | getAllUsers, 5 | createPaymentNotification, 6 | createLikeNotification, 7 | createLike, 8 | createComment, 9 | createCommentNotification, 10 | getTransactionsByUserId, 11 | getNotificationsByUserId, 12 | createNotifications, 13 | updateNotificationById, 14 | getNotificationById, 15 | formatNotificationForApiResponse, 16 | } from "../../backend/database"; 17 | 18 | import { 19 | User, 20 | Transaction, 21 | PaymentNotificationStatus, 22 | PaymentNotification, 23 | Like, 24 | Comment, 25 | LikeNotification, 26 | CommentNotification, 27 | NotificationsType, 28 | NotificationType, 29 | } from "../../src/models"; 30 | 31 | describe("Notifications", () => { 32 | let user: User; 33 | beforeEach(() => { 34 | seedDatabase(); 35 | user = getAllUsers()[0]; 36 | }); 37 | 38 | describe("create notifications", () => { 39 | let transactions: Transaction[]; 40 | let transaction: Transaction; 41 | let paymentNotification: PaymentNotification; 42 | let like: Like; 43 | let likeNotification: LikeNotification; 44 | let comment: Comment; 45 | let commentNotification: CommentNotification; 46 | beforeEach(() => { 47 | user = getAllUsers()[0]; 48 | transactions = getTransactionsForUserContacts(user.id); 49 | transaction = transactions[0]; 50 | paymentNotification = createPaymentNotification( 51 | user.id, 52 | transaction.id, 53 | PaymentNotificationStatus.received 54 | ); 55 | like = createLike(user.id, transaction.id); 56 | likeNotification = createLikeNotification(user.id, transaction.id, like.id); 57 | comment = createComment(user.id, transaction.id, "This is my comment"); 58 | 59 | commentNotification = createCommentNotification(user.id, transaction.id, comment.id); 60 | }); 61 | 62 | it("should create a payment notification for a transaction", () => { 63 | expect(paymentNotification.transactionId).toBe(transaction.id); 64 | expect(paymentNotification.status).toBe(PaymentNotificationStatus.received); 65 | }); 66 | 67 | it("should create a like notification for a transaction", () => { 68 | expect(likeNotification.transactionId).toBe(transaction.id); 69 | expect(likeNotification.likeId).toBe(like.id); 70 | }); 71 | 72 | it("should create a comment notification for a transaction", () => { 73 | expect(commentNotification.transactionId).toBe(transaction.id); 74 | expect(commentNotification.commentId).toBe(comment.id); 75 | }); 76 | 77 | it("should format comment notification for api", () => { 78 | const apiNotification = formatNotificationForApiResponse(commentNotification); 79 | expect(apiNotification.userFullName).toBeDefined(); 80 | }); 81 | 82 | it("should create notifications for a transaction", () => { 83 | const notificationsPayload = [ 84 | { 85 | type: NotificationsType.payment, 86 | transactionId: transaction.id, 87 | status: PaymentNotificationStatus.received, 88 | }, 89 | { 90 | type: NotificationsType.like, 91 | transactionId: transaction.id, 92 | likeId: like.id, 93 | }, 94 | { 95 | type: NotificationsType.comment, 96 | transactionId: transaction.id, 97 | commentId: comment.id, 98 | }, 99 | ]; 100 | 101 | const notifications = createNotifications(user.id, notificationsPayload); 102 | 103 | expect(notifications[0]!.transactionId).toBe(transaction.id); 104 | // @ts-ignore 105 | expect(notifications[1]!.likeId).toBe(like.id); 106 | // @ts-ignore 107 | expect(notifications[2]!.commentId).toBe(comment.id); 108 | }); 109 | }); 110 | 111 | it("should get a list of notifications for a user", () => { 112 | const transactions: Transaction[] = getTransactionsByUserId(user.id); 113 | const transaction = transactions[0]; 114 | 115 | // create comment and like and notifications for transaction 116 | const comment = createComment(user.id, transaction.id, "This is my notification content"); 117 | createCommentNotification(user.id, transaction.id, comment.id); 118 | const like = createLike(user.id, transaction.id); 119 | createLikeNotification(user.id, transaction.id, like.id); 120 | 121 | const notifications = getNotificationsByUserId(user.id); 122 | 123 | expect(notifications.length).toBeGreaterThan(1); 124 | expect(notifications[notifications.length - 1]).toMatchObject({ 125 | transactionId: transaction.id, 126 | }); 127 | }); 128 | 129 | it("should update a notification", () => { 130 | const notifications = getNotificationsByUserId(user.id); 131 | const edits: Partial = { 132 | isRead: true, 133 | }; 134 | // @ts-ignore 135 | updateNotificationById(user.id, notifications[0].id, edits); 136 | 137 | // @ts-ignore 138 | const updatedNotification = getNotificationById(notifications[0].id); 139 | expect(updatedNotification.isRead).toBe(true); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/__tests__/users.test.ts: -------------------------------------------------------------------------------- 1 | import { seedDatabase, getAllUsers, searchUsers } from "../../backend/database"; 2 | 3 | import { User } from "../models"; 4 | 5 | describe("Users", () => { 6 | beforeEach(() => { 7 | seedDatabase(); 8 | }); 9 | 10 | it("should get a user by email address", () => { 11 | const userToLookup: User = getAllUsers()[0]; 12 | const { email } = userToLookup; 13 | 14 | const users = searchUsers(email); 15 | 16 | expect(users.length).toBeGreaterThanOrEqual(1); 17 | expect(users[0].id).toBe(userToLookup.id); 18 | }); 19 | 20 | it("should get a user by username", () => { 21 | const userToLookup: User = getAllUsers()[0]; 22 | const { username } = userToLookup; 23 | 24 | const users = searchUsers(username); 25 | 26 | expect(users.length).toBeGreaterThanOrEqual(1); 27 | expect(users[0].id).toBe(userToLookup.id); 28 | }); 29 | 30 | it("should get a user by phone number", () => { 31 | const userToLookup: User = getAllUsers()[0]; 32 | const { phoneNumber } = userToLookup; 33 | 34 | const users = searchUsers(phoneNumber); 35 | 36 | expect(users.length).toBeGreaterThanOrEqual(1); 37 | expect(users[0].id).toBe(userToLookup.id); 38 | }); 39 | 40 | it("should get a list of users by alpha (username, email) (fuzzy match)", () => { 41 | const userToLookup: User = getAllUsers()[0]; 42 | const users = searchUsers(userToLookup.firstName); 43 | 44 | expect(users.length).toBeGreaterThanOrEqual(1); 45 | }); 46 | 47 | it("should get a list of users by phone (fuzzy match)", () => { 48 | const users = searchUsers("201"); 49 | 50 | expect(users.length).toBeGreaterThanOrEqual(1); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/components/AlertBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Snackbar } from "@material-ui/core"; 3 | import { Interpreter } from "xstate"; 4 | import { SnackbarContext, SnackbarSchema, SnackbarEvents } from "../machines/snackbarMachine"; 5 | import { useService } from "@xstate/react"; 6 | import { Alert } from "@material-ui/lab"; 7 | 8 | interface Props { 9 | snackbarService: Interpreter; 10 | } 11 | 12 | const AlertBar: React.FC = ({ snackbarService }) => { 13 | const [snackbarState] = useService(snackbarService); 14 | 15 | return ( 16 | 21 | 27 | {snackbarState?.context.message} 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default AlertBar; 34 | -------------------------------------------------------------------------------- /src/components/BankAccountForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, TextField, Button, Grid } from "@material-ui/core"; 3 | import { Formik, Form, Field, FieldProps } from "formik"; 4 | import { string, object } from "yup"; 5 | import { BankAccountPayload, User } from "../models"; 6 | import { useHistory } from "react-router"; 7 | 8 | const validationSchema = object({ 9 | bankName: string().min(5, "Must contain at least 5 characters").required("Enter a bank name"), 10 | routingNumber: string() 11 | .length(9, "Must contain a valid routing number") 12 | .required("Enter a valid bank routing number"), 13 | accountNumber: string() 14 | .min(9, "Must contain at least 9 digits") 15 | .max(12, "Must contain no more than 12 digits") 16 | .required("Enter a valid bank account number"), 17 | }); 18 | 19 | const useStyles = makeStyles((theme) => ({ 20 | paper: { 21 | marginTop: theme.spacing(8), 22 | display: "flex", 23 | flexDirection: "column", 24 | alignItems: "center", 25 | }, 26 | form: { 27 | width: "100%", // Fix IE 11 issue. 28 | marginTop: theme.spacing(1), 29 | }, 30 | submit: { 31 | margin: theme.spacing(3, 0, 2), 32 | }, 33 | })); 34 | 35 | export interface BankAccountFormProps { 36 | userId: User["id"]; 37 | createBankAccount: Function; 38 | onboarding?: boolean; 39 | } 40 | 41 | const BankAccountForm: React.FC = ({ 42 | userId, 43 | createBankAccount, 44 | onboarding, 45 | }) => { 46 | const history = useHistory(); 47 | const classes = useStyles(); 48 | const initialValues: BankAccountPayload = { 49 | userId, 50 | bankName: "", 51 | accountNumber: "", 52 | routingNumber: "", 53 | }; 54 | 55 | return ( 56 | { 60 | setSubmitting(true); 61 | 62 | createBankAccount({ ...values, userId }); 63 | 64 | if (!onboarding) { 65 | history.push("/bankaccounts"); 66 | } 67 | }} 68 | > 69 | {({ isValid, isSubmitting }) => ( 70 |
71 | 72 | {({ field, meta: { error, value, initialValue, touched } }: FieldProps) => ( 73 | 86 | )} 87 | 88 | 89 | {({ field, meta: { error, value, initialValue, touched } }: FieldProps) => ( 90 | 103 | )} 104 | 105 | 106 | {({ field, meta: { error, value, initialValue, touched } }: FieldProps) => ( 107 | 120 | )} 121 | 122 | 123 | 124 | 135 | 136 | 137 |
138 | )} 139 |
140 | ); 141 | }; 142 | 143 | export default BankAccountForm; 144 | -------------------------------------------------------------------------------- /src/components/BankAccountItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Grid, Typography, Button, ListItem } from "@material-ui/core"; 4 | import { BankAccount } from "../models"; 5 | 6 | export interface BankAccountListItemProps { 7 | bankAccount: BankAccount; 8 | deleteBankAccount: Function; 9 | } 10 | 11 | const BankAccountListItem: React.FC = ({ 12 | bankAccount, 13 | deleteBankAccount, 14 | }) => { 15 | return ( 16 | 17 | 18 | 19 | 20 | {bankAccount.bankName} {bankAccount.isDeleted ? "(Deleted)" : undefined} 21 | 22 | 23 | {!bankAccount.isDeleted && ( 24 | 25 | 36 | 37 | )} 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default BankAccountListItem; 44 | -------------------------------------------------------------------------------- /src/components/BankAccountList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { List } from "@material-ui/core"; 3 | 4 | import { BankAccount } from "../models"; 5 | import BankAccountItem from "./BankAccountItem"; 6 | import EmptyList from "./EmptyList"; 7 | 8 | export interface BankAccountListProps { 9 | bankAccounts: BankAccount[]; 10 | deleteBankAccount: Function; 11 | } 12 | 13 | const BankAccountList: React.FC = ({ bankAccounts, deleteBankAccount }) => { 14 | return ( 15 | <> 16 | {bankAccounts?.length > 0 ? ( 17 | 18 | {bankAccounts.map((bankAccount: BankAccount) => ( 19 | 24 | ))} 25 | 26 | ) : ( 27 | 28 | )} 29 | 30 | ); 31 | }; 32 | 33 | export default BankAccountList; 34 | -------------------------------------------------------------------------------- /src/components/CommentForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, TextField } from "@material-ui/core"; 3 | import { Formik, Form, Field, FieldProps } from "formik"; 4 | import { string, object } from "yup"; 5 | 6 | const validationSchema = object({ 7 | content: string(), 8 | }); 9 | 10 | const useStyles = makeStyles((theme) => ({ 11 | paper: { 12 | marginTop: theme.spacing(8), 13 | display: "flex", 14 | flexDirection: "column", 15 | alignItems: "center", 16 | }, 17 | form: { 18 | width: "100%", // Fix IE 11 issue. 19 | marginTop: theme.spacing(1), 20 | }, 21 | })); 22 | 23 | export interface CommentFormProps { 24 | transactionId: string; 25 | transactionComment: (payload: object) => void; 26 | } 27 | 28 | const CommentForm: React.FC = ({ transactionId, transactionComment }) => { 29 | const classes = useStyles(); 30 | const initialValues = { content: "" }; 31 | 32 | return ( 33 |
34 | { 38 | setSubmitting(true); 39 | transactionComment({ transactionId, ...values }); 40 | }} 41 | > 42 | {() => ( 43 |
44 | 45 | {({ field, meta }: FieldProps) => ( 46 | 58 | )} 59 | 60 |
61 | )} 62 |
63 |
64 | ); 65 | }; 66 | 67 | export default CommentForm; 68 | -------------------------------------------------------------------------------- /src/components/CommentList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { List } from "@material-ui/core"; 3 | 4 | import CommentListItem from "./CommentListItem"; 5 | import { Comment } from "../models"; 6 | 7 | export interface CommentsListProps { 8 | comments: Comment[]; 9 | } 10 | 11 | const CommentsList: React.FC = ({ comments }) => { 12 | return ( 13 | 14 | {comments && 15 | comments.map((comment: Comment) => )} 16 | 17 | ); 18 | }; 19 | 20 | export default CommentsList; 21 | -------------------------------------------------------------------------------- /src/components/CommentListItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ListItem, ListItemText } from "@material-ui/core"; 3 | 4 | import { Comment } from "../models"; 5 | 6 | export interface CommentListItemProps { 7 | comment: Comment; 8 | } 9 | 10 | const CommentListItem: React.FC = ({ comment }) => { 11 | return ( 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default CommentListItem; 19 | -------------------------------------------------------------------------------- /src/components/EmptyList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Typography, Grid, colors } from "@material-ui/core"; 3 | 4 | const { grey } = colors; 5 | 6 | const EmptyList: React.FC<{ entity: string; children?: React.ReactNode }> = ({ 7 | entity, 8 | children, 9 | }) => { 10 | return ( 11 | 20 | 28 | 29 | 30 | No {entity} 31 | 32 | 33 | 34 | 41 | {children} 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | export default EmptyList; 50 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Container, Typography } from "@material-ui/core"; 3 | 4 | import logo from "../svgs/cypress-logo.svg"; 5 | 6 | export default function Footer() { 7 | return ( 8 | 9 | 10 | Built by 11 | 17 | 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useMachine } from "@xstate/react"; 3 | import { Interpreter } from "xstate"; 4 | import { makeStyles, Container, Grid, useMediaQuery, useTheme } from "@material-ui/core"; 5 | 6 | import Footer from "./Footer"; 7 | import NavBar from "./NavBar"; 8 | import NavDrawer from "./NavDrawer"; 9 | import { DataContext, DataEvents } from "../machines/dataMachine"; 10 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 11 | import { drawerMachine } from "../machines/drawerMachine"; 12 | 13 | const useStyles = makeStyles((theme) => ({ 14 | root: { 15 | display: "flex", 16 | }, 17 | toolbar: { 18 | paddingRight: 24, // keep right padding when drawer closed 19 | }, 20 | appBarSpacer: { 21 | minHeight: theme.spacing(13), 22 | [theme.breakpoints.up("sm")]: { 23 | minHeight: theme.spacing(14), 24 | }, 25 | }, 26 | content: { 27 | flexGrow: 1, 28 | height: "100vh", 29 | overflow: "auto", 30 | }, 31 | container: { 32 | minHeight: "77vh", 33 | paddingTop: theme.spacing(1), 34 | paddingBottom: theme.spacing(1), 35 | [theme.breakpoints.up("sm")]: { 36 | paddingTop: theme.spacing(4), 37 | padding: theme.spacing(4), 38 | }, 39 | }, 40 | })); 41 | 42 | interface Props { 43 | children: React.ReactNode; 44 | authService: Interpreter; 45 | notificationsService: Interpreter; 46 | } 47 | 48 | const MainLayout: React.FC = ({ children, notificationsService, authService }) => { 49 | const classes = useStyles(); 50 | const theme = useTheme(); 51 | const [drawerState, sendDrawer] = useMachine(drawerMachine); 52 | 53 | const aboveSmallBreakpoint = useMediaQuery(theme.breakpoints.up("sm")); 54 | const xsBreakpoint = useMediaQuery(theme.breakpoints.only("xs")); 55 | 56 | const desktopDrawerOpen = drawerState?.matches({ desktop: "open" }); 57 | const mobileDrawerOpen = drawerState?.matches({ mobile: "open" }); 58 | const toggleDesktopDrawer = () => { 59 | sendDrawer("TOGGLE_DESKTOP"); 60 | }; 61 | const toggleMobileDrawer = () => { 62 | sendDrawer("TOGGLE_MOBILE"); 63 | }; 64 | 65 | const openDesktopDrawer = (payload: any) => sendDrawer("OPEN_DESKTOP", payload); 66 | const closeMobileDrawer = () => sendDrawer("CLOSE_MOBILE"); 67 | 68 | useEffect(() => { 69 | if (!desktopDrawerOpen && aboveSmallBreakpoint) { 70 | openDesktopDrawer({ aboveSmallBreakpoint }); 71 | } 72 | // eslint-disable-next-line react-hooks/exhaustive-deps 73 | }, [aboveSmallBreakpoint, desktopDrawerOpen]); 74 | 75 | return ( 76 | <> 77 | 82 | 88 |
89 |
90 | 91 | 92 | 93 | {children} 94 | 95 | 96 | 97 |
98 |
99 |
100 |
101 | 102 | ); 103 | }; 104 | 105 | export default MainLayout; 106 | -------------------------------------------------------------------------------- /src/components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | import { Interpreter } from "xstate"; 4 | import { useService } from "@xstate/react"; 5 | import { 6 | makeStyles, 7 | AppBar, 8 | Toolbar, 9 | Typography, 10 | IconButton, 11 | Badge, 12 | Button, 13 | useTheme, 14 | useMediaQuery, 15 | Link, 16 | } from "@material-ui/core"; 17 | import { 18 | Menu as MenuIcon, 19 | Notifications as NotificationsIcon, 20 | AttachMoney as AttachMoneyIcon, 21 | } from "@material-ui/icons"; 22 | import { Link as RouterLink, useLocation } from "react-router-dom"; 23 | 24 | import { DataContext, DataEvents } from "../machines/dataMachine"; 25 | import TransactionNavTabs from "./TransactionNavTabs"; 26 | // import { ReactComponent as RWALogo } from "../svgs/rwa-logo.svg"; 27 | // import { ReactComponent as RWALogoIcon } from "../svgs/rwa-icon-logo.svg"; 28 | 29 | const drawerWidth = 240; 30 | 31 | const useStyles = makeStyles((theme) => ({ 32 | toolbar: { 33 | paddingRight: 24, // keep right padding when drawer closed 34 | }, 35 | appBar: { 36 | zIndex: theme.zIndex.drawer + 1, 37 | transition: theme.transitions.create(["width", "margin"], { 38 | easing: theme.transitions.easing.sharp, 39 | duration: theme.transitions.duration.leavingScreen, 40 | }), 41 | }, 42 | appBarShift: { 43 | marginLeft: drawerWidth, 44 | width: `calc(100% - ${drawerWidth}px)`, 45 | transition: theme.transitions.create(["width", "margin"], { 46 | easing: theme.transitions.easing.sharp, 47 | duration: theme.transitions.duration.enteringScreen, 48 | }), 49 | }, 50 | menuButtonHidden: { 51 | display: "none", 52 | }, 53 | title: { 54 | flexGrow: 1, 55 | textAlign: "center", 56 | }, 57 | logo: { 58 | color: "white", 59 | verticalAlign: "bottom", 60 | }, 61 | newTransactionButton: { 62 | fontSize: 16, 63 | backgroundColor: "#00C853", 64 | paddingTop: 5, 65 | paddingBottom: 5, 66 | paddingRight: 20, 67 | fontWeight: "bold", 68 | "&:hover": { 69 | backgroundColor: "#4CAF50", 70 | borderColor: "#00C853", 71 | boxShadow: "none", 72 | }, 73 | }, 74 | customBadge: { 75 | backgroundColor: "red", 76 | color: "white", 77 | }, 78 | })); 79 | 80 | interface NavBarProps { 81 | drawerOpen: boolean; 82 | toggleDrawer: Function; 83 | notificationsService: Interpreter; 84 | } 85 | 86 | const NavBar: React.FC = ({ drawerOpen, toggleDrawer, notificationsService }) => { 87 | const match = useLocation(); 88 | const classes = useStyles(); 89 | const theme = useTheme(); 90 | const [notificationsState] = useService(notificationsService); 91 | 92 | const allNotifications = notificationsState?.context?.results; 93 | const xsBreakpoint = useMediaQuery(theme.breakpoints.only("xs")); 94 | 95 | return ( 96 | 97 | 98 | toggleDrawer()} 104 | > 105 | 106 | 107 | 115 | 116 | {/* {xsBreakpoint ? ( 117 | 118 | ) : ( 119 | 120 | )} */} 121 | 122 | 123 | 133 | 139 | 144 | 145 | 146 | 147 | 148 | {(match.pathname === "/" || RegExp("/(?:public|contacts|personal)").test(match.pathname)) && ( 149 | 150 | )} 151 | 152 | ); 153 | }; 154 | 155 | export default NavBar; 156 | -------------------------------------------------------------------------------- /src/components/NotificationList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { List } from "@material-ui/core"; 3 | 4 | import NotificationListItem from "./NotificationListItem"; 5 | import { NotificationResponseItem } from "../models"; 6 | import EmptyList from "./EmptyList"; 7 | // import { ReactComponent as RemindersIllustration } from "../svgs/undraw_reminders_697p.svg"; 8 | 9 | export interface NotificationsListProps { 10 | notifications: NotificationResponseItem[]; 11 | updateNotification: Function; 12 | } 13 | 14 | const NotificationsList: React.FC = ({ 15 | notifications, 16 | updateNotification, 17 | }) => { 18 | return ( 19 | <> 20 | {notifications?.length > 0 ? ( 21 | 22 | {notifications.map((notification: NotificationResponseItem) => ( 23 | 28 | ))} 29 | 30 | ) : ( 31 | 32 | {/* */} 33 | 34 | )} 35 | 36 | ); 37 | }; 38 | 39 | export default NotificationsList; 40 | -------------------------------------------------------------------------------- /src/components/NotificationListItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Check as CheckIcon, 5 | ThumbUpAltOutlined as LikeIcon, 6 | Payment as PaymentIcon, 7 | CommentRounded as CommentIcon, 8 | MonetizationOn as MonetizationOnIcon, 9 | } from "@material-ui/icons"; 10 | import { 11 | Button, 12 | makeStyles, 13 | ListItemIcon, 14 | ListItemText, 15 | useTheme, 16 | useMediaQuery, 17 | ListItem, 18 | IconButton, 19 | } from "@material-ui/core"; 20 | import { 21 | isCommentNotification, 22 | isLikeNotification, 23 | isPaymentNotification, 24 | isPaymentRequestedNotification, 25 | isPaymentReceivedNotification, 26 | } from "../utils/transactionUtils"; 27 | import { NotificationResponseItem } from "../models"; 28 | 29 | export interface NotificationListItemProps { 30 | notification: NotificationResponseItem; 31 | updateNotification: Function; 32 | } 33 | 34 | const useStyles = makeStyles({ 35 | card: { 36 | minWidth: "100%", 37 | }, 38 | title: { 39 | fontSize: 18, 40 | }, 41 | green: { 42 | color: "#4CAF50", 43 | }, 44 | red: { 45 | color: "red", 46 | }, 47 | blue: { 48 | color: "blue", 49 | }, 50 | }); 51 | 52 | const NotificationListItem: React.FC = ({ 53 | notification, 54 | updateNotification, 55 | }) => { 56 | const classes = useStyles(); 57 | const theme = useTheme(); 58 | let listItemText = undefined; 59 | let listItemIcon = undefined; 60 | const xsBreakpoint = useMediaQuery(theme.breakpoints.only("xs")); 61 | 62 | if (isCommentNotification(notification)) { 63 | listItemIcon = ; 64 | listItemText = `${notification.userFullName} commented on a transaction.`; 65 | } 66 | 67 | if (isLikeNotification(notification)) { 68 | listItemIcon = ; 69 | listItemText = `${notification.userFullName} liked a transaction.`; 70 | } 71 | 72 | if (isPaymentNotification(notification)) { 73 | if (isPaymentRequestedNotification(notification)) { 74 | listItemIcon = ; 75 | listItemText = `${notification.userFullName} requested payment.`; 76 | } else if (isPaymentReceivedNotification(notification)) { 77 | listItemIcon = ; 78 | listItemText = `${notification.userFullName} received payment.`; 79 | } 80 | } 81 | 82 | return ( 83 | 84 | {listItemIcon!} 85 | 86 | {xsBreakpoint && ( 87 | updateNotification({ id: notification.id, isRead: true })} 91 | data-test={`notification-mark-read-${notification.id}`} 92 | > 93 | 94 | 95 | )} 96 | {!xsBreakpoint && ( 97 | 105 | )} 106 | 107 | ); 108 | }; 109 | 110 | export default NotificationListItem; 111 | -------------------------------------------------------------------------------- /src/components/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Redirect, RouteProps } from "react-router-dom"; 3 | 4 | interface IPrivateRouteProps extends RouteProps { 5 | isLoggedIn: boolean; 6 | } 7 | 8 | function PrivateRoute({ isLoggedIn, children, ...rest }: IPrivateRouteProps) { 9 | return ( 10 | 13 | isLoggedIn ? ( 14 | children 15 | ) : ( 16 | /* istanbul ignore next */ 17 | 23 | ) 24 | } 25 | /> 26 | ); 27 | } 28 | 29 | export default PrivateRoute; 30 | -------------------------------------------------------------------------------- /src/components/SkeletonList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Skeleton } from "@material-ui/lab"; 3 | import { makeStyles } from "@material-ui/core"; 4 | 5 | const useStyles = makeStyles((theme) => ({ 6 | root: { 7 | minHeight: "100vh", 8 | marginLeft: theme.spacing(2), 9 | marginRight: theme.spacing(2), 10 | width: "95%", 11 | }, 12 | })); 13 | 14 | const ListSkeleton = () => { 15 | const classes = useStyles(); 16 | return ( 17 |
18 |
19 | 20 | 21 | 22 |
23 |
24 | 25 | 26 | 27 |
28 |
29 | 30 | 31 | 32 |
33 |
34 | 35 | 36 | 37 |
38 |
39 | 40 | 41 | 42 |
43 |
44 | 45 | 46 | 47 |
48 |
49 | 50 | 51 | 52 |
53 |
54 | 55 | 56 | 57 |
58 |
59 | 60 | 61 | 62 |
63 | ); 64 | }; 65 | 66 | export default ListSkeleton; 67 | -------------------------------------------------------------------------------- /src/components/TransactionAmount.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, Typography } from "@material-ui/core"; 3 | import { TransactionResponseItem } from "../models"; 4 | import { isRequestTransaction, formatAmount } from "../utils/transactionUtils"; 5 | 6 | const useStyles = makeStyles((theme) => ({ 7 | amountPositive: { 8 | fontSize: 24, 9 | [theme.breakpoints.down("sm")]: { 10 | fontSize: theme.typography.body1.fontSize, 11 | }, 12 | color: "#4CAF50", 13 | }, 14 | amountNegative: { 15 | fontSize: 24, 16 | [theme.breakpoints.down("sm")]: { 17 | fontSize: theme.typography.body1.fontSize, 18 | }, 19 | color: "red", 20 | }, 21 | })); 22 | 23 | const TransactionAmount: React.FC<{ 24 | transaction: TransactionResponseItem; 25 | }> = ({ transaction }) => { 26 | const classes = useStyles(); 27 | 28 | return ( 29 | 38 | {isRequestTransaction(transaction) ? "+" : "-"} 39 | {transaction.amount && formatAmount(transaction.amount)} 40 | 41 | ); 42 | }; 43 | 44 | export default TransactionAmount; 45 | -------------------------------------------------------------------------------- /src/components/TransactionContactsList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, ReactNode } from "react"; 2 | import { useMachine } from "@xstate/react"; 3 | import { 4 | TransactionPagination, 5 | TransactionResponseItem, 6 | TransactionDateRangePayload, 7 | TransactionAmountRangePayload, 8 | } from "../models"; 9 | import TransactionList from "./TransactionList"; 10 | import { contactsTransactionsMachine } from "../machines/contactsTransactionsMachine"; 11 | 12 | export interface TransactionContactListProps { 13 | filterComponent: ReactNode; 14 | dateRangeFilters: TransactionDateRangePayload; 15 | amountRangeFilters: TransactionAmountRangePayload; 16 | } 17 | 18 | const TransactionContactsList: React.FC = ({ 19 | filterComponent, 20 | dateRangeFilters, 21 | amountRangeFilters, 22 | }) => { 23 | const [current, send, contactTransactionService] = useMachine(contactsTransactionsMachine); 24 | const { pageData, results } = current.context; 25 | 26 | // @ts-ignore 27 | if (window.Cypress) { 28 | // @ts-ignore 29 | window.contactTransactionService = contactTransactionService; 30 | } 31 | 32 | useEffect(() => { 33 | send("FETCH", { ...dateRangeFilters, ...amountRangeFilters }); 34 | }, [send, dateRangeFilters, amountRangeFilters]); 35 | 36 | const loadNextPage = (page: number) => 37 | send("FETCH", { page, ...dateRangeFilters, ...amountRangeFilters }); 38 | 39 | return ( 40 | <> 41 | 50 | 51 | ); 52 | }; 53 | 54 | export default TransactionContactsList; 55 | -------------------------------------------------------------------------------- /src/components/TransactionCreateStepOne.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, Paper } from "@material-ui/core"; 3 | import UsersList from "./UsersList"; 4 | import { User } from "../models"; 5 | import UserListSearchForm from "./UserListSearchForm"; 6 | 7 | const useStyles = makeStyles((theme) => ({ 8 | paper: { 9 | //marginTop: theme.spacing(2), 10 | padding: theme.spacing(2), 11 | display: "flex", 12 | overflow: "auto", 13 | flexDirection: "column", 14 | }, 15 | })); 16 | 17 | export interface TransactionCreateStepOneProps { 18 | setReceiver: Function; 19 | userListSearch: Function; 20 | users: User[]; 21 | } 22 | 23 | const TransactionCreateStepOne: React.FC = ({ 24 | setReceiver, 25 | userListSearch, 26 | users, 27 | }) => { 28 | const classes = useStyles(); 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default TransactionCreateStepOne; 39 | -------------------------------------------------------------------------------- /src/components/TransactionCreateStepThree.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link as RouterLink, useHistory } from "react-router-dom"; 3 | import { Paper, Typography, Grid, Avatar, Box, Button, makeStyles } from "@material-ui/core"; 4 | import { Interpreter } from "xstate"; 5 | import { 6 | CreateTransactionMachineContext, 7 | CreateTransactionMachineEvents, 8 | } from "../machines/createTransactionMachine"; 9 | import { useService } from "@xstate/react"; 10 | import { formatAmount } from "../utils/transactionUtils"; 11 | 12 | const useStyles = makeStyles((theme) => ({ 13 | paper: { 14 | display: "flex", 15 | flexDirection: "column", 16 | alignItems: "center", 17 | }, 18 | })); 19 | 20 | export interface TransactionCreateStepThreeProps { 21 | createTransactionService: Interpreter< 22 | CreateTransactionMachineContext, 23 | any, 24 | CreateTransactionMachineEvents, 25 | any 26 | >; 27 | } 28 | 29 | const TransactionCreateStepThree: React.FC = ({ 30 | createTransactionService, 31 | }) => { 32 | const history = useHistory(); 33 | const classes = useStyles(); 34 | const [createTransactionState, sendCreateTransaction] = useService(createTransactionService); 35 | 36 | const receiver = createTransactionState?.context?.receiver; 37 | const transactionDetails = createTransactionState?.context?.transactionDetails; 38 | 39 | return ( 40 | 41 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {receiver.firstName} {receiver.lastName} 58 | 59 | 60 | 61 | 62 | 63 | 64 | 71 | 72 | 73 | 74 | {transactionDetails?.transactionType === "payment" ? "Paid " : "Requested "} 75 | {transactionDetails?.amount && 76 | formatAmount(parseInt(transactionDetails.amount, 10) * 100)}{" "} 77 | for {transactionDetails?.description} 78 | 79 | 80 | 81 | 82 | 89 | 90 | 91 | 100 | 101 | 102 | 114 | 115 | 116 | 117 | 118 | ); 119 | }; 120 | 121 | export default TransactionCreateStepThree; 122 | -------------------------------------------------------------------------------- /src/components/TransactionInfiniteList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { get } from "lodash/fp"; 3 | import { useTheme, makeStyles, useMediaQuery, Divider } from "@material-ui/core"; 4 | import { InfiniteLoader, List, Index } from "react-virtualized"; 5 | import "react-virtualized/styles.css"; // only needs to be imported once 6 | 7 | import TransactionItem from "./TransactionItem"; 8 | import { TransactionResponseItem, TransactionPagination } from "../models"; 9 | 10 | export interface TransactionListProps { 11 | transactions: TransactionResponseItem[]; 12 | loadNextPage: Function; 13 | pagination: TransactionPagination; 14 | } 15 | 16 | const useStyles = makeStyles((theme) => ({ 17 | transactionList: { 18 | width: "100%", 19 | minHeight: "80vh", 20 | display: "flex", 21 | overflow: "auto", 22 | flexDirection: "column", 23 | }, 24 | })); 25 | 26 | const TransactionInfiniteList: React.FC = ({ 27 | transactions, 28 | loadNextPage, 29 | pagination, 30 | }) => { 31 | const classes = useStyles(); 32 | const theme = useTheme(); 33 | const isXsBreakpoint = useMediaQuery(theme.breakpoints.down("xs")); 34 | const isMobile = useMediaQuery(theme.breakpoints.down("sm")); 35 | 36 | const itemCount = pagination.hasNextPages ? transactions.length + 1 : transactions.length; 37 | 38 | const loadMoreItems = () => { 39 | return new Promise((resolve) => { 40 | return resolve(pagination.hasNextPages && loadNextPage(pagination.page + 1)); 41 | }); 42 | }; 43 | 44 | const isRowLoaded = (params: Index) => 45 | !pagination.hasNextPages || params.index < transactions.length; 46 | 47 | // @ts-ignore 48 | function rowRenderer({ key, index, style }) { 49 | const transaction = get(index, transactions); 50 | 51 | if (index < transactions.length) { 52 | return ( 53 |
54 | 55 | 56 |
57 | ); 58 | } 59 | } 60 | 61 | return ( 62 | 68 | {({ onRowsRendered, registerChild }) => ( 69 |
70 | 79 |
80 | )} 81 |
82 | ); 83 | }; 84 | 85 | export default TransactionInfiniteList; 86 | -------------------------------------------------------------------------------- /src/components/TransactionItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useHistory } from "react-router"; 3 | import { 4 | ListItem, 5 | Typography, 6 | Grid, 7 | Avatar, 8 | ListItemAvatar, 9 | Paper, 10 | Badge, 11 | withStyles, 12 | Theme, 13 | createStyles, 14 | makeStyles, 15 | } from "@material-ui/core"; 16 | import { ThumbUpAltOutlined as LikeIcon, CommentRounded as CommentIcon } from "@material-ui/icons"; 17 | import { TransactionResponseItem } from "../models"; 18 | import TransactionTitle from "./TransactionTitle"; 19 | import TransactionAmount from "./TransactionAmount"; 20 | 21 | const useStyles = makeStyles((theme) => ({ 22 | root: { 23 | flexGrow: 1, 24 | }, 25 | paper: { 26 | padding: theme.spacing(0), 27 | margin: "auto", 28 | width: "100%", 29 | }, 30 | avatar: { 31 | width: theme.spacing(2), 32 | }, 33 | socialStats: { 34 | [theme.breakpoints.down("sm")]: { 35 | marginTop: theme.spacing(2), 36 | }, 37 | }, 38 | countIcons: { 39 | color: theme.palette.grey[400], 40 | }, 41 | countText: { 42 | color: theme.palette.grey[400], 43 | marginTop: 2, 44 | height: theme.spacing(2), 45 | width: theme.spacing(2), 46 | }, 47 | })); 48 | 49 | type TransactionProps = { 50 | transaction: TransactionResponseItem; 51 | }; 52 | 53 | const SmallAvatar = withStyles((theme: Theme) => 54 | createStyles({ 55 | root: { 56 | width: 22, 57 | height: 22, 58 | border: `2px solid ${theme.palette.background.paper}`, 59 | }, 60 | }) 61 | )(Avatar); 62 | 63 | const TransactionItem: React.FC = ({ transaction }) => { 64 | const classes = useStyles(); 65 | const history = useHistory(); 66 | 67 | const showTransactionDetail = (transactionId: string) => { 68 | history.push(`/transaction/${transactionId}`); 69 | }; 70 | 71 | return ( 72 | showTransactionDetail(transaction.id)} 76 | > 77 | 78 | 79 | 80 | 81 | } 88 | > 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | {transaction.description} 99 | 100 | 108 | 109 | 110 | 111 | 112 | 113 | {transaction.likes.length} 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | {transaction.comments.length} 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | ); 135 | }; 136 | 137 | export default TransactionItem; 138 | -------------------------------------------------------------------------------- /src/components/TransactionList.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import { makeStyles, Paper, Button, ListSubheader, Grid } from "@material-ui/core"; 3 | import { Link as RouterLink } from "react-router-dom"; 4 | import { isEmpty } from "lodash/fp"; 5 | 6 | import SkeletonList from "./SkeletonList"; 7 | import { TransactionResponseItem, TransactionPagination } from "../models"; 8 | import EmptyList from "./EmptyList"; 9 | import TransactionInfiniteList from "./TransactionInfiniteList"; 10 | // import { ReactComponent as TransferMoneyIllustration } from "../svgs/undraw_transfer_money_rywa.svg"; 11 | 12 | export interface TransactionListProps { 13 | header: string; 14 | transactions: TransactionResponseItem[]; 15 | isLoading: Boolean; 16 | showCreateButton?: Boolean; 17 | loadNextPage: Function; 18 | pagination: TransactionPagination; 19 | filterComponent: ReactNode; 20 | } 21 | 22 | const useStyles = makeStyles((theme) => ({ 23 | paper: { 24 | paddingLeft: theme.spacing(1), 25 | }, 26 | })); 27 | 28 | const TransactionList: React.FC = ({ 29 | header, 30 | transactions, 31 | isLoading, 32 | showCreateButton, 33 | loadNextPage, 34 | pagination, 35 | filterComponent, 36 | }) => { 37 | const classes = useStyles(); 38 | 39 | const showEmptyList = !isLoading && transactions?.length === 0; 40 | const showSkeleton = isLoading && isEmpty(pagination); 41 | 42 | return ( 43 | 44 | {filterComponent} 45 | {header} 46 | {showSkeleton && } 47 | {transactions.length > 0 && ( 48 | 53 | )} 54 | {showEmptyList && ( 55 | 56 | 64 | 65 | {/* */} 66 | 67 | 68 | {showCreateButton && ( 69 | 78 | )} 79 | 80 | 81 | 82 | )} 83 | 84 | ); 85 | }; 86 | 87 | export default TransactionList; 88 | -------------------------------------------------------------------------------- /src/components/TransactionListFilters.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, Paper, Grid } from "@material-ui/core"; 3 | import { TransactionDateRangePayload, TransactionAmountRangePayload } from "../models"; 4 | import TransactionListDateRangeFilter from "./TransactionDateRangeFilter"; 5 | import TransactionListAmountRangeFilter from "./TransactionListAmountRangeFilter"; 6 | import { debounce } from "lodash/fp"; 7 | 8 | const useStyles = makeStyles((theme) => ({ 9 | paper: { 10 | padding: theme.spacing(2), 11 | display: "flex", 12 | overflow: "auto", 13 | flexDirection: "column", 14 | }, 15 | })); 16 | 17 | export type TransactionListFiltersProps = { 18 | sendFilterEvent: Function; 19 | dateRangeFilters: TransactionDateRangePayload; 20 | amountRangeFilters: TransactionAmountRangePayload; 21 | }; 22 | 23 | const TransactionListFilters: React.FC = ({ 24 | sendFilterEvent, 25 | dateRangeFilters, 26 | amountRangeFilters, 27 | }) => { 28 | const classes = useStyles(); 29 | 30 | const filterDateRange = (payload: TransactionDateRangePayload) => 31 | sendFilterEvent("DATE_FILTER", payload); 32 | const resetDateRange = () => sendFilterEvent("DATE_RESET"); 33 | 34 | const filterAmountRange = debounce(200, (payload: TransactionAmountRangePayload) => 35 | sendFilterEvent("AMOUNT_FILTER", payload) 36 | ); 37 | const resetAmountRange = () => sendFilterEvent("AMOUNT_RESET"); 38 | 39 | return ( 40 | 41 | 42 | 43 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | ); 59 | }; 60 | 61 | export default TransactionListFilters; 62 | -------------------------------------------------------------------------------- /src/components/TransactionNavTabs.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Tabs, Tab } from "@material-ui/core"; 3 | import { Link, useRouteMatch } from "react-router-dom"; 4 | 5 | export default function TransactionNavTabs() { 6 | const match = useRouteMatch(); 7 | 8 | // Route Lookup for tabs 9 | const navUrls: any = { 10 | "/": 0, 11 | "/public": 0, 12 | "/contacts": 1, 13 | "/personal": 2, 14 | }; 15 | 16 | // Set selected tab based on url 17 | const [value, setValue] = React.useState(navUrls[match.url]); 18 | 19 | const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { 20 | setValue(newValue); 21 | }; 22 | 23 | return ( 24 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/TransactionPersonalList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, ReactNode } from "react"; 2 | import { useMachine } from "@xstate/react"; 3 | import { 4 | TransactionPagination, 5 | TransactionResponseItem, 6 | TransactionDateRangePayload, 7 | TransactionAmountRangePayload, 8 | } from "../models"; 9 | import TransactionList from "./TransactionList"; 10 | import { personalTransactionsMachine } from "../machines/personalTransactionsMachine"; 11 | 12 | export interface TransactionPersonalListProps { 13 | filterComponent: ReactNode; 14 | dateRangeFilters: TransactionDateRangePayload; 15 | amountRangeFilters: TransactionAmountRangePayload; 16 | } 17 | 18 | const TransactionPersonalList: React.FC = ({ 19 | filterComponent, 20 | dateRangeFilters, 21 | amountRangeFilters, 22 | }) => { 23 | const [current, send, personalTransactionService] = useMachine(personalTransactionsMachine); 24 | const { pageData, results } = current.context; 25 | 26 | // @ts-ignore 27 | if (window.Cypress) { 28 | // @ts-ignore 29 | window.personalTransactionService = personalTransactionService; 30 | } 31 | 32 | useEffect(() => { 33 | send("FETCH", { ...dateRangeFilters, ...amountRangeFilters }); 34 | }, [send, dateRangeFilters, amountRangeFilters]); 35 | 36 | const loadNextPage = (page: number) => 37 | send("FETCH", { page, ...dateRangeFilters, ...amountRangeFilters }); 38 | 39 | return ( 40 | <> 41 | 50 | 51 | ); 52 | }; 53 | 54 | export default TransactionPersonalList; 55 | -------------------------------------------------------------------------------- /src/components/TransactionPublicList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, ReactNode } from "react"; 2 | import { useMachine } from "@xstate/react"; 3 | import { 4 | TransactionPagination, 5 | TransactionResponseItem, 6 | TransactionDateRangePayload, 7 | TransactionAmountRangePayload, 8 | } from "../models"; 9 | import TransactionList from "./TransactionList"; 10 | import { publicTransactionsMachine } from "../machines/publicTransactionsMachine"; 11 | 12 | export interface TransactionPublicListProps { 13 | filterComponent: ReactNode; 14 | dateRangeFilters: TransactionDateRangePayload; 15 | amountRangeFilters: TransactionAmountRangePayload; 16 | } 17 | 18 | const TransactionPublicList: React.FC = ({ 19 | filterComponent, 20 | dateRangeFilters, 21 | amountRangeFilters, 22 | }) => { 23 | const [current, send, publicTransactionService] = useMachine(publicTransactionsMachine); 24 | const { pageData, results } = current.context; 25 | 26 | // @ts-ignore 27 | if (window.Cypress) { 28 | // @ts-ignore 29 | window.publicTransactionService = publicTransactionService; 30 | } 31 | 32 | useEffect(() => { 33 | send("FETCH", { ...dateRangeFilters, ...amountRangeFilters }); 34 | }, [send, dateRangeFilters, amountRangeFilters]); 35 | 36 | const loadNextPage = (page: number) => 37 | send("FETCH", { page, ...dateRangeFilters, ...amountRangeFilters }); 38 | 39 | return ( 40 | <> 41 | 50 | 51 | ); 52 | }; 53 | 54 | export default TransactionPublicList; 55 | -------------------------------------------------------------------------------- /src/components/TransactionTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Typography } from "@material-ui/core"; 3 | import { makeStyles } from "@material-ui/core"; 4 | import { TransactionResponseItem } from "../models"; 5 | import { isRequestTransaction, isAcceptedRequestTransaction } from "../utils/transactionUtils"; 6 | 7 | const useStyles = makeStyles((theme) => ({ 8 | title: { 9 | fontSize: 18, 10 | [theme.breakpoints.down("sm")]: { 11 | fontSize: theme.typography.fontSize, 12 | }, 13 | }, 14 | titleAction: { 15 | fontSize: 18, 16 | [theme.breakpoints.down("sm")]: { 17 | fontSize: theme.typography.fontSize, 18 | }, 19 | }, 20 | titleName: { 21 | fontSize: 18, 22 | [theme.breakpoints.down("sm")]: { 23 | fontSize: theme.typography.fontSize, 24 | }, 25 | color: "#1A202C", 26 | }, 27 | })); 28 | 29 | const TransactionTitle: React.FC<{ 30 | transaction: TransactionResponseItem; 31 | }> = ({ transaction }) => { 32 | const classes = useStyles(); 33 | 34 | return ( 35 | 36 | 42 | {transaction.senderName} 43 | 44 | 50 | {isRequestTransaction(transaction) 51 | ? isAcceptedRequestTransaction(transaction) 52 | ? " charged " 53 | : " requested " 54 | : " paid "} 55 | 56 | 62 | {transaction.receiverName} 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default TransactionTitle; 69 | -------------------------------------------------------------------------------- /src/components/UserListItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ListItem, ListItemText, ListItemAvatar, Avatar, Grid } from "@material-ui/core"; 3 | 4 | import { User } from "../models"; 5 | 6 | export interface UserListItemProps { 7 | user: User; 8 | setReceiver: Function; 9 | index: Number; 10 | } 11 | 12 | const UserListItem: React.FC = ({ user, setReceiver, index }) => { 13 | return ( 14 | setReceiver(user)}> 15 | 16 | 17 | 18 | 22 | 30 | 31 | U: 32 | {user.username} 33 | 34 | 35 | • 36 | 37 | 38 | E: 39 | {user.email} 40 | 41 | 42 | • 43 | 44 | 45 | P: 46 | {user.phoneNumber} 47 | 48 | 49 | 50 | } 51 | /> 52 | 53 | ); 54 | }; 55 | 56 | export default UserListItem; 57 | -------------------------------------------------------------------------------- /src/components/UserListSearchForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { makeStyles, TextField } from "@material-ui/core"; 3 | 4 | const useStyles = makeStyles((theme) => ({ 5 | paper: { 6 | marginTop: theme.spacing(8), 7 | display: "flex", 8 | flexDirection: "column", 9 | alignItems: "center", 10 | }, 11 | form: { 12 | width: "100%", // Fix IE 11 issue. 13 | marginTop: theme.spacing(1), 14 | }, 15 | })); 16 | 17 | export interface UserListSearchFormProps { 18 | userListSearch: Function; 19 | } 20 | 21 | const UserListSearchForm: React.FC = ({ userListSearch }) => { 22 | const classes = useStyles(); 23 | const inputEl = useRef(null); 24 | 25 | return ( 26 |
27 |
28 | { 39 | if (null !== inputEl.current) { 40 | inputEl.current.value = ""; 41 | inputEl.current.focus(); 42 | } 43 | }} 44 | onChange={({ target: { value: q } }) => { 45 | userListSearch({ q }); 46 | }} 47 | /> 48 | 49 |
50 | ); 51 | }; 52 | 53 | export default UserListSearchForm; 54 | -------------------------------------------------------------------------------- /src/components/UsersList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { List } from "@material-ui/core"; 3 | 4 | import UserListItem from "./UserListItem"; 5 | import { User } from "../models"; 6 | 7 | export interface UsersListProps { 8 | users: User[]; 9 | setReceiver: Function; 10 | } 11 | 12 | const UsersList: React.FC = ({ users, setReceiver }) => { 13 | return ( 14 | 15 | {users && 16 | users.map((user: User, index: number) => ( 17 | 18 | ))} 19 | 20 | ); 21 | }; 22 | 23 | export default UsersList; 24 | -------------------------------------------------------------------------------- /src/containers/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Switch, Route, Redirect } from "react-router-dom"; 3 | import { useService, useMachine } from "@xstate/react"; 4 | import { makeStyles } from "@material-ui/core"; 5 | import { CssBaseline } from "@material-ui/core"; 6 | 7 | import { snackbarMachine } from "../machines/snackbarMachine"; 8 | import { notificationsMachine } from "../machines/notificationsMachine"; 9 | import { authService } from "../machines/authMachine"; 10 | import AlertBar from "../components/AlertBar"; 11 | import SignInForm from "../components/SignInForm"; 12 | import SignUpForm from "../components/SignUpForm"; 13 | import { bankAccountsMachine } from "../machines/bankAccountsMachine"; 14 | import PrivateRoutesContainer from "./PrivateRoutesContainer"; 15 | 16 | // @ts-ignore 17 | if (window.Cypress) { 18 | // Expose authService on window for Cypress 19 | // @ts-ignore 20 | window.authService = authService; 21 | } 22 | 23 | const useStyles = makeStyles((theme) => ({ 24 | root: { 25 | display: "flex", 26 | }, 27 | })); 28 | 29 | const App: React.FC = () => { 30 | const classes = useStyles(); 31 | const [authState] = useService(authService); 32 | const [, , notificationsService] = useMachine(notificationsMachine); 33 | 34 | const [, , snackbarService] = useMachine(snackbarMachine); 35 | 36 | const [, , bankAccountsService] = useMachine(bankAccountsMachine); 37 | 38 | const isLoggedIn = 39 | authState.matches("authorized") || 40 | authState.matches("refreshing") || 41 | authState.matches("updating"); 42 | 43 | return ( 44 |
45 | 46 | 47 | {isLoggedIn && ( 48 | 55 | )} 56 | {authState.matches("unauthorized") && ( 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 70 | 71 | 72 | )} 73 | 74 |
75 | ); 76 | }; 77 | 78 | export default App; 79 | -------------------------------------------------------------------------------- /src/containers/BankAccountsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useService } from "@xstate/react"; 3 | import { Interpreter } from "xstate"; 4 | import { Link as RouterLink, useRouteMatch } from "react-router-dom"; 5 | import { makeStyles, Grid, Button, Paper, Typography } from "@material-ui/core"; 6 | 7 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 8 | import { DataContext, DataEvents } from "../machines/dataMachine"; 9 | import BankAccountForm from "../components/BankAccountForm"; 10 | import BankAccountList from "../components/BankAccountList"; 11 | 12 | export interface Props { 13 | authService: Interpreter; 14 | bankAccountsService: Interpreter; 15 | } 16 | 17 | const useStyles = makeStyles((theme) => ({ 18 | paper: { 19 | padding: theme.spacing(2), 20 | display: "flex", 21 | overflow: "auto", 22 | flexDirection: "column", 23 | }, 24 | })); 25 | 26 | const BankAccountsContainer: React.FC = ({ authService, bankAccountsService }) => { 27 | const match = useRouteMatch(); 28 | const classes = useStyles(); 29 | const [authState] = useService(authService); 30 | const [bankAccountsState, sendBankAccounts] = useService(bankAccountsService); 31 | 32 | const currentUser = authState?.context.user; 33 | 34 | const createBankAccount = (payload: any) => { 35 | sendBankAccounts({ type: "CREATE", ...payload }); 36 | }; 37 | 38 | const deleteBankAccount = (payload: any) => { 39 | sendBankAccounts({ type: "DELETE", ...payload }); 40 | }; 41 | 42 | useEffect(() => { 43 | sendBankAccounts("FETCH"); 44 | }, [sendBankAccounts]); 45 | 46 | if (match.url === "/bankaccounts/new" && currentUser?.id) { 47 | return ( 48 | 49 | 50 | Create Bank Account 51 | 52 | 53 | 54 | ); 55 | } 56 | 57 | return ( 58 | 59 | 60 | 61 | 62 | Bank Accounts 63 | 64 | 65 | 66 | 76 | 77 | 78 | 82 | 83 | ); 84 | }; 85 | export default BankAccountsContainer; 86 | -------------------------------------------------------------------------------- /src/containers/NotificationsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Interpreter } from "xstate"; 3 | import { useService } from "@xstate/react"; 4 | import { makeStyles, Paper, Typography } from "@material-ui/core"; 5 | import { NotificationUpdatePayload } from "../models"; 6 | import NotificationList from "../components/NotificationList"; 7 | import { DataContext, DataSchema, DataEvents } from "../machines/dataMachine"; 8 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 9 | 10 | const useStyles = makeStyles((theme) => ({ 11 | paper: { 12 | minHeight: "90vh", 13 | padding: theme.spacing(2), 14 | display: "flex", 15 | overflow: "auto", 16 | flexDirection: "column", 17 | }, 18 | })); 19 | 20 | export interface Props { 21 | authService: Interpreter; 22 | notificationsService: Interpreter; 23 | } 24 | 25 | const NotificationsContainer: React.FC = ({ authService, notificationsService }) => { 26 | const classes = useStyles(); 27 | const [authState] = useService(authService); 28 | const [notificationsState, sendNotifications] = useService(notificationsService); 29 | 30 | useEffect(() => { 31 | sendNotifications({ type: "FETCH" }); 32 | }, [authState, sendNotifications]); 33 | 34 | const updateNotification = (payload: NotificationUpdatePayload) => 35 | sendNotifications({ type: "UPDATE", ...payload }); 36 | 37 | return ( 38 | 39 | 40 | Notifications 41 | 42 | 46 | 47 | ); 48 | }; 49 | 50 | export default NotificationsContainer; 51 | -------------------------------------------------------------------------------- /src/containers/PrivateRoutesContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Switch } from "react-router"; 3 | import { Interpreter } from "xstate"; 4 | import MainLayout from "../components/MainLayout"; 5 | import PrivateRoute from "../components/PrivateRoute"; 6 | import TransactionsContainer from "./TransactionsContainer"; 7 | import UserSettingsContainer from "./UserSettingsContainer"; 8 | import NotificationsContainer from "./NotificationsContainer"; 9 | import BankAccountsContainer from "./BankAccountsContainer"; 10 | import TransactionCreateContainer from "./TransactionCreateContainer"; 11 | import TransactionDetailContainer from "./TransactionDetailContainer"; 12 | import { DataContext, DataSchema, DataEvents } from "../machines/dataMachine"; 13 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 14 | import { SnackbarContext, SnackbarSchema, SnackbarEvents } from "../machines/snackbarMachine"; 15 | import { useService } from "@xstate/react"; 16 | import UserOnboardingContainer from "./UserOnboardingContainer"; 17 | 18 | export interface Props { 19 | isLoggedIn: boolean; 20 | authService: Interpreter; 21 | notificationsService: Interpreter; 22 | snackbarService: Interpreter; 23 | bankAccountsService: Interpreter; 24 | } 25 | 26 | const PrivateRoutesContainer: React.FC = ({ 27 | isLoggedIn, 28 | authService, 29 | notificationsService, 30 | snackbarService, 31 | bankAccountsService, 32 | }) => { 33 | const [, sendNotifications] = useService(notificationsService); 34 | 35 | useEffect(() => { 36 | sendNotifications({ type: "FETCH" }); 37 | }, [sendNotifications]); 38 | 39 | return ( 40 | 41 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 57 | 58 | 59 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | 75 | export default PrivateRoutesContainer; 76 | -------------------------------------------------------------------------------- /src/containers/TransactionCreateContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useMachine, useService } from "@xstate/react"; 3 | import { User, TransactionPayload } from "../models"; 4 | import TransactionCreateStepOne from "../components/TransactionCreateStepOne"; 5 | import TransactionCreateStepTwo from "../components/TransactionCreateStepTwo"; 6 | import TransactionCreateStepThree from "../components/TransactionCreateStepThree"; 7 | import { createTransactionMachine } from "../machines/createTransactionMachine"; 8 | import { usersMachine } from "../machines/usersMachine"; 9 | import { debounce } from "lodash/fp"; 10 | import { Interpreter } from "xstate"; 11 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 12 | import { SnackbarSchema, SnackbarContext, SnackbarEvents } from "../machines/snackbarMachine"; 13 | import { Stepper, Step, StepLabel } from "@material-ui/core"; 14 | 15 | export interface Props { 16 | authService: Interpreter; 17 | snackbarService: Interpreter; 18 | } 19 | 20 | const TransactionCreateContainer: React.FC = ({ authService, snackbarService }) => { 21 | const [authState] = useService(authService); 22 | const [, sendSnackbar] = useService(snackbarService); 23 | 24 | const [createTransactionState, sendCreateTransaction, createTransactionService] = useMachine( 25 | createTransactionMachine 26 | ); 27 | 28 | // Expose createTransactionService on window for Cypress 29 | // @ts-ignore 30 | window.createTransactionService = createTransactionService; 31 | 32 | const [usersState, sendUsers] = useMachine(usersMachine); 33 | 34 | useEffect(() => { 35 | sendUsers({ type: "FETCH" }); 36 | }, [sendUsers]); 37 | 38 | const sender = authState?.context?.user; 39 | const setReceiver = (receiver: User) => { 40 | // @ts-ignore 41 | sendCreateTransaction({ type: "SET_USERS", sender, receiver }); 42 | }; 43 | const createTransaction = (payload: TransactionPayload) => { 44 | sendCreateTransaction("CREATE", payload); 45 | }; 46 | const userListSearch = debounce(200, (payload: any) => sendUsers({ type: "FETCH", ...payload })); 47 | 48 | const showSnackbar = (payload: SnackbarContext) => sendSnackbar({ type: "SHOW", ...payload }); 49 | 50 | let activeStep; 51 | if (createTransactionState.matches("stepTwo")) { 52 | activeStep = 1; 53 | } else if (createTransactionState.matches("stepThree")) { 54 | activeStep = 3; 55 | } else { 56 | activeStep = 0; 57 | } 58 | 59 | return ( 60 | <> 61 | 62 | 63 | Select Contact 64 | 65 | 66 | Payment 67 | 68 | 69 | Complete 70 | 71 | 72 | {createTransactionState.matches("stepOne") && ( 73 | 78 | )} 79 | {sender && createTransactionState.matches("stepTwo") && ( 80 | 86 | )} 87 | {createTransactionState.matches("stepThree") && ( 88 | 89 | )} 90 | 91 | ); 92 | }; 93 | 94 | export default TransactionCreateContainer; 95 | -------------------------------------------------------------------------------- /src/containers/TransactionDetailContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useMachine, useService } from "@xstate/react"; 3 | import { useParams } from "react-router-dom"; 4 | import TransactionDetail from "../components/TransactionDetail"; 5 | import { Transaction } from "../models"; 6 | import { transactionDetailMachine } from "../machines/transactionDetailMachine"; 7 | import { first } from "lodash/fp"; 8 | import { Interpreter } from "xstate"; 9 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 10 | 11 | export interface Props { 12 | authService: Interpreter; 13 | } 14 | interface Params { 15 | transactionId: string; 16 | } 17 | 18 | const TransactionDetailsContainer: React.FC = ({ authService }) => { 19 | const { transactionId }: Params = useParams(); 20 | const [authState] = useService(authService); 21 | const [transactionDetailState, sendTransactionDetail] = useMachine(transactionDetailMachine); 22 | useEffect(() => { 23 | sendTransactionDetail("FETCH", { transactionId }); 24 | }, [sendTransactionDetail, transactionId]); 25 | 26 | const transactionLike = (transactionId: Transaction["id"]) => 27 | sendTransactionDetail("CREATE", { entity: "LIKE", transactionId }); 28 | 29 | const transactionComment = (payload: any) => 30 | sendTransactionDetail("CREATE", { entity: "COMMENT", ...payload }); 31 | 32 | const transactionUpdate = (payload: any) => sendTransactionDetail("UPDATE", payload); 33 | 34 | const transaction = first(transactionDetailState.context?.results); 35 | const currentUser = authState?.context?.user; 36 | 37 | return ( 38 | <> 39 | {transactionDetailState.matches("idle") && ( 40 |
41 | Loading... 42 |
43 |
44 | )} 45 | {currentUser && transactionDetailState.matches("success") && ( 46 | 53 | )} 54 | 55 | ); 56 | }; 57 | 58 | export default TransactionDetailsContainer; 59 | -------------------------------------------------------------------------------- /src/containers/TransactionsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useMachine } from "@xstate/react"; 3 | import { Switch, Route } from "react-router"; 4 | import { TransactionDateRangePayload, TransactionAmountRangePayload } from "../models"; 5 | import TransactionListFilters from "../components/TransactionListFilters"; 6 | import TransactionContactsList from "../components/TransactionContactsList"; 7 | import { transactionFiltersMachine } from "../machines/transactionFiltersMachine"; 8 | import { getDateQueryFields, getAmountQueryFields } from "../utils/transactionUtils"; 9 | import TransactionPersonalList from "../components/TransactionPersonalList"; 10 | import TransactionPublicList from "../components/TransactionPublicList"; 11 | 12 | const TransactionsContainer: React.FC = () => { 13 | const [currentFilters, sendFilterEvent] = useMachine(transactionFiltersMachine); 14 | 15 | const hasDateRangeFilter = currentFilters.matches({ dateRange: "filter" }); 16 | const hasAmountRangeFilter = currentFilters.matches({ 17 | amountRange: "filter", 18 | }); 19 | 20 | const dateRangeFilters = hasDateRangeFilter && getDateQueryFields(currentFilters.context); 21 | const amountRangeFilters = hasAmountRangeFilter && getAmountQueryFields(currentFilters.context); 22 | 23 | const Filters = ( 24 | 29 | ); 30 | 31 | return ( 32 | 33 | 34 | 39 | 40 | 41 | 46 | 47 | 48 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default TransactionsContainer; 59 | -------------------------------------------------------------------------------- /src/containers/UserOnboardingContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { 3 | Button, 4 | Box, 5 | useTheme, 6 | useMediaQuery, 7 | Grid, 8 | Dialog, 9 | DialogActions, 10 | DialogContent, 11 | DialogContentText, 12 | DialogTitle, 13 | } from "@material-ui/core"; 14 | import { Interpreter } from "xstate"; 15 | import { isEmpty } from "lodash/fp"; 16 | import { useService, useMachine } from "@xstate/react"; 17 | 18 | import { userOnboardingMachine } from "../machines/userOnboardingMachine"; 19 | import BankAccountForm from "../components/BankAccountForm"; 20 | import { DataContext, DataEvents } from "../machines/dataMachine"; 21 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 22 | // import { ReactComponent as NavigatorIllustration } from "../svgs/undraw_navigator_a479.svg"; 23 | // import { ReactComponent as PersonalFinance } from "../svgs/undraw_personal_finance_tqcd.svg"; 24 | 25 | export interface Props { 26 | authService: Interpreter; 27 | bankAccountsService: Interpreter; 28 | } 29 | 30 | const UserOnboardingContainer: React.FC = ({ authService, bankAccountsService }) => { 31 | const theme = useTheme(); 32 | const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); 33 | const [bankAccountsState, sendBankAccounts] = useService(bankAccountsService); 34 | const [authState, sendAuth] = useService(authService); 35 | const [userOnboardingState, sendUserOnboarding] = useMachine(userOnboardingMachine); 36 | 37 | const currentUser = authState?.context?.user; 38 | 39 | useEffect(() => { 40 | sendBankAccounts("FETCH"); 41 | }, [sendBankAccounts]); 42 | 43 | const noBankAccounts = 44 | bankAccountsState?.matches("success.withoutData") && 45 | isEmpty(bankAccountsState?.context?.results); 46 | 47 | const dialogIsOpen = 48 | (userOnboardingState.matches("stepTwo") && !noBankAccounts) || 49 | (userOnboardingState.matches("stepThree") && !noBankAccounts) || 50 | (!userOnboardingState.matches("done") && noBankAccounts) || 51 | false; 52 | 53 | const nextStep = () => sendUserOnboarding("NEXT"); 54 | 55 | const createBankAccountWithNextStep = (payload: any) => { 56 | sendBankAccounts({ type: "CREATE", ...payload }); 57 | nextStep(); 58 | }; 59 | 60 | return ( 61 | 62 | 63 | {userOnboardingState.matches("stepOne") && "Get Started with Real World App"} 64 | {userOnboardingState.matches("stepTwo") && "Create Bank Account"} 65 | {userOnboardingState.matches("stepThree") && "Finished"} 66 | 67 | 68 | 69 | {userOnboardingState.matches("stepOne") && ( 70 | <> 71 | {/* */} 72 |
73 | 74 | Real World App requires a Bank Account to perform transactions. 75 |
76 |
77 | Click Next to begin setup of your Bank Account. 78 |
79 | 80 | )} 81 | {userOnboardingState.matches("stepTwo") && ( 82 | 87 | )} 88 | {userOnboardingState.matches("stepThree") && ( 89 | <> 90 | {/* */} 91 |
92 | 93 | You're all set! 94 |
95 |
96 | We're excited to have you aboard the Real World App! 97 |
98 | 99 | )} 100 |
101 |
102 | 103 | 104 | 105 | 113 | 114 | 115 | {!userOnboardingState.matches("stepTwo") && ( 116 | 119 | )} 120 | 121 | 122 | 123 |
124 | ); 125 | }; 126 | 127 | export default UserOnboardingContainer; 128 | -------------------------------------------------------------------------------- /src/containers/UserSettingsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, Paper, Typography, Grid } from "@material-ui/core"; 3 | import UserSettingsForm from "../components/UserSettingsForm"; 4 | import { Interpreter } from "xstate"; 5 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 6 | import { useService } from "@xstate/react"; 7 | // import { ReactComponent as PersonalSettingsIllustration } from "../svgs/undraw_personal_settings_kihd.svg"; 8 | 9 | const useStyles = makeStyles((theme) => ({ 10 | paper: { 11 | padding: theme.spacing(2), 12 | display: "flex", 13 | overflow: "auto", 14 | flexDirection: "column", 15 | }, 16 | })); 17 | 18 | export interface Props { 19 | authService: Interpreter; 20 | } 21 | 22 | const UserSettingsContainer: React.FC = ({ authService }) => { 23 | const classes = useStyles(); 24 | const [authState, sendAuth] = useService(authService); 25 | 26 | const currentUser = authState?.context?.user; 27 | const updateUser = (payload: any) => sendAuth({ type: "UPDATE", ...payload }); 28 | 29 | return ( 30 | 31 | 32 | User Settings 33 | 34 | 35 | 36 | {/* */} 37 | 38 | 39 | {currentUser && } 40 | 41 | 42 | 43 | ); 44 | }; 45 | export default UserSettingsContainer; 46 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Router } from "react-router-dom"; 4 | import { history } from "./utils/historyUtils"; 5 | 6 | import App from "./containers/App"; 7 | import { createMuiTheme, ThemeProvider } from "@material-ui/core"; 8 | 9 | const theme = createMuiTheme({ 10 | palette: { 11 | secondary: { 12 | main: "#fff", 13 | }, 14 | }, 15 | }); 16 | 17 | ReactDOM.render( 18 | 19 | 20 | 21 | 22 | , 23 | document.getElementById("root") 24 | ); 25 | -------------------------------------------------------------------------------- /src/machines/bankAccountsMachine.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty, omit } from "lodash/fp"; 2 | import { dataMachine } from "./dataMachine"; 3 | import { httpClient } from "../utils/asyncUtils"; 4 | 5 | export const bankAccountsMachine = dataMachine("bankAccounts").withConfig({ 6 | services: { 7 | fetchData: async (ctx, event: any) => { 8 | const payload = omit("type", event); 9 | const resp = await httpClient.get(`http://localhost:3001/bankAccounts`, { 10 | params: !isEmpty(payload) && event.type === "FETCH" ? payload : undefined, 11 | }); 12 | return resp.data; 13 | }, 14 | deleteData: async (ctx, event: any) => { 15 | const payload = omit("type", event); 16 | const resp = await httpClient.delete( 17 | `http://localhost:3001/bankAccounts/${payload.id}`, 18 | payload 19 | ); 20 | return resp.data; 21 | }, 22 | createData: async (ctx, event: any) => { 23 | const payload = omit("type", event); 24 | const resp = await httpClient.post("http://localhost:3001/bankAccounts", payload); 25 | return resp.data; 26 | }, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /src/machines/contactsTransactionsMachine.ts: -------------------------------------------------------------------------------- 1 | import { dataMachine } from "./dataMachine"; 2 | import { httpClient } from "../utils/asyncUtils"; 3 | import { isEmpty, omit } from "lodash/fp"; 4 | 5 | export const contactsTransactionsMachine = dataMachine("contactsTransactions").withConfig({ 6 | services: { 7 | fetchData: async (ctx, event: any) => { 8 | const payload = omit("type", event); 9 | const resp = await httpClient.get(`http://localhost:3001/transactions/contacts`, { 10 | params: !isEmpty(payload) ? payload : undefined, 11 | }); 12 | return resp.data; 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/machines/createTransactionMachine.ts: -------------------------------------------------------------------------------- 1 | import { omit } from "lodash/fp"; 2 | import { Machine, assign } from "xstate"; 3 | import { dataMachine } from "./dataMachine"; 4 | import { httpClient } from "../utils/asyncUtils"; 5 | import { User, TransactionCreatePayload } from "../models"; 6 | import { authService } from "./authMachine"; 7 | 8 | export interface CreateTransactionMachineSchema { 9 | states: { 10 | stepOne: {}; 11 | stepTwo: {}; 12 | stepThree: {}; 13 | }; 14 | } 15 | 16 | const transactionDataMachine = dataMachine("transactionData").withConfig({ 17 | services: { 18 | createData: async (ctx, event: any) => { 19 | const payload = omit("type", event); 20 | const resp = await httpClient.post(`http://localhost:3001/transactions`, payload); 21 | authService.send("REFRESH"); 22 | return resp.data; 23 | }, 24 | }, 25 | }); 26 | 27 | export type CreateTransactionMachineEvents = 28 | | { type: "SET_USERS" } 29 | | { type: "CREATE" } 30 | | { type: "RESET" }; 31 | 32 | export interface CreateTransactionMachineContext { 33 | sender: User; 34 | receiver: User; 35 | transactionDetails: TransactionCreatePayload; 36 | } 37 | 38 | export const createTransactionMachine = Machine< 39 | CreateTransactionMachineContext, 40 | CreateTransactionMachineSchema, 41 | CreateTransactionMachineEvents 42 | >( 43 | { 44 | id: "createTransaction", 45 | initial: "stepOne", 46 | states: { 47 | stepOne: { 48 | entry: "clearContext", 49 | on: { 50 | SET_USERS: "stepTwo", 51 | }, 52 | }, 53 | stepTwo: { 54 | entry: "setSenderAndReceiver", 55 | invoke: { 56 | id: "transactionDataMachine", 57 | src: transactionDataMachine, 58 | autoForward: true, 59 | }, 60 | on: { 61 | CREATE: "stepThree", 62 | }, 63 | }, 64 | stepThree: { 65 | entry: "setTransactionDetails", 66 | on: { 67 | RESET: "stepOne", 68 | }, 69 | }, 70 | }, 71 | }, 72 | { 73 | actions: { 74 | setSenderAndReceiver: assign((ctx, event: any) => ({ 75 | sender: event.sender, 76 | receiver: event.receiver, 77 | })), 78 | setTransactionDetails: assign((ctx, event: any) => ({ 79 | transactionDetails: event, 80 | })), 81 | clearContext: assign((ctx, event: any) => ({})), 82 | }, 83 | } 84 | ); 85 | -------------------------------------------------------------------------------- /src/machines/dataMachine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, assign } from "xstate"; 2 | import { concat } from "lodash/fp"; 3 | 4 | export interface DataSchema { 5 | states: { 6 | idle: {}; 7 | loading: {}; 8 | updating: {}; 9 | creating: {}; 10 | deleting: {}; 11 | success: { 12 | states: { 13 | unknown: {}; 14 | withData: {}; 15 | withoutData: {}; 16 | }; 17 | }; 18 | failure: {}; 19 | }; 20 | } 21 | 22 | type SuccessEvent = { type: "SUCCESS"; results: any[]; pageData: object }; 23 | type FailureEvent = { type: "FAILURE"; message: string }; 24 | export type DataEvents = 25 | | { type: "FETCH" } 26 | | { type: "UPDATE" } 27 | | { type: "CREATE" } 28 | | { type: "DELETE" } 29 | | SuccessEvent 30 | | FailureEvent; 31 | 32 | export interface DataContext { 33 | pageData?: object; 34 | results?: any[]; 35 | message?: string; 36 | } 37 | 38 | export const dataMachine = (machineId: string) => 39 | Machine( 40 | { 41 | id: machineId, 42 | initial: "idle", 43 | context: { 44 | pageData: {}, 45 | results: [], 46 | message: undefined, 47 | }, 48 | states: { 49 | idle: { 50 | on: { 51 | FETCH: "loading", 52 | CREATE: "creating", 53 | UPDATE: "updating", 54 | DELETE: "deleting", 55 | }, 56 | }, 57 | loading: { 58 | invoke: { 59 | src: "fetchData", 60 | onDone: { target: "success" }, 61 | onError: { target: "failure", actions: "setMessage" }, 62 | }, 63 | }, 64 | updating: { 65 | invoke: { 66 | src: "updateData", 67 | onDone: { target: "loading" }, 68 | onError: { target: "failure", actions: "setMessage" }, 69 | }, 70 | }, 71 | creating: { 72 | invoke: { 73 | src: "createData", 74 | onDone: { target: "loading" }, 75 | onError: { target: "failure", actions: "setMessage" }, 76 | }, 77 | }, 78 | deleting: { 79 | invoke: { 80 | src: "deleteData", 81 | onDone: { target: "loading" }, 82 | onError: { target: "failure", actions: "setMessage" }, 83 | }, 84 | }, 85 | success: { 86 | entry: ["setResults", "setPageData"], 87 | on: { 88 | FETCH: "loading", 89 | CREATE: "creating", 90 | UPDATE: "updating", 91 | DELETE: "deleting", 92 | }, 93 | initial: "unknown", 94 | states: { 95 | unknown: { 96 | on: { 97 | "": [{ target: "withData", cond: "hasData" }, { target: "withoutData" }], 98 | }, 99 | }, 100 | withData: {}, 101 | withoutData: {}, 102 | }, 103 | }, 104 | failure: { 105 | entry: ["setMessage"], 106 | on: { 107 | FETCH: "loading", 108 | }, 109 | }, 110 | }, 111 | }, 112 | { 113 | actions: { 114 | setResults: assign((ctx: DataContext, event: any) => ({ 115 | results: 116 | event.data && event.data.pageData && event.data.pageData.page > 1 117 | ? concat(ctx.results, event.data.results) 118 | : event.data.results, 119 | })), 120 | setPageData: assign((ctx: DataContext, event: any) => ({ 121 | pageData: event.data.pageData, 122 | })), 123 | 124 | setMessage: /* istanbul ignore next */ assign((ctx, event: any) => ({ 125 | message: event.message, 126 | })), 127 | }, 128 | guards: { 129 | hasData: (ctx: DataContext, event) => !!ctx.results && ctx.results.length > 0, 130 | }, 131 | } 132 | ); 133 | -------------------------------------------------------------------------------- /src/machines/drawerMachine.ts: -------------------------------------------------------------------------------- 1 | import { Machine } from "xstate"; 2 | 3 | export const drawerMachine = Machine( 4 | { 5 | id: "drawer", 6 | type: "parallel", 7 | states: { 8 | desktop: { 9 | initial: "open", 10 | states: { 11 | closed: { 12 | on: { 13 | TOGGLE_DESKTOP: "open", 14 | OPEN_DESKTOP: { target: "open", cond: "shouldOpenDesktop" }, 15 | }, 16 | }, 17 | open: { 18 | on: { TOGGLE_DESKTOP: "closed", CLOSE_DESKTOP: "closed" }, 19 | }, 20 | hist: { 21 | type: "history", 22 | }, 23 | }, 24 | }, 25 | mobile: { 26 | initial: "closed", 27 | states: { 28 | closed: { 29 | on: { TOGGLE_MOBILE: "open", OPEN_MOBILE: "open" }, 30 | }, 31 | open: { 32 | on: { TOGGLE_MOBILE: "closed", CLOSE_MOBILE: "closed" }, 33 | }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | { 39 | guards: { 40 | shouldOpenDesktop: (context, event, guardMeta) => { 41 | return ( 42 | guardMeta.state.history?.context.aboveSmallBreakpoint !== context.aboveSmallBreakpoint 43 | ); 44 | }, 45 | }, 46 | } 47 | ); 48 | -------------------------------------------------------------------------------- /src/machines/notificationsMachine.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty, omit } from "lodash/fp"; 2 | import { dataMachine } from "./dataMachine"; 3 | import { httpClient } from "../utils/asyncUtils"; 4 | 5 | export const notificationsMachine = dataMachine("notifications").withConfig({ 6 | services: { 7 | fetchData: async (ctx, event: any) => { 8 | const payload = omit("type", event); 9 | const resp = await httpClient.get(`http://localhost:3001/notifications`, { 10 | params: !isEmpty(payload) && event.type === "FETCH" ? payload : undefined, 11 | }); 12 | return resp.data; 13 | }, 14 | updateData: async (ctx, event: any) => { 15 | const payload = omit("type", event); 16 | const resp = await httpClient.patch( 17 | `http://localhost:3001/notifications/${payload.id}`, 18 | payload 19 | ); 20 | return resp.data; 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/machines/personalTransactionsMachine.ts: -------------------------------------------------------------------------------- 1 | import { dataMachine } from "./dataMachine"; 2 | import { httpClient } from "../utils/asyncUtils"; 3 | import { isEmpty, omit } from "lodash/fp"; 4 | 5 | export const personalTransactionsMachine = dataMachine("personalTransactions").withConfig({ 6 | services: { 7 | fetchData: async (ctx, event: any) => { 8 | const payload = omit("type", event); 9 | const resp = await httpClient.get(`http://localhost:3001/transactions`, { 10 | params: !isEmpty(payload) ? payload : undefined, 11 | }); 12 | return resp.data; 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/machines/publicTransactionsMachine.ts: -------------------------------------------------------------------------------- 1 | import { dataMachine } from "./dataMachine"; 2 | import { httpClient } from "../utils/asyncUtils"; 3 | import { isEmpty, omit } from "lodash/fp"; 4 | 5 | export const publicTransactionsMachine = dataMachine("publicTransactions").withConfig({ 6 | services: { 7 | fetchData: async (ctx, event: any) => { 8 | const payload = omit("type", event); 9 | const resp = await httpClient.get(`http://localhost:3001/transactions/public`, { 10 | params: !isEmpty(payload) ? payload : undefined, 11 | }); 12 | return resp.data; 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/machines/snackbarMachine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, assign } from "xstate"; 2 | 3 | export interface SnackbarSchema { 4 | states: { 5 | invisible: {}; 6 | visible: {}; 7 | }; 8 | } 9 | 10 | export type SnackbarEvents = { type: "SHOW" } | { type: "HIDE" }; 11 | 12 | export interface SnackbarContext { 13 | severity?: "success" | "info" | "warning" | "error"; 14 | message?: string; 15 | } 16 | 17 | export const snackbarMachine = Machine( 18 | { 19 | id: "snackbar", 20 | initial: "invisible", 21 | context: { 22 | severity: undefined, 23 | message: undefined, 24 | }, 25 | states: { 26 | invisible: { 27 | entry: "resetSnackbar", 28 | on: { SHOW: "visible" }, 29 | }, 30 | visible: { 31 | entry: "setSnackbar", 32 | on: { HIDE: "invisible" }, 33 | after: { 34 | // after 5 seconds, transition to invisible 35 | 3000: "invisible", 36 | }, 37 | }, 38 | }, 39 | }, 40 | { 41 | actions: { 42 | setSnackbar: assign((ctx, event: any) => ({ 43 | severity: event.severity, 44 | message: event.message, 45 | })), 46 | resetSnackbar: assign((ctx, event: any) => ({ 47 | severity: undefined, 48 | message: undefined, 49 | })), 50 | }, 51 | } 52 | ); 53 | -------------------------------------------------------------------------------- /src/machines/transactionDetailMachine.ts: -------------------------------------------------------------------------------- 1 | import { omit, flow, first, isEmpty } from "lodash/fp"; 2 | import { dataMachine } from "./dataMachine"; 3 | import { httpClient } from "../utils/asyncUtils"; 4 | 5 | export const transactionDetailMachine = dataMachine("transactionData").withConfig({ 6 | services: { 7 | fetchData: async (ctx, event: any) => { 8 | const payload = omit("type", event); 9 | const contextTransactionId = !isEmpty(ctx.results) && first(ctx.results)["id"]; 10 | const transactionId = contextTransactionId || payload.transactionId; 11 | const resp = await httpClient.get(`http://localhost:3001/transactions/${transactionId}`); 12 | return { results: [resp.data.transaction] }; 13 | }, 14 | createData: async (ctx, event: any) => { 15 | let route = event.entity === "LIKE" ? "likes" : "comments"; 16 | const payload = flow(omit("type"), omit("entity"))(event); 17 | const resp = await httpClient.post( 18 | `http://localhost:3001/${route}/${payload.transactionId}`, 19 | payload 20 | ); 21 | return resp.data; 22 | }, 23 | updateData: async (ctx, event: any) => { 24 | const payload = omit("type", event); 25 | const contextTransactionId = !isEmpty(ctx.results) && first(ctx.results)["id"]; 26 | const transactionId = contextTransactionId || payload.id; 27 | const resp = await httpClient.patch( 28 | `http://localhost:3001/transactions/${transactionId}`, 29 | payload 30 | ); 31 | return resp.data; 32 | }, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /src/machines/transactionFiltersMachine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, assign } from "xstate"; 2 | 3 | interface FilterSchema { 4 | states: { 5 | dateRange: { 6 | states: { 7 | none: {}; 8 | filter: {}; 9 | }; 10 | }; 11 | amountRange: { 12 | states: { 13 | none: {}; 14 | filter: {}; 15 | }; 16 | }; 17 | }; 18 | } 19 | 20 | type DateFilterEvent = { 21 | type: "DATE_FILTER"; 22 | dateRangeStart: string; 23 | dateRangeEnd: string; 24 | }; 25 | type AmountFilterEvent = { 26 | type: "AMOUNT_FILTER"; 27 | amountMin: string; 28 | amountMax: string; 29 | }; 30 | type DateResetEvent = { type: "DATE_RESET" }; 31 | type AmountResetEvent = { type: "AMOUNT_RESET" }; 32 | type FilterEvents = 33 | | { type: "NONE" } 34 | | DateFilterEvent 35 | | AmountFilterEvent 36 | | DateResetEvent 37 | | AmountResetEvent; 38 | 39 | export interface FilterContext {} 40 | 41 | export const transactionFiltersMachine = Machine( 42 | { 43 | id: "filters", 44 | type: "parallel", 45 | context: {}, 46 | states: { 47 | dateRange: { 48 | initial: "none", 49 | states: { 50 | none: { 51 | entry: "resetDateRange", 52 | on: { 53 | DATE_FILTER: "filter", 54 | }, 55 | }, 56 | filter: { 57 | entry: "setDateRange", 58 | on: { 59 | DATE_RESET: "none", 60 | }, 61 | }, 62 | }, 63 | }, 64 | amountRange: { 65 | initial: "none", 66 | states: { 67 | none: { 68 | entry: "resetAmountRange", 69 | on: { 70 | AMOUNT_FILTER: "filter", 71 | }, 72 | }, 73 | filter: { 74 | entry: "setAmountRange", 75 | on: { 76 | AMOUNT_RESET: "none", 77 | AMOUNT_FILTER: "filter", 78 | }, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | { 85 | actions: { 86 | setDateRange: assign((ctx: FilterContext, event: any) => ({ 87 | dateRangeStart: event.dateRangeStart, 88 | dateRangeEnd: event.dateRangeEnd, 89 | })), 90 | resetDateRange: assign((ctx: FilterContext, event: any) => ({ 91 | dateRangeStart: undefined, 92 | dateRangeEnd: undefined, 93 | })), 94 | setAmountRange: assign((ctx: FilterContext, event: any) => ({ 95 | amountMin: event.amountMin, 96 | amountMax: event.amountMax, 97 | })), 98 | resetAmountRange: assign((ctx: FilterContext, event: any) => ({ 99 | amountMin: undefined, 100 | amountMax: undefined, 101 | })), 102 | }, 103 | } 104 | ); 105 | -------------------------------------------------------------------------------- /src/machines/userOnboardingMachine.ts: -------------------------------------------------------------------------------- 1 | import { Machine } from "xstate"; 2 | 3 | export interface UserOnboardingMachineSchema { 4 | states: { 5 | idle: {}; 6 | stepOne: {}; 7 | stepTwo: {}; 8 | stepThree: {}; 9 | done: {}; 10 | }; 11 | } 12 | 13 | export type UserOnboardingMachineEvents = { type: "PREV" } | { type: "NEXT" }; 14 | 15 | export interface UserOnboardingMachineContext {} 16 | 17 | export const userOnboardingMachine = Machine< 18 | UserOnboardingMachineContext, 19 | UserOnboardingMachineSchema, 20 | UserOnboardingMachineEvents 21 | >({ 22 | id: "userOnboarding", 23 | initial: "stepOne", 24 | states: { 25 | idle: { 26 | on: { 27 | NEXT: "stepOne", 28 | }, 29 | }, 30 | stepOne: { 31 | on: { 32 | NEXT: "stepTwo", 33 | }, 34 | }, 35 | stepTwo: { 36 | on: { 37 | PREV: "stepOne", 38 | NEXT: "stepThree", 39 | }, 40 | }, 41 | stepThree: { 42 | on: { 43 | PREV: "stepTwo", 44 | NEXT: "done", 45 | }, 46 | }, 47 | done: { 48 | type: "final", 49 | }, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /src/machines/usersMachine.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty, omit } from "lodash/fp"; 2 | import { dataMachine } from "./dataMachine"; 3 | import { httpClient } from "../utils/asyncUtils"; 4 | 5 | export const usersMachine = dataMachine("users").withConfig({ 6 | services: { 7 | fetchData: async (ctx, event: any) => { 8 | const payload = omit("type", event); 9 | let route = isEmpty(payload) ? "users" : "users/search"; 10 | const resp = await httpClient.get(`http://localhost:3001/${route}`, { 11 | params: !isEmpty(payload) ? payload : undefined, 12 | }); 13 | return resp.data; 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/models/bankaccount.ts: -------------------------------------------------------------------------------- 1 | export interface BankAccount { 2 | id: string; 3 | uuid: string; 4 | userId: string; 5 | bankName: string; 6 | accountNumber: string; 7 | routingNumber: string; 8 | isDeleted: boolean; 9 | createdAt: Date; 10 | modifiedAt: Date; 11 | } 12 | 13 | export type BankAccountPayload = Pick< 14 | BankAccount, 15 | "userId" | "bankName" | "accountNumber" | "routingNumber" 16 | >; 17 | -------------------------------------------------------------------------------- /src/models/banktransfer.ts: -------------------------------------------------------------------------------- 1 | export enum BankTransferType { 2 | withdrawal = "withdrawal", 3 | deposit = "deposit", 4 | } 5 | export interface BankTransfer { 6 | id: string; 7 | uuid: string; 8 | userId: string; 9 | source: string; 10 | amount: number; 11 | type: BankTransferType; 12 | transactionId: string; 13 | createdAt: Date; 14 | modifiedAt: Date; 15 | } 16 | export type BankTransferPayload = Omit; 17 | -------------------------------------------------------------------------------- /src/models/comment.ts: -------------------------------------------------------------------------------- 1 | export interface Comment { 2 | id: string; 3 | uuid: string; 4 | content: string; 5 | userId: string; 6 | transactionId: string; 7 | createdAt: Date; 8 | modifiedAt: Date; 9 | } 10 | -------------------------------------------------------------------------------- /src/models/contact.ts: -------------------------------------------------------------------------------- 1 | export interface Contact { 2 | id: string; 3 | uuid: string; 4 | userId: string; 5 | contactUserId: string; 6 | createdAt: Date; 7 | modifiedAt: Date; 8 | } 9 | -------------------------------------------------------------------------------- /src/models/db-schema.ts: -------------------------------------------------------------------------------- 1 | import { User } from "./user"; 2 | import { Contact } from "./contact"; 3 | import { BankAccount } from "./bankaccount"; 4 | import { Transaction } from "./transaction"; 5 | import { Like } from "./like"; 6 | import { BankTransfer } from "./banktransfer"; 7 | import { NotificationType } from "./notification"; 8 | import { Comment } from "./comment"; 9 | 10 | export interface DbSchema { 11 | users: User[]; 12 | contacts: Contact[]; 13 | bankaccounts: BankAccount[]; 14 | transactions: Transaction[]; 15 | likes: Like[]; 16 | comments: Comment[]; 17 | notifications: NotificationType[]; 18 | banktransfers: BankTransfer[]; 19 | } 20 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./user"; 2 | export * from "./bankaccount"; 3 | export * from "./contact"; 4 | export * from "./transaction"; 5 | export * from "./like"; 6 | export * from "./comment"; 7 | export * from "./notification"; 8 | export * from "./banktransfer"; 9 | -------------------------------------------------------------------------------- /src/models/like.ts: -------------------------------------------------------------------------------- 1 | export interface Like { 2 | id: string; 3 | uuid: string; 4 | userId: string; 5 | transactionId: string; 6 | createdAt: Date; 7 | modifiedAt: Date; 8 | } 9 | -------------------------------------------------------------------------------- /src/models/notification.ts: -------------------------------------------------------------------------------- 1 | export enum PaymentNotificationStatus { 2 | requested = "requested", 3 | received = "received", 4 | incomplete = "incomplete", 5 | } 6 | 7 | export enum NotificationsType { 8 | payment = "payment", 9 | like = "like", 10 | comment = "comment", 11 | } 12 | 13 | export interface NotificationBase { 14 | id: string; 15 | uuid: string; 16 | userId: string; 17 | transactionId: string; 18 | isRead: boolean; 19 | createdAt: Date; 20 | modifiedAt: Date; 21 | } 22 | 23 | export type NotificationUpdatePayload = Partial>; 24 | 25 | export interface PaymentNotification extends NotificationBase { 26 | status: PaymentNotificationStatus; 27 | } 28 | 29 | export interface LikeNotification extends NotificationBase { 30 | likeId: string; 31 | } 32 | 33 | export interface CommentNotification extends NotificationBase { 34 | commentId: string; 35 | } 36 | 37 | export interface PaymentNotificationResponseItem extends PaymentNotification { 38 | userFullName: string; 39 | } 40 | 41 | export interface CommentNotificationResponseItem extends CommentNotification { 42 | userFullName: string; 43 | } 44 | 45 | export interface LikeNotificationResponseItem extends LikeNotification { 46 | userFullName: string; 47 | } 48 | 49 | export interface NotificationPayloadBase { 50 | type: NotificationsType; 51 | transactionId: string; 52 | } 53 | 54 | export interface PaymentNotificationPayload extends NotificationPayloadBase { 55 | status: PaymentNotificationStatus; 56 | } 57 | 58 | export interface LikeNotificationPayload extends NotificationPayloadBase { 59 | likeId: string; 60 | } 61 | 62 | export interface CommentNotificationPayload extends NotificationPayloadBase { 63 | commentId: string; 64 | } 65 | 66 | export type NotificationType = PaymentNotification | LikeNotification | CommentNotification; 67 | 68 | export type NotificationPayloadType = 69 | | PaymentNotificationPayload 70 | | LikeNotificationPayload 71 | | CommentNotificationPayload; 72 | 73 | export type NotificationResponseItem = 74 | | PaymentNotificationResponseItem 75 | | LikeNotificationResponseItem 76 | | CommentNotificationResponseItem; 77 | -------------------------------------------------------------------------------- /src/models/transaction.ts: -------------------------------------------------------------------------------- 1 | import { DefaultPrivacyLevel } from "./user"; 2 | import { Like, Comment } from "."; 3 | 4 | export enum TransactionStatus { 5 | pending = "pending", 6 | incomplete = "incomplete", 7 | complete = "complete", 8 | } 9 | 10 | export enum TransactionRequestStatus { 11 | pending = "pending", 12 | accepted = "accepted", 13 | rejected = "rejected", 14 | } 15 | 16 | export interface Transaction { 17 | id: string; 18 | uuid: string; 19 | source: string; // Empty if Payment or Request; Populated with BankAccount ID 20 | amount: number; 21 | description: string; 22 | privacyLevel: DefaultPrivacyLevel; 23 | receiverId: string; 24 | senderId: string; 25 | balanceAtCompletion?: number; 26 | status: TransactionStatus; 27 | requestStatus?: TransactionRequestStatus | string; 28 | requestResolvedAt?: Date | string; 29 | createdAt: Date; 30 | modifiedAt: Date; 31 | } 32 | 33 | export interface FakeTransaction { 34 | id?: string; 35 | uuid?: string; 36 | source?: string; // Empty if Payment or Request; Populated with BankAccount ID 37 | amount?: number; 38 | description?: string; 39 | privacyLevel?: DefaultPrivacyLevel; 40 | receiverId: string; 41 | senderId: string; 42 | balanceAtCompletion?: number; 43 | status?: TransactionStatus; 44 | requestStatus?: TransactionRequestStatus | string; 45 | requestResolvedAt?: Date | string; 46 | createdAt?: Date; 47 | modifiedAt?: Date; 48 | } 49 | 50 | export interface TransactionResponseItem extends Transaction { 51 | likes: Like[]; 52 | comments: Comment[]; 53 | receiverName: string; 54 | receiverAvatar: string; 55 | senderName: string; 56 | senderAvatar: string; 57 | } 58 | 59 | export type TransactionScenario = { 60 | status: TransactionStatus; 61 | requestStatus: TransactionRequestStatus | string; 62 | }; 63 | 64 | export type TransactionPayload = Omit; 65 | 66 | export type TransactionCreatePayload = Partial< 67 | Pick & { 68 | amount: string; 69 | transactionType: string; 70 | } 71 | >; 72 | 73 | export type TransactionUpdateActionPayload = Pick; 74 | 75 | type TransactionQueryBase = { 76 | dateRangeStart?: string; 77 | dateRangeEnd?: string; 78 | amountMin?: number; 79 | amountMax?: number; 80 | status?: TransactionStatus; 81 | limit?: number; 82 | page?: number; 83 | }; 84 | 85 | export type TransactionQueryPayload = Partial; 86 | 87 | export type TransactionDateRangePayload = Partial< 88 | Pick 89 | >; 90 | 91 | export type TransactionAmountRangePayload = Partial< 92 | Pick 93 | >; 94 | 95 | export type TransactionPaginationPayload = Partial>; 96 | 97 | export type TransactionClearFiltersPayload = { 98 | filterType: "date" | "amount"; 99 | }; 100 | 101 | export type TransactionPagination = { 102 | page: number; 103 | limit: number; 104 | hasNextPages: boolean; 105 | totalPages: number; 106 | }; 107 | -------------------------------------------------------------------------------- /src/models/user.ts: -------------------------------------------------------------------------------- 1 | export enum DefaultPrivacyLevel { 2 | public = "public", 3 | private = "private", 4 | contacts = "contacts", 5 | } 6 | 7 | export interface User { 8 | id: string; 9 | uuid: string; 10 | firstName: string; 11 | lastName: string; 12 | username: string; 13 | password: string; 14 | email: string; 15 | phoneNumber: string; 16 | balance: number; 17 | avatar: string; 18 | defaultPrivacyLevel: DefaultPrivacyLevel; 19 | createdAt: Date; 20 | modifiedAt: Date; 21 | } 22 | 23 | export type UserSettingsPayload = Pick< 24 | User, 25 | "firstName" | "lastName" | "email" | "phoneNumber" | "defaultPrivacyLevel" 26 | >; 27 | 28 | export type SignInPayload = Pick & { 29 | remember?: Boolean; 30 | }; 31 | 32 | export type SignUpPayload = Pick; 33 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const createProxyMiddleware = require("http-proxy-middleware"); 2 | 3 | module.exports = function (app) { 4 | app.use( 5 | createProxyMiddleware(["/login", "/callback", "/logout", "/checkAuth"], { 6 | target: `http://localhost:3001`, 7 | changeOrigin: true, 8 | logLevel: "debug", 9 | }) 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/svgs/rwa-icon-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | -------------------------------------------------------------------------------- /src/utils/__tests__/transactionUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isRequestTransaction, 3 | getFakeAmount, 4 | currentUserLikesTransaction, 5 | getQueryWithoutDateFields, 6 | getQueryWithoutAmountFields, 7 | getQueryWithoutFilterFields, 8 | } from "../transactionUtils"; 9 | import faker from "faker"; 10 | import { 11 | Transaction, 12 | TransactionRequestStatus, 13 | DefaultPrivacyLevel, 14 | TransactionStatus, 15 | TransactionResponseItem, 16 | } from "../../models"; 17 | import shortid from "shortid"; 18 | 19 | const fakeTransaction = ( 20 | requestStatus?: TransactionRequestStatus, 21 | createdAt?: Date 22 | ): Transaction => ({ 23 | id: shortid(), 24 | uuid: faker.random.uuid(), 25 | source: shortid(), 26 | amount: getFakeAmount(), 27 | description: "food", 28 | privacyLevel: DefaultPrivacyLevel.public, 29 | receiverId: shortid(), 30 | senderId: shortid(), 31 | balanceAtCompletion: getFakeAmount(), 32 | status: TransactionStatus.pending, 33 | requestStatus, 34 | requestResolvedAt: faker.date.future(), 35 | createdAt: faker.date.past(), 36 | modifiedAt: createdAt || faker.date.recent(), 37 | }); 38 | 39 | describe("Transaction Utils", () => { 40 | describe("isRequestTransaction", () => { 41 | let transaction; 42 | 43 | test("validates that a transaction is a request", () => { 44 | for (let s in TransactionRequestStatus) { 45 | transaction = fakeTransaction(s as TransactionRequestStatus); 46 | expect(isRequestTransaction(transaction)).toBeTruthy(); 47 | } 48 | }); 49 | 50 | test("validates that a transaction is not a request", () => { 51 | transaction = fakeTransaction(); 52 | expect(isRequestTransaction(transaction)).toBe(false); 53 | }); 54 | 55 | test("checks if the current user likes a transaction", () => { 56 | const transactionBase = fakeTransaction(); 57 | 58 | const currentUser = { 59 | id: "9IUK0xpw", 60 | uuid: faker.random.uuid(), 61 | firstName: faker.name.firstName(), 62 | lastName: faker.name.lastName(), 63 | username: faker.internet.userName(), 64 | password: "abc123", 65 | email: faker.internet.email(), 66 | phoneNumber: faker.phone.phoneNumber(), 67 | avatar: faker.internet.avatar(), 68 | defaultPrivacyLevel: DefaultPrivacyLevel.public, 69 | balance: faker.random.number(), 70 | createdAt: faker.date.past(), 71 | modifiedAt: faker.date.recent(), 72 | }; 73 | 74 | const transactionWithLikes: TransactionResponseItem = { 75 | ...transactionBase, 76 | receiverName: "Receiver Name", 77 | receiverAvatar: "/path/to/receiver/avatar.png", 78 | senderAvatar: "/path/to/sender/avatar.png", 79 | senderName: "Sender Name", 80 | likes: [ 81 | { 82 | id: "ExVksKSH", 83 | uuid: "c849329f-42f7-4ff5-a792-e01c9cec05b5", 84 | userId: "9IUK0xpw", 85 | transactionId: "dKAI-6Ua", 86 | createdAt: new Date(), 87 | modifiedAt: new Date(), 88 | }, 89 | ], 90 | comments: [], 91 | }; 92 | 93 | expect(currentUserLikesTransaction(currentUser, transactionWithLikes)).toBe(true); 94 | 95 | const otherCurrentUser = { 96 | ...currentUser, 97 | id: "ABC123", 98 | }; 99 | 100 | expect(currentUserLikesTransaction(otherCurrentUser, transactionWithLikes)).toBe(false); 101 | }); 102 | }); 103 | 104 | test("gets query without date range fields", () => { 105 | expect( 106 | getQueryWithoutDateFields({ 107 | dateRangeStart: new Date().toString(), 108 | dateRangeEnd: new Date().toString(), 109 | status: TransactionStatus.incomplete, 110 | }) 111 | ).toMatchObject({ status: "incomplete" }); 112 | expect( 113 | getQueryWithoutDateFields({ 114 | status: TransactionStatus.incomplete, 115 | }) 116 | ).toMatchObject({ status: "incomplete" }); 117 | }); 118 | 119 | test("gets query without amount range fields", () => { 120 | expect( 121 | getQueryWithoutAmountFields({ 122 | amountMin: 5, 123 | amountMax: 10, 124 | status: TransactionStatus.incomplete, 125 | }) 126 | ).toMatchObject({ status: "incomplete" }); 127 | expect( 128 | getQueryWithoutAmountFields({ 129 | status: TransactionStatus.incomplete, 130 | }) 131 | ).toMatchObject({ status: "incomplete" }); 132 | }); 133 | 134 | test("gets query without date and amount range fields", () => { 135 | const query = { 136 | amountMin: 5, 137 | amountMax: 10, 138 | requestStatus: "pending", 139 | dateRangeStart: "2019-12-01T06:00:00.000Z", 140 | dateRangeEnd: "2019-12-05T06:00:00.000Z", 141 | }; 142 | expect(getQueryWithoutFilterFields(query)).toMatchObject({ 143 | requestStatus: "pending", 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/utils/asyncUtils.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const httpClient = axios.create({ withCredentials: true }); 4 | -------------------------------------------------------------------------------- /src/utils/historyUtils.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from "history"; 2 | 3 | export const history = createBrowserHistory(); 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react" 18 | }, 19 | "include": ["src", "scripts", "backend", "src/__tests__"] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.tsnode.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "isolatedModules": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | optimizeDeps: { 3 | include: ["lodash/fp"], 4 | }, 5 | plugins: [require("@vitejs/plugin-react-refresh")()], 6 | }; 7 | --------------------------------------------------------------------------------