├── .gitignore ├── email.txt ├── src ├── react-app-env.d.ts ├── .DS_Store ├── components │ ├── .DS_Store │ ├── AppContextProvider │ │ ├── types.ts │ │ └── index.tsx │ ├── InputCheckbox │ │ ├── types.ts │ │ └── index.tsx │ ├── InputSelect │ │ ├── types.ts │ │ └── index.tsx │ ├── Instructions.tsx │ └── Transactions │ │ ├── types.ts │ │ ├── index.tsx │ │ └── TransactionPane.tsx ├── utils │ ├── constants.ts │ ├── context.ts │ ├── types.ts │ ├── requests.ts │ └── fetch.ts ├── index.tsx ├── hooks │ ├── types.ts │ ├── useEmployees.ts │ ├── useWrappedRequest.ts │ ├── useTransactionsByEmployee.ts │ ├── usePaginatedTransactions.ts │ └── useCustomFetch.ts ├── App.tsx ├── index.css └── mock-data.json ├── public └── index.html ├── tsconfig.json ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /email.txt: -------------------------------------------------------------------------------- 1 | YOUR_EMAIL_HERE -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyire/ramp-challenge/main/src/.DS_Store -------------------------------------------------------------------------------- /src/components/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyire/ramp-challenge/main/src/components/.DS_Store -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import { Employee } from "./types" 2 | 3 | export const EMPTY_EMPLOYEE: Employee = { 4 | id: "", 5 | firstName: "All", 6 | lastName: "Employees", 7 | } 8 | -------------------------------------------------------------------------------- /src/components/AppContextProvider/types.ts: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, PropsWithChildren } from "react" 2 | 3 | export type AppContextProviderComponent = FunctionComponent> 4 | -------------------------------------------------------------------------------- /src/utils/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react" 2 | 3 | export const AppContext = createContext({ setError: () => {} }) 4 | 5 | type AppContextProps = { 6 | setError: (error: string) => void 7 | cache?: React.MutableRefObject> 8 | } 9 | -------------------------------------------------------------------------------- /src/components/InputCheckbox/types.ts: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react" 2 | 3 | type InputCheckboxProps = { 4 | id: string | number 5 | checked?: boolean 6 | onChange: (newValue: boolean) => void 7 | disabled?: boolean 8 | } 9 | 10 | export type InputCheckboxComponent = FunctionComponent 11 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client" 2 | import "./index.css" 3 | import { App } from "./App" 4 | import { AppContextProvider } from "./components/AppContextProvider" 5 | 6 | const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement) 7 | root.render( 8 | 9 | 10 | 11 | ) 12 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Ramp | Approvals 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "." 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /src/components/InputSelect/types.ts: -------------------------------------------------------------------------------- 1 | export type InputSelectItem = { label: string; value: string } 2 | 3 | export type InputSelectProps = { 4 | label: string 5 | defaultValue?: TItem | null 6 | onChange: (value: TItem | null) => void 7 | items: TItem[] 8 | parseItem: (item: TItem) => InputSelectItem 9 | isLoading?: boolean 10 | loadingLabel: string 11 | } 12 | 13 | export type DropdownPosition = { 14 | top: number 15 | left: number 16 | } 17 | 18 | export type InputSelectOnChange = (selectedItem: TItem | null) => void 19 | 20 | export type GetDropdownPositionFn = (target: EventTarget) => DropdownPosition 21 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type Transaction = { 2 | id: string 3 | amount: number 4 | employee: Employee 5 | merchant: string 6 | date: string 7 | approved: boolean 8 | } 9 | 10 | export type Employee = { 11 | id: string 12 | firstName: string 13 | lastName: string 14 | } 15 | 16 | export type PaginatedResponse = { 17 | data: TData 18 | nextPage: number | null 19 | } 20 | 21 | export type PaginatedRequestParams = { 22 | page: number | null 23 | } 24 | 25 | export type RequestByEmployeeParams = { 26 | employeeId: string 27 | } 28 | 29 | export type SetTransactionApprovalParams = { 30 | transactionId: string 31 | value: boolean 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Instructions.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react" 2 | 3 | export function Instructions() { 4 | return ( 5 | 6 |

Approve transactions

7 |
8 |

9 | Your company uses Ramp as their main financial instrument. You are a manager and you need to 10 | approve the transactions made by your employees. 11 | 12 | Select the checkbox on the right to approve or decline the transactions. You can filter 13 | transactions by employee. 14 |

15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Transactions/types.ts: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react" 2 | import { Transaction } from "../../utils/types" 3 | 4 | export type SetTransactionApprovalFunction = (params: { 5 | transactionId: string 6 | newValue: boolean 7 | }) => Promise 8 | 9 | type TransactionsProps = { transactions: Transaction[] | null } 10 | 11 | type TransactionPaneProps = { 12 | transaction: Transaction 13 | loading: boolean 14 | approved?: boolean 15 | setTransactionApproval: SetTransactionApprovalFunction 16 | } 17 | 18 | export type TransactionsComponent = FunctionComponent 19 | export type TransactionPaneComponent = FunctionComponent 20 | -------------------------------------------------------------------------------- /src/hooks/types.ts: -------------------------------------------------------------------------------- 1 | import { Employee, PaginatedResponse, Transaction } from "../utils/types" 2 | 3 | type UseTypeBaseResult = { 4 | data: TValue 5 | loading: boolean 6 | invalidateData: () => void 7 | } 8 | 9 | type UseTypeBaseAllResult = UseTypeBaseResult & { 10 | fetchAll: () => Promise 11 | } 12 | 13 | type UseTypeBaseByIdResult = UseTypeBaseResult & { 14 | fetchById: (id: string) => Promise 15 | } 16 | 17 | export type EmployeeResult = UseTypeBaseAllResult 18 | 19 | export type PaginatedTransactionsResult = UseTypeBaseAllResult | null> 20 | 21 | export type TransactionsByEmployeeResult = UseTypeBaseByIdResult 22 | -------------------------------------------------------------------------------- /src/hooks/useEmployees.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react" 2 | import { Employee } from "../utils/types" 3 | import { useCustomFetch } from "./useCustomFetch" 4 | import { EmployeeResult } from "./types" 5 | 6 | export function useEmployees(): EmployeeResult { 7 | const { fetchWithCache, loading } = useCustomFetch() 8 | const [employees, setEmployees] = useState(null) 9 | 10 | const fetchAll = useCallback(async () => { 11 | const employeesData = await fetchWithCache("employees") 12 | setEmployees(employeesData) 13 | }, [fetchWithCache]) 14 | 15 | const invalidateData = useCallback(() => { 16 | setEmployees(null) 17 | }, []) 18 | 19 | return { data: employees, loading, fetchAll, invalidateData } 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useWrappedRequest.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useState } from "react" 2 | import { AppContext } from "../utils/context" 3 | 4 | export function useWrappedRequest() { 5 | const [loading, setLoading] = useState(false) 6 | const { setError } = useContext(AppContext) 7 | 8 | const wrappedRequest = useCallback( 9 | async (promise: () => Promise): Promise => { 10 | try { 11 | setLoading(true) 12 | const result = await promise() 13 | return result 14 | } catch (error) { 15 | setError(error as string) 16 | return null 17 | } finally { 18 | setLoading(false) 19 | } 20 | }, 21 | [setError] 22 | ) 23 | 24 | return { loading, wrappedRequest } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/AppContextProvider/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react" 2 | import { AppContext } from "../../utils/context" 3 | import { AppContextProviderComponent } from "./types" 4 | 5 | export const AppContextProvider: AppContextProviderComponent = ({ children }) => { 6 | const cache = useRef(new Map()) 7 | const [error, setError] = useState("") 8 | 9 | return ( 10 | 11 | {error ? ( 12 |
13 |

Oops. Application broken

14 |
15 | Error: {error} 16 |
17 | ) : ( 18 | children 19 | )} 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/InputCheckbox/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames" 2 | import { useRef } from "react" 3 | import { InputCheckboxComponent } from "./types" 4 | 5 | export const InputCheckbox: InputCheckboxComponent = ({ id, checked = false, disabled, onChange }) => { 6 | const { current: inputId } = useRef(`RampInputCheckbox-${id}`) 7 | 8 | return ( 9 |
10 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useTransactionsByEmployee.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react" 2 | import { RequestByEmployeeParams, Transaction } from "../utils/types" 3 | import { TransactionsByEmployeeResult } from "./types" 4 | import { useCustomFetch } from "./useCustomFetch" 5 | 6 | export function useTransactionsByEmployee(): TransactionsByEmployeeResult { 7 | const { fetchWithCache, loading } = useCustomFetch() 8 | const [transactionsByEmployee, setTransactionsByEmployee] = useState(null) 9 | 10 | const fetchById = useCallback( 11 | async (employeeId: string) => { 12 | const data = await fetchWithCache( 13 | "transactionsByEmployee", 14 | { 15 | employeeId, 16 | } 17 | ) 18 | 19 | setTransactionsByEmployee(data) 20 | }, 21 | [fetchWithCache] 22 | ) 23 | 24 | const invalidateData = useCallback(() => { 25 | setTransactionsByEmployee(null) 26 | }, []) 27 | 28 | return { data: transactionsByEmployee, loading, fetchById, invalidateData } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Transactions/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react" 2 | import { useCustomFetch } from "src/hooks/useCustomFetch" 3 | import { SetTransactionApprovalParams } from "src/utils/types" 4 | import { TransactionPane } from "./TransactionPane" 5 | import { SetTransactionApprovalFunction, TransactionsComponent } from "./types" 6 | 7 | export const Transactions: TransactionsComponent = ({ transactions }) => { 8 | const { fetchWithoutCache, loading } = useCustomFetch() 9 | 10 | const setTransactionApproval = useCallback( 11 | async ({ transactionId, newValue }) => { 12 | await fetchWithoutCache("setTransactionApproval", { 13 | transactionId, 14 | value: newValue, 15 | }) 16 | }, 17 | [fetchWithoutCache] 18 | ) 19 | 20 | if (transactions === null) { 21 | return
Loading...
22 | } 23 | 24 | return ( 25 |
26 | {transactions.map((transaction) => ( 27 | 33 | ))} 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/hooks/usePaginatedTransactions.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react" 2 | import { PaginatedRequestParams, PaginatedResponse, Transaction } from "../utils/types" 3 | import { PaginatedTransactionsResult } from "./types" 4 | import { useCustomFetch } from "./useCustomFetch" 5 | 6 | export function usePaginatedTransactions(): PaginatedTransactionsResult { 7 | const { fetchWithCache, loading } = useCustomFetch() 8 | const [paginatedTransactions, setPaginatedTransactions] = useState | null>(null) 11 | 12 | const fetchAll = useCallback(async () => { 13 | const response = await fetchWithCache, PaginatedRequestParams>( 14 | "paginatedTransactions", 15 | { 16 | page: paginatedTransactions === null ? 0 : paginatedTransactions.nextPage, 17 | } 18 | ) 19 | 20 | setPaginatedTransactions((previousResponse) => { 21 | if (response === null || previousResponse === null) { 22 | return response 23 | } 24 | 25 | return { data: response.data, nextPage: response.nextPage } 26 | }) 27 | }, [fetchWithCache, paginatedTransactions]) 28 | 29 | const invalidateData = useCallback(() => { 30 | setPaginatedTransactions(null) 31 | }, []) 32 | 33 | return { data: paginatedTransactions, loading, fetchAll, invalidateData } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Transactions/TransactionPane.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { InputCheckbox } from "../InputCheckbox" 3 | import { TransactionPaneComponent } from "./types" 4 | 5 | export const TransactionPane: TransactionPaneComponent = ({ 6 | transaction, 7 | loading, 8 | setTransactionApproval: consumerSetTransactionApproval, 9 | }) => { 10 | const [approved, setApproved] = useState(transaction.approved) 11 | 12 | return ( 13 |
14 |
15 |

{transaction.merchant}

16 | {moneyFormatter.format(transaction.amount)} 17 |

18 | {transaction.employee.firstName} {transaction.employee.lastName} - {transaction.date} 19 |

20 |
21 | { 26 | await consumerSetTransactionApproval({ 27 | transactionId: transaction.id, 28 | newValue, 29 | }) 30 | 31 | setApproved(newValue) 32 | }} 33 | /> 34 |
35 | ) 36 | } 37 | 38 | const moneyFormatter = new Intl.NumberFormat("en-US", { 39 | style: "currency", 40 | currency: "USD", 41 | }) 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ramp-fe-challenge", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "classnames": "^2.3.2", 7 | "downshift": "^7.0.4", 8 | "react": "^18.2.0", 9 | "react-dom": "^18.2.0", 10 | "react-scripts": "5.0.1", 11 | "typescript": "^4.9.4" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "upload": "codesandbox ./", 16 | "format": "prettier --write ." 17 | }, 18 | "eslintConfig": { 19 | "extends": [ 20 | "react-app", 21 | "react-app/jest" 22 | ] 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | }, 36 | "devDependencies": { 37 | "@playwright/test": "^1.32.1", 38 | "@types/jest": "^29.2.4", 39 | "@types/node": "^18.11.15", 40 | "@types/react": "^18.0.26", 41 | "@types/react-dom": "^18.0.9", 42 | "codesandbox": "^2.2.3", 43 | "dotenv": "^16.0.3", 44 | "prettier": "^2.8.1", 45 | "wait-on": "^7.0.1" 46 | }, 47 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/requests.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PaginatedRequestParams, 3 | PaginatedResponse, 4 | RequestByEmployeeParams, 5 | SetTransactionApprovalParams, 6 | Transaction, 7 | Employee, 8 | } from "./types" 9 | import mockData from "../mock-data.json" 10 | 11 | const TRANSACTIONS_PER_PAGE = 5 12 | 13 | const data: { employees: Employee[]; transactions: Transaction[] } = { 14 | employees: mockData.employees, 15 | transactions: mockData.transactions, 16 | } 17 | 18 | export const getEmployees = (): Employee[] => data.employees 19 | 20 | export const getTransactionsPaginated = ({ 21 | page, 22 | }: PaginatedRequestParams): PaginatedResponse => { 23 | if (page === null) { 24 | throw new Error("Page cannot be null") 25 | } 26 | 27 | const start = page * TRANSACTIONS_PER_PAGE 28 | const end = start + TRANSACTIONS_PER_PAGE 29 | 30 | if (start > data.transactions.length) { 31 | throw new Error(`Invalid page ${page}`) 32 | } 33 | 34 | const nextPage = end < data.transactions.length ? page + 1 : null 35 | 36 | return { 37 | nextPage, 38 | data: data.transactions.slice(start, end), 39 | } 40 | } 41 | 42 | export const getTransactionsByEmployee = ({ employeeId }: RequestByEmployeeParams) => { 43 | if (!employeeId) { 44 | throw new Error("Employee id cannot be empty") 45 | } 46 | 47 | return data.transactions.filter((transaction) => transaction.employee.id === employeeId) 48 | } 49 | 50 | export const setTransactionApproval = ({ transactionId, value }: SetTransactionApprovalParams): void => { 51 | const transaction = data.transactions.find( 52 | (currentTransaction) => currentTransaction.id === transactionId 53 | ) 54 | 55 | if (!transaction) { 56 | throw new Error("Invalid transaction to approve") 57 | } 58 | 59 | transaction.approved = value 60 | } 61 | -------------------------------------------------------------------------------- /src/hooks/useCustomFetch.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext } from "react" 2 | import { AppContext } from "../utils/context" 3 | import { fakeFetch, RegisteredEndpoints } from "../utils/fetch" 4 | import { useWrappedRequest } from "./useWrappedRequest" 5 | 6 | export function useCustomFetch() { 7 | const { cache } = useContext(AppContext) 8 | const { loading, wrappedRequest } = useWrappedRequest() 9 | 10 | const fetchWithCache = useCallback( 11 | async ( 12 | endpoint: RegisteredEndpoints, 13 | params?: TParams 14 | ): Promise => 15 | wrappedRequest(async () => { 16 | const cacheKey = getCacheKey(endpoint, params) 17 | const cacheResponse = cache?.current.get(cacheKey) 18 | 19 | if (cacheResponse) { 20 | const data = JSON.parse(cacheResponse) 21 | return data as Promise 22 | } 23 | 24 | const result = await fakeFetch(endpoint, params) 25 | cache?.current.set(cacheKey, JSON.stringify(result)) 26 | return result 27 | }), 28 | [cache, wrappedRequest] 29 | ) 30 | 31 | const fetchWithoutCache = useCallback( 32 | async ( 33 | endpoint: RegisteredEndpoints, 34 | params?: TParams 35 | ): Promise => 36 | wrappedRequest(async () => { 37 | const result = await fakeFetch(endpoint, params) 38 | return result 39 | }), 40 | [wrappedRequest] 41 | ) 42 | 43 | const clearCache = useCallback(() => { 44 | if (cache?.current === undefined) { 45 | return 46 | } 47 | 48 | cache.current = new Map() 49 | }, [cache]) 50 | 51 | const clearCacheByEndpoint = useCallback( 52 | (endpointsToClear: RegisteredEndpoints[]) => { 53 | if (cache?.current === undefined) { 54 | return 55 | } 56 | 57 | const cacheKeys = Array.from(cache.current.keys()) 58 | 59 | for (const key of cacheKeys) { 60 | const clearKey = endpointsToClear.some((endpoint) => key.startsWith(endpoint)) 61 | 62 | if (clearKey) { 63 | cache.current.delete(key) 64 | } 65 | } 66 | }, 67 | [cache] 68 | ) 69 | 70 | return { fetchWithCache, fetchWithoutCache, clearCache, clearCacheByEndpoint, loading } 71 | } 72 | 73 | function getCacheKey(endpoint: RegisteredEndpoints, params?: object) { 74 | return `${endpoint}${params ? `@${JSON.stringify(params)}` : ""}` 75 | } 76 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useCallback, useEffect, useMemo, useState } from "react" 2 | import { InputSelect } from "./components/InputSelect" 3 | import { Instructions } from "./components/Instructions" 4 | import { Transactions } from "./components/Transactions" 5 | import { useEmployees } from "./hooks/useEmployees" 6 | import { usePaginatedTransactions } from "./hooks/usePaginatedTransactions" 7 | import { useTransactionsByEmployee } from "./hooks/useTransactionsByEmployee" 8 | import { EMPTY_EMPLOYEE } from "./utils/constants" 9 | import { Employee } from "./utils/types" 10 | 11 | export function App() { 12 | const { data: employees, ...employeeUtils } = useEmployees() 13 | const { data: paginatedTransactions, ...paginatedTransactionsUtils } = usePaginatedTransactions() 14 | const { data: transactionsByEmployee, ...transactionsByEmployeeUtils } = useTransactionsByEmployee() 15 | const [isLoading, setIsLoading] = useState(false) 16 | 17 | const transactions = useMemo( 18 | () => paginatedTransactions?.data ?? transactionsByEmployee ?? null, 19 | [paginatedTransactions, transactionsByEmployee] 20 | ) 21 | 22 | const loadAllTransactions = useCallback(async () => { 23 | setIsLoading(true) 24 | transactionsByEmployeeUtils.invalidateData() 25 | 26 | await employeeUtils.fetchAll() 27 | await paginatedTransactionsUtils.fetchAll() 28 | 29 | setIsLoading(false) 30 | }, [employeeUtils, paginatedTransactionsUtils, transactionsByEmployeeUtils]) 31 | 32 | const loadTransactionsByEmployee = useCallback( 33 | async (employeeId: string) => { 34 | paginatedTransactionsUtils.invalidateData() 35 | await transactionsByEmployeeUtils.fetchById(employeeId) 36 | }, 37 | [paginatedTransactionsUtils, transactionsByEmployeeUtils] 38 | ) 39 | 40 | useEffect(() => { 41 | if (employees === null && !employeeUtils.loading) { 42 | loadAllTransactions() 43 | } 44 | }, [employeeUtils.loading, employees, loadAllTransactions]) 45 | 46 | return ( 47 | 48 |
49 | 50 | 51 |
52 | 53 | 54 | isLoading={isLoading} 55 | defaultValue={EMPTY_EMPLOYEE} 56 | items={employees === null ? [] : [EMPTY_EMPLOYEE, ...employees]} 57 | label="Filter by employee" 58 | loadingLabel="Loading employees" 59 | parseItem={(item) => ({ 60 | value: item.id, 61 | label: `${item.firstName} ${item.lastName}`, 62 | })} 63 | onChange={async (newValue) => { 64 | if (newValue === null) { 65 | return 66 | } 67 | 68 | await loadTransactionsByEmployee(newValue.id) 69 | }} 70 | /> 71 | 72 |
73 | 74 |
75 | 76 | 77 | {transactions !== null && ( 78 | 87 | )} 88 |
89 |
90 |
91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getEmployees, 3 | getTransactionsPaginated, 4 | getTransactionsByEmployee, 5 | setTransactionApproval, 6 | } from "./requests" 7 | import { PaginatedRequestParams, RequestByEmployeeParams, SetTransactionApprovalParams } from "./types" 8 | 9 | const timeout = getTimeout() 10 | const mockTimeout = 1 * timeout 11 | 12 | export function fakeFetch( 13 | endpoint: RegisteredEndpoints, 14 | params?: TParams 15 | ): Promise { 16 | return new Promise((resolve, reject) => { 17 | mockApiLogger({ 18 | message: "Loading request", 19 | data: { endpoint, params }, 20 | type: "info", 21 | }) 22 | 23 | let result: TData 24 | 25 | try { 26 | switch (endpoint) { 27 | case "employees": 28 | result = getEmployees() as unknown as TData 29 | 30 | setTimeout(() => { 31 | mockApiLogger({ data: { endpoint, params, result } }) 32 | resolve(result) 33 | }, mockTimeout) 34 | break 35 | 36 | case "paginatedTransactions": 37 | result = getTransactionsPaginated(params as PaginatedRequestParams) as unknown as TData 38 | 39 | setTimeout(() => { 40 | mockApiLogger({ data: { endpoint, params, result } }) 41 | resolve(result) 42 | }, mockTimeout * 2.5) 43 | break 44 | 45 | case "transactionsByEmployee": 46 | result = getTransactionsByEmployee(params as RequestByEmployeeParams) as unknown as TData 47 | 48 | setTimeout(() => { 49 | mockApiLogger({ data: { endpoint, params, result } }) 50 | resolve(result) 51 | }, mockTimeout * 1.5) 52 | break 53 | 54 | case "setTransactionApproval": 55 | result = setTransactionApproval(params as SetTransactionApprovalParams) as unknown as TData 56 | 57 | setTimeout(() => { 58 | mockApiLogger({ data: { endpoint, params, result } }) 59 | resolve(result) 60 | }, mockTimeout * 1) 61 | break 62 | 63 | default: 64 | throw new Error("Invalid endpoint") 65 | } 66 | } catch (error) { 67 | if (error instanceof Error) { 68 | mockApiLogger({ 69 | message: error.message, 70 | data: { endpoint, params }, 71 | type: "error", 72 | }) 73 | reject(error.message) 74 | } 75 | } 76 | }) 77 | } 78 | 79 | function mockApiLogger({ 80 | data, 81 | message = "Success request", 82 | type = "success", 83 | }: { 84 | message?: string 85 | data: object 86 | type?: "success" | "error" | "info" 87 | }) { 88 | if (process.env.REACT_APP_MOCK_REQUEST_LOGS_ENABLED === "false") { 89 | return 90 | } 91 | 92 | console.log(`%c--Fake Request Debugger-- %c${message}`, "color: #717171", getTitleColor()) 93 | console.log(data) 94 | 95 | function getTitleColor() { 96 | if (type === "error") { 97 | return "color: #d93e3e;" 98 | } 99 | 100 | if (type === "info") { 101 | return "color: #1670d2;" 102 | } 103 | 104 | return "color: #548a54;" 105 | } 106 | } 107 | 108 | function getTimeout() { 109 | const timeout = parseInt( 110 | new URL(document.location as unknown as URL).searchParams.get("timeout") ?? 111 | process.env.REACT_APP_TIMEOUT_MULTIPLIER ?? 112 | "1000" 113 | ) 114 | 115 | if (Number.isNaN(timeout)) { 116 | return 1000 117 | } 118 | 119 | return timeout 120 | } 121 | 122 | export type RegisteredEndpoints = 123 | | "employees" 124 | | "paginatedTransactions" 125 | | "transactionsByEmployee" 126 | | "setTransactionApproval" 127 | -------------------------------------------------------------------------------- /src/components/InputSelect/index.tsx: -------------------------------------------------------------------------------- 1 | import Downshift from "downshift" 2 | import { useCallback, useState } from "react" 3 | import classNames from "classnames" 4 | import { DropdownPosition, GetDropdownPositionFn, InputSelectOnChange, InputSelectProps } from "./types" 5 | 6 | export function InputSelect({ 7 | label, 8 | defaultValue, 9 | onChange: consumerOnChange, 10 | items, 11 | parseItem, 12 | isLoading, 13 | loadingLabel, 14 | }: InputSelectProps) { 15 | const [selectedValue, setSelectedValue] = useState(defaultValue ?? null) 16 | const [dropdownPosition, setDropdownPosition] = useState({ 17 | top: 0, 18 | left: 0, 19 | }) 20 | 21 | const onChange = useCallback>( 22 | (selectedItem) => { 23 | if (selectedItem === null) { 24 | return 25 | } 26 | 27 | consumerOnChange(selectedItem) 28 | setSelectedValue(selectedItem) 29 | }, 30 | [consumerOnChange] 31 | ) 32 | 33 | return ( 34 | 35 | id="RampSelect" 36 | onChange={onChange} 37 | selectedItem={selectedValue} 38 | itemToString={(item) => (item ? parseItem(item).label : "")} 39 | > 40 | {({ 41 | getItemProps, 42 | getLabelProps, 43 | getMenuProps, 44 | isOpen, 45 | highlightedIndex, 46 | selectedItem, 47 | getToggleButtonProps, 48 | inputValue, 49 | }) => { 50 | const toggleProps = getToggleButtonProps() 51 | const parsedSelectedItem = selectedItem === null ? null : parseItem(selectedItem) 52 | 53 | return ( 54 |
55 | 58 |
59 |
{ 62 | setDropdownPosition(getDropdownPosition(event.target)) 63 | toggleProps.onClick(event) 64 | }} 65 | > 66 | {inputValue} 67 |
68 | 69 |
76 | {renderItems()} 77 |
78 |
79 | ) 80 | 81 | function renderItems() { 82 | if (!isOpen) { 83 | return null 84 | } 85 | 86 | if (isLoading) { 87 | return
{loadingLabel}...
88 | } 89 | 90 | if (items.length === 0) { 91 | return
No items
92 | } 93 | 94 | return items.map((item, index) => { 95 | const parsedItem = parseItem(item) 96 | return ( 97 |
110 | {parsedItem.label} 111 |
112 | ) 113 | }) 114 | } 115 | }} 116 | 117 | ) 118 | } 119 | 120 | const getDropdownPosition: GetDropdownPositionFn = (target) => { 121 | if (target instanceof Element) { 122 | const { top, left } = target.getBoundingClientRect() 123 | const { scrollY } = window 124 | return { 125 | top: scrollY + top + 63, 126 | left, 127 | } 128 | } 129 | 130 | return { top: 0, left: 0 } 131 | } 132 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-empty-shade: #fff; 3 | --color-light-shade: #eaeaea; 4 | --color-lighter-shade: #edebe6; 5 | --color-dark-shade: #6f6f66; 6 | --color-darker-shade: #ecf3d3; 7 | --color-text: #2d2d26; 8 | --color-accent: #f4ff56; 9 | --color-constructive: #109d46; 10 | --color-destructive: #d94b03; 11 | } 12 | 13 | html, 14 | body { 15 | padding: 0; 16 | margin: 0; 17 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, 18 | Droid Sans, Helvetica Neue, sans-serif; 19 | } 20 | 21 | a { 22 | color: inherit; 23 | text-decoration: none; 24 | } 25 | 26 | * { 27 | padding: 0; 28 | margin: 0; 29 | box-sizing: border-box; 30 | } 31 | 32 | [class^="RampText--"] { 33 | line-height: 1.5; 34 | font-size: 1rem; 35 | margin: 0; 36 | } 37 | 38 | .RampText--s { 39 | font-size: 0.75rem; 40 | } 41 | 42 | .RampText--hushed { 43 | color: var(--color-dark-shade); 44 | } 45 | 46 | [class^="RampTextHeading--"] { 47 | margin: 0; 48 | line-height: 1; 49 | } 50 | 51 | .RampTextHeading--l { 52 | font-size: 2.5rem; 53 | } 54 | 55 | .RampGrid { 56 | display: grid; 57 | grid-template-columns: 1fr; 58 | gap: 0.75rem; 59 | width: 100%; 60 | } 61 | 62 | .RampBreak--xs { 63 | height: 0.5rem; 64 | } 65 | 66 | .RampBreak--s { 67 | height: 0.75rem; 68 | } 69 | 70 | .RampBreak--l { 71 | height: 1.75rem; 72 | } 73 | 74 | hr.RampBreak--l { 75 | height: 1px; 76 | width: 100%; 77 | margin: 1.75rem 0; 78 | border: none; 79 | background-color: var(--color-light-shade); 80 | } 81 | 82 | .RampInputSelect--root { 83 | width: 100%; 84 | } 85 | 86 | .RampInputSelect--input { 87 | padding: 1rem 0.75rem; 88 | border: 1px solid var(--color-light-shade); 89 | border-bottom: 1px solid var(--color-text); 90 | cursor: pointer; 91 | } 92 | 93 | .RampInputSelect--dropdown-container { 94 | display: none; 95 | position: fixed; 96 | width: 100%; 97 | max-width: 700px; 98 | border: 1px solid var(--color-darker-shade); 99 | margin-top: 0.5rem; 100 | max-height: 16rem; 101 | overflow: auto; 102 | box-shadow: rgb(0 0 0 / 10%) 0px 0px 1px, rgb(0 0 0 / 13%) 0px 4px 8px; 103 | } 104 | 105 | .RampInputSelect--dropdown-container-opened { 106 | display: block; 107 | } 108 | 109 | .RampInputSelect--dropdown-item { 110 | padding: 1rem 0.75rem; 111 | background-color: var(--color-empty-shade); 112 | border-bottom: 1px solid var(--color-light-shade); 113 | cursor: pointer; 114 | } 115 | 116 | .RampInputSelect--dropdown-item-highlighted { 117 | background-color: var(--color-lighter-shade); 118 | } 119 | 120 | .RampInputSelect--dropdown-item-selected { 121 | font-weight: bold; 122 | } 123 | 124 | .RampInputCheckbox--container { 125 | display: flex; 126 | } 127 | 128 | .RampInputCheckbox--label { 129 | border: 1px solid var(--color-light-shade); 130 | background-color: var(--color-lighter-shade); 131 | display: flex; 132 | justify-content: center; 133 | align-items: center; 134 | padding: 0.125rem; 135 | width: 1.25rem; 136 | height: 1.25rem; 137 | cursor: pointer; 138 | } 139 | 140 | .RampInputCheckbox--label-disabled { 141 | cursor: progress; 142 | } 143 | 144 | .RampInputCheckbox--label-checked:before { 145 | content: " "; 146 | width: 100%; 147 | height: 100%; 148 | border-color: var(--color-constructive); 149 | background-color: var(--color-constructive); 150 | } 151 | 152 | .RampInputCheckbox--input { 153 | display: none; 154 | } 155 | 156 | .RampButton { 157 | border: 1px solid var(--color-light-shade); 158 | background-color: var(--color-light-shade); 159 | padding: 0.5rem; 160 | cursor: pointer; 161 | } 162 | 163 | .RampButton:hover { 164 | background-color: var(--color-lighter-shade); 165 | } 166 | 167 | .RampButton:disabled { 168 | cursor: not-allowed; 169 | } 170 | 171 | .RampPane { 172 | padding: 1rem; 173 | border: 1px solid var(--color-light-shade); 174 | display: flex; 175 | justify-content: space-between; 176 | align-items: center; 177 | gap: 1rem; 178 | } 179 | 180 | .RampPane--content { 181 | flex: 1; 182 | } 183 | 184 | .RampLoading--container { 185 | display: flex; 186 | justify-content: center; 187 | } 188 | 189 | .RampError { 190 | margin: 4rem auto; 191 | max-width: 40rem; 192 | border: 1px solid var(--color-destructive); 193 | color: var(--color-destructive); 194 | padding: 2rem; 195 | } 196 | 197 | .MainContainer { 198 | max-width: 700px; 199 | margin: 0 auto; 200 | min-height: 100vh; 201 | padding: 4rem 0; 202 | } 203 | 204 | .FilterContainer { 205 | display: flex; 206 | justify-content: flex-end; 207 | gap: 0.75rem; 208 | } 209 | -------------------------------------------------------------------------------- /src/mock-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "employees": [ 3 | { 4 | "id": "89bd9324-04e0-4cd6-aa27-981508bd219f", 5 | "firstName": "James", 6 | "lastName": "Smith" 7 | }, 8 | { 9 | "id": "7eeba422-5717-4026-8e74-e517576e26bd", 10 | "firstName": "Mary", 11 | "lastName": "Miller" 12 | }, 13 | { 14 | "id": "6e88529c-6739-4f1b-bea0-02afdd336bb3", 15 | "firstName": "Linda", 16 | "lastName": "Jones" 17 | } 18 | ], 19 | "transactions": [ 20 | { 21 | "id": "77af111b-4177-4774-af57-36df9053e1df", 22 | "amount": 95.22, 23 | "employee": { 24 | "id": "89bd9324-04e0-4cd6-aa27-981508bd219f", 25 | "firstName": "James", 26 | "lastName": "Smith", 27 | "extras": 2 28 | }, 29 | "merchant": "Social Media Ads Inc", 30 | "date": "8/5/2022", 31 | "approved": true 32 | }, 33 | { 34 | "id": "6e6698a6-972e-4d6e-a491-0e738616e75a", 35 | "amount": 613.19, 36 | "employee": { 37 | "id": "7eeba422-5717-4026-8e74-e517576e26bd", 38 | "firstName": "Mary", 39 | "lastName": "Miller", 40 | "extras": 1 41 | }, 42 | "merchant": "Air New York", 43 | "date": "7/30/2022", 44 | "approved": false 45 | }, 46 | { 47 | "id": "af9bb7ed-36c4-4151-af07-7c319ded1363", 48 | "amount": 450.01, 49 | "employee": { 50 | "id": "6e88529c-6739-4f1b-bea0-02afdd336bb3", 51 | "firstName": "Linda", 52 | "lastName": "Jones", 53 | "extras": 3 54 | }, 55 | "merchant": "Go Go Taxi", 56 | "date": "7/30/2022", 57 | "approved": false 58 | }, 59 | { 60 | "id": "853c7ee7-3c1f-4b12-8354-dc462367f8ba", 61 | "amount": 37.36, 62 | "employee": { 63 | "id": "89bd9324-04e0-4cd6-aa27-981508bd219f", 64 | "firstName": "James", 65 | "lastName": "Smith", 66 | "extras": 2 67 | }, 68 | "merchant": "Stardusk Coffee", 69 | "date": "8/1/2022", 70 | "approved": true 71 | }, 72 | { 73 | "id": "05c21eca-4995-4305-871b-c4ec705579c3", 74 | "amount": 496.28, 75 | "employee": { 76 | "id": "7eeba422-5717-4026-8e74-e517576e26bd", 77 | "firstName": "Mary", 78 | "lastName": "Miller", 79 | "extras": 1 80 | }, 81 | "merchant": "Halton Hotel", 82 | "date": "8/6/2022", 83 | "approved": true 84 | }, 85 | { 86 | "id": "459965f2-fa11-41e2-a14d-9e572333e52d", 87 | "amount": 666.1, 88 | "employee": { 89 | "id": "6e88529c-6739-4f1b-bea0-02afdd336bb3", 90 | "firstName": "Linda", 91 | "lastName": "Jones", 92 | "extras": 3 93 | }, 94 | "merchant": "Gas-n-go", 95 | "date": "8/1/2022", 96 | "approved": false 97 | }, 98 | { 99 | "id": "da5a47d6-5c63-465e-9d44-65a611257ba4", 100 | "amount": 294.45, 101 | "employee": { 102 | "id": "89bd9324-04e0-4cd6-aa27-981508bd219f", 103 | "firstName": "James", 104 | "lastName": "Smith", 105 | "extras": 2 106 | }, 107 | "merchant": "T-Phone", 108 | "date": "8/1/2022", 109 | "approved": false 110 | }, 111 | { 112 | "id": "59dd50c6-1e6c-4a33-b9ab-73f138d3313f", 113 | "amount": 229.15, 114 | "employee": { 115 | "id": "7eeba422-5717-4026-8e74-e517576e26bd", 116 | "firstName": "Mary", 117 | "lastName": "Miller", 118 | "extras": 1 119 | }, 120 | "merchant": "Parking On Site", 121 | "date": "7/30/2022", 122 | "approved": false 123 | }, 124 | { 125 | "id": "990277f7-12ba-4bf5-af0e-a8e16d6886f3", 126 | "amount": 249.19, 127 | "employee": { 128 | "id": "6e88529c-6739-4f1b-bea0-02afdd336bb3", 129 | "firstName": "Linda", 130 | "lastName": "Jones", 131 | "extras": 3 132 | }, 133 | "merchant": "Taxi Inc", 134 | "date": "8/4/2022", 135 | "approved": true 136 | }, 137 | { 138 | "id": "4f941d9c-37cc-4393-ba10-c7e0d405a724", 139 | "amount": 276.22, 140 | "employee": { 141 | "id": "89bd9324-04e0-4cd6-aa27-981508bd219f", 142 | "firstName": "James", 143 | "lastName": "Smith", 144 | "extras": 2 145 | }, 146 | "merchant": "Raltz Hotel", 147 | "date": "8/5/2022", 148 | "approved": true 149 | }, 150 | { 151 | "id": "33c9eb97-7643-4143-a161-cea569f4b613", 152 | "amount": 202.86, 153 | "employee": { 154 | "id": "7eeba422-5717-4026-8e74-e517576e26bd", 155 | "firstName": "Mary", 156 | "lastName": "Miller", 157 | "extras": 1 158 | }, 159 | "merchant": "Gas On The Road", 160 | "date": "8/1/2022", 161 | "approved": true 162 | }, 163 | { 164 | "id": "82bfd411-fc03-4f74-90cf-b273b0867c7b", 165 | "amount": 295.9, 166 | "employee": { 167 | "id": "6e88529c-6739-4f1b-bea0-02afdd336bb3", 168 | "firstName": "Linda", 169 | "lastName": "Jones", 170 | "extras": 3 171 | }, 172 | "merchant": "Food Hall", 173 | "date": "7/29/2022", 174 | "approved": true 175 | }, 176 | { 177 | "id": "c8296067-2131-4a2c-93a5-826a6d8942c2", 178 | "amount": 492.91, 179 | "employee": { 180 | "id": "89bd9324-04e0-4cd6-aa27-981508bd219f", 181 | "firstName": "James", 182 | "lastName": "Smith", 183 | "extras": 2 184 | }, 185 | "merchant": "Pizza Place Restaurant", 186 | "date": "7/29/2022", 187 | "approved": true 188 | }, 189 | { 190 | "id": "870c552f-5833-4060-9233-eb98f514a917", 191 | "amount": 84.82, 192 | "employee": { 193 | "id": "6e88529c-6739-4f1b-bea0-02afdd336bb3", 194 | "firstName": "Linda", 195 | "lastName": "Jones", 196 | "extras": 3 197 | }, 198 | "merchant": "Tasty Restaurant", 199 | "date": "8/3/2022", 200 | "approved": false 201 | } 202 | ] 203 | } 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Instructions 2 | 3 | Welcome to Ramp's frontend interview challenge. 4 | 5 | In this challenge, you will need to fix certain bugs within the starter code provided to you. 6 | 7 | The bugs **do not depend on each other**, so you can solve them independently. 8 | 9 | You will submit a CodeSandbox link with your response. 10 | 11 | ### Prerequisites 12 | 13 | - `node` 14 | - `npm` or `yarn` 15 | - [CodeSandbox](https://codesandbox.io) 16 | 17 | ### Coding 18 | 19 | Since you need to submit a CodeSandbox link with your response (_See [Submission](#submission)_), we recommend that you create the CodeSandbox first, solve the bugs in your generated CodeSandbox, and then share the link with us. _You can also work locally first, and upload at the end._ 20 | 21 | #### Upload the project to CodeSandbox 22 | 23 | **NOTE: We recommend you use this method to upload your project (with the CLI) rather than importing directly from Github to generate a CodeSandbox.** 24 | 25 | - Run `yarn install` or `npm install` 26 | - Run `yarn upload` or `npm run upload` 27 | - If this is the first time using CodeSandbox CLI, it will ask you to log in with Github first 28 | - You might be prompted: **We will upload XXX static files to your CodeSandbox upload storage** and then a list of files (typically `DS_Store` or `desktop.ini` files). It's fine if you upload with these, or you can manually remove them before uploading. 29 | - Confirm that you want to proceed with deployment 30 | - Once it finishes, you will get the link for your CodeSandbox. Also, you can log in to the website with your Github account and see your projects to retrieve the link. 31 | - Start working directly on the CodeSandbox 32 | 33 | _Reference: https://codesandbox.io/docs/learn/sandboxes/cli-api_ 34 | 35 | Or 36 | 37 | #### Run the server locally 38 | 39 | - Run `yarn install` or `npm install` 40 | - Run `yarn start` 41 | - The server will be available in `http://localhost:3000` 42 | 43 | If you work locally to solve the challenge, make sure you still follow the above steps to upload the project to CodeSandbox. 44 | 45 | ### Special considerations 46 | 47 | #### Typescript 48 | 49 | At Ramp, we use React + Typescript in our codebase. 50 | 51 | You are not required to know Typescript and using it in this challenge is optional. We have abstracted most of the Typescript code into its own files (_types.ts_), so feel free to ignore those. All of the bugs can be solved without Typescript. 52 | 53 | If you work on the CodeSandbox, you can ignore any warnings on the code as long it works in the browser. However, feel free to write any Typescript code if your feel comfortable. 54 | 55 | If you work locally, `TSC_COMPILE_ON_ERROR` flag is set to `true` by default. However, if you feel comfortable with Typescript, feel free to remove it on `.env` and to write any Typescript code. 56 | 57 | #### API 58 | 59 | We don't have a real API for this challenge, so we added some utilities to simulate API requests. To help you debug, we `console.log` the status of the ongoing simulated requests. You will not be able to see these requests in the network tab of your browser. 60 | 61 | #### Solution 62 | 63 | - Solutions can be HTML, CSS or Javascript oriented, depending on the bug and your solution. 64 | - Modify any file inside the `src` folder as long as the expected result is correct. 65 | - The goal is to solve the bug as expected. Finding a clean and efficient solution is a nice to have, but not required. 66 | - Except for the last one, the first bugs don't depend on each other and can be solved in any order. 67 | - We recommend reading all the descriptions first. You might find the solution to one bug while trying to fix another. 68 | - The last bug will need other bugs to be fixed first in order to be reproduced. 69 | - You cannot add any external dependency to the project. The bugs can be solved with vanilla HTML, CSS and Javascript. 70 | 71 | --- 72 | 73 | # Bug 1: Select dropdown doesn't scroll with rest of the page 74 | 75 | **How to reproduce:** 76 | 77 | 1. Make your viewport smaller in height. Small enough to have a scroll bar 78 | 2. Click on the **Filter by employee** select to open the options dropdown 79 | 3. Scroll down the page 80 | 81 | **Expected:** Options dropdown moves with its parent input as you scroll the page 82 | 83 | **Actual:** Options dropdown stays in the same position as you scroll the page, losing the reference to the select input 84 | 85 | # Bug 2: Approve checkbox not working 86 | 87 | **How to reproduce:** 88 | 89 | 1. Click on the checkbox on the right of any transaction 90 | 91 | **Expected:** Clicking the checkbox toggles its value 92 | 93 | **Actual:** Nothing happens 94 | 95 | # Bug 3: Cannot select _All Employees_ after selecting an employee 96 | 97 | **How to reproduce:** 98 | 99 | 1. Click on the **Filter by employee** select to open the options dropdown 100 | 2. Select an employee from the list 101 | 3. Click on the **Filter by employee** select to open the options dropdown 102 | 4. Select **All Employees** option 103 | 104 | **Expected:** All transactions are loaded 105 | 106 | **Actual:** The page crashes 107 | 108 | # Bug 4: Clicking on View More button not showing correct data 109 | 110 | **How to reproduce:** 111 | 112 | 1. Click on the **View more** button 113 | 2. Wait until the new data loads 114 | 115 | **Expected:** Initial transactions plus new transactions are shown on the page 116 | 117 | **Actual:** New transactions replace initial transactions, losing initial transactions 118 | 119 | # Bug 5: Employees filter not available during loading more data 120 | 121 | _This bug has 2 wrong behaviors that will be fixed with the same solution_ 122 | 123 | ##### Part 1 124 | 125 | **How to reproduce:** 126 | 127 | 1. Open devtools to watch the simulated network requests in the console 128 | 2. Refresh the page 129 | 3. Quickly click on the **Filter by employee** select to open the options dropdown 130 | 131 | **Expected:** The filter should stop showing "Loading employees.." as soon as the request for `employees` is succeeded 132 | 133 | **Actual:** The filter stops showing "Loading employees.." until `paginatedTransactions` is succeeded 134 | 135 | ##### Part 2 136 | 137 | **How to reproduce:** 138 | 139 | 1. Open devtools to watch the simulated network requests in the console 140 | 2. Click on **View more** button 141 | 3. Quickly click on the **Filter by employee** select to open the options dropdown 142 | 143 | **Expected:** The employees filter should not show "Loading employees..." after clicking **View more**, as employees are already loaded 144 | 145 | **Actual:** The employees filter shows "Loading employees..." after clicking **View more** until new transactions are loaded. 146 | 147 | # Bug 6: View more button not working as expected 148 | 149 | _This bug has 2 wrong behaviors that can be fixed with the same solution. It's acceptable to fix with separate solutions as well._ 150 | 151 | ##### Part 1 152 | 153 | **How to reproduce:** 154 | 155 | 1. Click on the **Filter by employee** select to open the options dropdown 156 | 2. Select an employee from the list 157 | 3. Wait until transactions load 158 | 159 | **Expected:** The **View more** button is not be visible when transactions are filtered by user, because that is not a paginated request. 160 | 161 | **Actual:** The **View more** button is visible even when transactions are filtered by employee. _You can even click **View more** button and get an unexpected result_ 162 | 163 | ##### Part 2 164 | 165 | **How to reproduce:** 166 | 167 | 1. Click on **View more** button 168 | 2. Wait until it loads more data 169 | 3. Repeat these steps as many times as you can 170 | 171 | **Expected:** When you reach the end of the data, the **View More** button disappears and you are not able to request more data. 172 | 173 | **Actual:** When you reach the end of the data, the **View More** button is still showing and you are still able to click the button. If you click it, the page crashes. 174 | 175 | # Bug 7: Approving a transaction won't persist the new value 176 | 177 | _You need to fix some of the previous bugs in order to reproduce_ 178 | 179 | **How to reproduce:** 180 | 181 | 1. Click on the **Filter by employee** select to open the options dropdown 182 | 2. Select an employee from the list _(E.g. James Smith)_ 183 | 3. Toggle the first transaction _(E.g. Uncheck Social Media Ads Inc)_ 184 | 4. Click on the **Filter by employee** select to open the options dropdown 185 | 5. Select **All Employees** option 186 | 6. Verify values 187 | 7. Click on the **Filter by employee** select to open the options dropdown 188 | 8. Verify values 189 | 190 | **Expected:** In steps 6 and 8, toggled transaction kept the same value it was given in step 2 _(E.g. Social Media Ads Inc is unchecked)_ 191 | 192 | **Actual:** In steps 6 and 8, toggled transaction lost the value given in step 2. _(E.g. Social Media Ads Inc is checked again)_ 193 | 194 | ## Submission 195 | 196 | **IMPORTANT:** Before sharing your CodeSandbox, open the `email.txt` file and replace your email on the only line of the file. Don't use any prefix or suffix, just your email. 197 | 198 | You will submit a link to a CodeSandbox with your responses. Make sure your CodeSandbox has the shape of this Regex: `/^https:\/\/codesandbox\.io\/p\/sandbox\/[a-z\d]{6}$/`. _See [Coding](#coding)_ 199 | 200 | --- 201 | 202 | ### Callouts 203 | 204 | - Don't remove existing `data-testid` tags. Otherwise, your results will be invalidated. 205 | - Other than the bugs, don't modify anything that will have a different outcome. Otherwise, your results might be invalidated. 206 | - Plagiarism is a serious offense and will result in disqualification from further consideration. 207 | --------------------------------------------------------------------------------