NOTE: I renamed the original README file as "ORIGINAL_README.md"
The Task
You are required to create a simple auto-search feature similar to this one where-by, as you type, the data is checked against a Node server and the response is then loaded in. You can style this however you wish and can implement it however you wish but you MUST use Javascript/React and Node to accomplish this. This has already been setup for you in the source code provided.
Tech
First off, thank you for allowing me to take this challenge. I enjoyed doing it. I completed this challenge using React, React Hooks, Styled Components, and axios on the client and Express and a few supporting packages on the server.
With respect to the React app, I noticed the template used Class Based Components, but since it's strongly discouraged to continue with these types of components, I used Functional Components and converted the existing templates accordingly.
I didn't delete any existing files/folders. I'm not sure if that was something we should have done. I just added to the project. I didn't convert any of the sass to styled components. I just kept the sass as-is; however I may have updated a couple of existing styles just to tweak a couple styling preferences.
My Focus
I took the "Make it work, Make it better" approach when taking on this challenge. This allowed me to work fast, then refine, and refine again.
I focused more on the functionality of the auto-search and sprinkled in some added functionality to address performance and care for usability and less on the HTML/CSS, but I did use Styled Components to stylize the search results that get fed back from the search. When I fetch the results and load them in, I tried to keep true with how the maccosmetics.com
handled the search results, which involved showing the first 4 or 5 products (of many), but then showing the paging info (i.e. Showing 5 of 11 Results).
Areas of Lesser Focus
- I didn't create the functionality to link to a particular product or show a list of all the projects. The process for that would be fairly similar which would involve adding some additional routes to view the information. That could be a next logical step in a scenario like this.
- I also didn't stylize the search functionality for mobile, make it responsive, or add any accessibility, which are other areas that could improve this.
- I didn't send a token from the server to the client and accept a token to be validated again, which would address security concerns, such as CSRF.
- I didn't write tests for any of the app, but I did write a few tests for the server. I realize that in a real-world setting, we would want to do this, but we couldn't go all-out here. You'll see some output from these tests below. It would also be cool to perform some end-to-end tests using Cypress.io
The Walk-Through
In the sections below, I'll walk you through some of my thought processes during this challenge. I've commented my code in certain app files for the sake of this challenge (Menu.jsx
, useAutoSearch.js
, useFetch.js
, storage.js
) since those are the heavy hitters. I typically strive to write and format my code that is easy to follow, small enough to test, with objects, components, and variables named well enough to understand enabling it to be self-documenting, as comments often times go stale. If EL likes to write comments in code, I can get on board with that.
Requirements
App
- Change Search Input to appear more closely to Estee Lauder home page
- Clicking search button opens search input and sets focus
- Center the search terms in the input
- Minimum of 3 characters before calling the API
- Once we 3 chars are entered, we allow calls to the API. Any subsequent character entered will cancel the previous request
- Allow for multiple search terms to be entered (only letters can be entered) - (For testing purposes, I've added ABC, DEF, GHI, JKL as part of product descriptions. ABC is on a single tag)
- Each word is a separate search that is an "OR". Brings back all products with those words
- Tags in product cards can be clicked that will set the search input with that search term and re-query
- If no results, then showing a no results message
- When results are found, we cache the results for 20 minutes. When the same search term is used and it's found in cache, then we pull data from cache instead of server. The caching is configurable and optional through settings.
- When we get data and cache, we then check for expired data and clear from cache
- Create custom hooks for data fetching and auto searching
- Use React Context API to allow for child components to have access to hook without prop drilling
- Use a reducer instead of using useState to simplify state management and reduce re-renders
- Add error handling to the search should there be a server error.
Server
- Rate limit at 1000 requests per 15 minutes
- Version the API
- Simulate pulling from database and provide a more realistic architecture
- Impose a slight delay (1 sec) to simulate a database call
- Write some tests to validate the requests
To get the project started, you will need to install the project dependencies, then start the app. To do this, just type npm install && npm start
.
I added a couple new scripts to the package.json
file
npm start
- Basically, just calls npm run servers
, but a little more implanted in my brain to do this.
npm test
- Runs the tests I created for a few server functions
I started this challenge creating the server using Express.
I pulled in a cors package to enable it since we're running on two different domains (3030 and 3035) and providing the ability to white list the domains we wish to have access.
I also added a rate limit package, which is just generally good practice as we could easily have some bot spam the server otherwise. I allowed for add a rate limit (1000 hits every 15 minutes, which is configurable). With the caching that I enabled on the client, the user may very likely get their data without hitting the server, but more on that later.
We set up the routes and a default connection route that client can hit to see if they're connected (see Routes below)
└─ server
├─ app.js <== ENTRY POINT
├─ constants.js
├─ cors.js
├─ data.js <== DATABASE
├─ rateLimits.js
├─ routes.js
├─ utils.js
├─ v1
│ ├─ routes.js
│ └─ search
│ ├─ search.controller.js
│ ├─ search.db.js
│ ├─ search.service.helpers.js
│ ├─ search.service.js
│ └─ __tests__
│ └─ search.helpers.test.js
└─ __tests__
└─ utils.test.js
Keeping it simple
I kept the project structure pretty simple for the server since there wasn't much too it. I wanted to ensure that we separated where it made sense (utility functions in utils.js, constants in the constants.js, etc.). Generally speaking, if we group files into folders and it makes sense to add an Index file that we then export from, it allows us to have a cleaner way of importing modules. I did not do this here for the sake of time. I just ./../my/way/tofile
which should be fine for small demo purposes.
All the server configuration packages (cors, routes, rate limit) are self-contained in their own files for separation of concerns. This also allows us to easily swap in/out and configure it a bit easier.
Versioning the API
I also took the liberty of versioning (v1) the API in this case, which allows us to create new APIs without breaking previous ones if we were to expand on this. That was not a requirement of this, but it's nice to set ourselves up for success in this area if we wish.
In the v1 folder, I set up all my functionality within the search folder as I felt it made sense to keep the functionality together as a unit of work. I know this is an opinionated topic, how to structure projects, and I'm fine with whatever the team decides, including how to name files, but in this case, I kept things simple and together.
The Architecture
As for the search functionality, I structured it this way:
- Our routes forward requests to our controllers
- The controllers handle the request and make a call to the service
- The services make database calls and handle the business logic once it receives the data
- The data layer fetches from a database or external API (I faked this and just return a Promise with the data from the data.js file)
Tests
Lastly, I did create a few tests to ensure the accuracy of our search functionality. The tests are limited due to the short time duration of the challenge.
PASS server/__tests__/utils.test.js
utils - uniqueList
√ should return a unique number array when passing duplicate elements (2 ms)
√ should return a unique string array when passing duplicate elements
√ should return an empty array when supplying an empty array
√ should return an empty array when supplying a null value (1 ms)
PASS server/v1/search/__tests__/search.helpers.test.js
v1 Search Helpers - arraySearchFilter
√ should find term in tags since term and tag match (2 ms)
√ should find term in tags since multiple terms and tags match
√ should not find term in tags since it is part of tag
√ should not find term in tags since it is has more letters than tag
√ should not find term in tags since it does not exist
√ should not find term in tags since the term is an empty string (1 ms)
√ should not find term in tags since a term was not supplied
v1 Search Helpers - textualSearchFilter
√ should find the term in the string when same case
√ should find the term in the string when different case
√ should find the terms in the string when there are multiple words (1 ms)
v1 Search Helpers - getFilteredData
√ should find a single result when only one product has the term being searched in the name
√ should find two results when we search for a term in the name of one product and the tag of another product (1 ms)
√ should return all three items when searching by product name and tag for each of the items
√ should return all three items when searching by product name and part of tag for one of the items and a full tag on another
√ should not return any results when a term is not supplied (1 ms)
√ should not return any results when the combined list of terms does not meet the min char requirement
Test Suites: 2 passed, 2 total
Tests: 20 passed, 20 total
Snapshots: 0 total
Time: 1.13 s
Ran all test suites.
App
const { initCors } = require('./cors');
const { rateLimit } = require('./rateLimits');
const { initRoutes } = require('./routes');
const { HOSTNAME, PORT, CLIENT_URL } = require('./constants');
const express = require('express');
const app = express();
const { json, urlencoded } = express;
app.use(json());
app.use(urlencoded({ extended: true }));
initCors({ app, whitelist: [CLIENT_URL] });
rateLimit({ app });
initRoutes({ app });
app.listen(PORT, () => console.log(`CORS-enabled server running on ${HOSTNAME}:${PORT}.`));
Routes
const express = require('express');
const router = express.Router();
function initRoutes({ app }) {
app.get('/connection', (req, res) =>
res.send({
status: `Connected to ELC Server!`,
appVersion: process.env.npm_package_version,
description: `Rest API Endpoints for ELC`,
}),
);
router.use(`/v1`, require(`./v1/routes`));
app.use('/api', router);
}
module.exports = { initRoutes };
Cors
const cors = require('cors');
function init({ app, whitelist = [] }) {
const options = (req, callback) => {
const containsWhiteListUrl = whitelist.indexOf(req.header('Origin')) > -1;
const corsOptions = { origin: containsWhiteListUrl ? true : false };
callback(null, corsOptions);
};
app.use(cors(options));
}
module.exports = { initCors: init };
Rate Limiting
const rateLimit = require('express-rate-limit');
const FIFTEEN_MINUTES = 15 * 60 * 1000;
function init({ app, timeLimitMs = FIFTEEN_MINUTES, hits = 1000 }) {
const limiter = rateLimit({
windowMs: timeLimitMs,
max: hits,
standardHeaders: true,
legacyHeaders: false,
});
// Apply the rate limiting middleware to all requests
app.use(limiter);
}
module.exports = { rateLimit: init };
/connection
- Checks if we have a connection. Just a simple GET request.
Sample Response
{
"status": "Connected to ELC Server!",
"appVersion": "0.1.0",
"description": "Rest API Endpoints for ELC"
}
/api/v1/autoSearch
- POST request that enables the client app to fetch product data based on the json supplied as the body of the request. In this case
const express = require('express');
const router = express.Router();
const { getAutoSearchResponse } = require('./search/search.controller');
router.post('/autoSearch', getAutoSearchResponse);
module.exports = router;
Sample Request
// search for products where the name, about, or tag field includes the value. From the app, this these are separated by a space.
{
"terms": ["oil", "conditioner"]
}
// also valid, but the empty strings will be ignored (I split on each word)
{
"terms": ["oil", "", "conditioner"]
}
Another Sample Request/Response
// I added a few easy strings in the data to more easily see this in action from the app. I appended "abc", "def", "ghi", and "jkl" to the about field and one "abc" as a tag.
// For this response, this is the request
{
"terms": ["abc", "def"]
}
// In this response, I return all products where ABC or DEF are found (case insensitive)
{
"data": [
{
"_id": "006",
"isActive": "true",
"price": "38.00",
"picture": "/img/products/N0J801_430.png",
"name": "Damage Reverse Hair Serum",
"about": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sit amet porttitor eget dolor.",
"tags": [
"ojon",
"serum",
"abc" // <== HERE
]
},
{
"_id": "001",
"isActive": "true",
"price": "20.00",
"picture": "/img/products/N0CA_430.png",
"name": "Damage Reverse Oil Conditioner",
"about": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ABC", <== HERE
"tags": [
"ojon",
"oil",
"conditioner"
]
},
{
"_id": "002",
"isActive": "true",
"price": "22.00",
"picture": "/img/products/N0EN01_430.png",
"name": "Volume Advance Conditioner",
"about": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. DEF", <== HERE
"tags": [
"ojon",
"conditioner"
]
}
],
"status": 200,
"statusText": "OK"
}
async function getAutoSearchResponse(req, res, next) {
try {
const terms = req.body?.terms;
const data = await getAutoSearchResults(terms);
const response = formatResponse({ data });
return writeJsonResponse(res, response);
} catch (error) {
const response = formatErrorResponse(error);
return writeJsonResponse(res, response);
}
}
async function getAutoSearchResults(terms = []) {
try {
const notEnoughSearchChars =
terms.length === 0 || terms.join('').length < MIN_SEARCH_CHAR_COUNT;
if (notEnoughSearchChars) return [];
const data = await getDbData(ACTIVE_PRODUCTS_ONLY);
return helpers.getFilteredData(data, terms);
} catch (e) {
console.error(`ERROR: getAutoSearchResults > ${e}`);
}
}
// fake db call - only get active products
async function getDbData(activeOnly = true) {
return new Promise((resolve, reject) => {
try {
// simulate a slight delay as if going to some external source;
// currently 1 second
setTimeout(() => {
activeOnly ? resolve(data.filter(({ isActive }) => isActive === 'true')) : resolve(data);
}, FAKE_DELAY_IN_MS);
} catch (e) {
reject('ERROR: An unexpected error occurred while fetching data from the database.');
}
});
}
├─ app
│ ├─ scripts
│ │ ├─ common
│ │ │ ├─ constants.js
│ │ │ ├─ storage.js // data caching
│ │ │ └─ utils.js
│ │ ├─ components
│ │ │ ├─ Home.jsx
│ │ │ ├─ index.js
│ │ │ ├─ Input
│ │ │ │ └─ Input.jsx
│ │ │ ├─ Link
│ │ │ │ └─ Link.jsx
│ │ │ ├─ LoadingIndicator
│ │ │ │ └─ LoadingIndicator.jsx
│ │ │ └─ Menu
│ │ │ ├─ data.js <== Navbar text elements
│ │ │ ├─ ErrorMessage.jsx
│ │ │ ├─ Menu.jsx <== Menu Entry Point
│ │ │ ├─ MenuContainer.jsx
│ │ │ ├─ NoResultsMessage.jsx
│ │ │ ├─ ProductTags.jsx
│ │ │ ├─ SearchInputContainer.jsx
│ │ │ ├─ SearchProductCard.jsx
│ │ │ ├─ SearchProductCard.styles.jsx
│ │ │ ├─ SearchProductCards.jsx
│ │ │ ├─ SearchProductCards.styles.jsx
│ │ │ ├─ SearchStateContext.js
│ │ │ ├─ TotalResults.jsx
│ │ │ ├─ TotalResults.styles.jsx
│ │ │ └─ useAutoSearch.js // autoSearch state/functionality (Menu)
│ │ ├─ core-ui // simple core styled components
│ │ │ ├─ button.js
│ │ │ └─ divider.js
│ │ │ └─ index.js
│ │ │ └─ text.js
│ │ ├─ hooks
│ │ │ └─ useFetch.js // called by useAutoSearch
│ │ ├─ Main.jsx
│ │ └─ services
│ │ └─ http.js // axios and http constants
As with the server project structure, I kept the app's project structure simple, as well. Generally speaking, I like having the following top level project folders, then I get more specific from there (assets
, components
, constants
, core-ui
, helpers
, hooks
, views
, services
, stores
, validations
), but fine with whatever the team decides. Like the server, I didn't configure a named path, so I also, just ./../my/way/tofile
for the app, as well.
I over-commented in the components for the sake of the challenge.
This is the main entry point into the auto-search functionality
/**
* If we know that the menu is not going to change and is the
* only Menu component (maybe we would
* want to create a MobileMenu component) then we could just create
* the props internally here and not have to deal with any props.
*
* For the purpose of this challenge, I was passing in the props with
* the idea that if we wanted to create a separate Mobile menu then
* we could lift the props out and share across both, yet handle the
* UI a little differently.
*/
import React from 'react';
import PropTypes from 'prop-types';
// We use react context to allow for tags in the products to be clickable,
// update the search input, then reload the data; thus re-rendering the results.
import SearchStateContext from './SearchStateContext';
import { MAX_AUTO_SEARCH_PREVIEW_ITEMS } from './../../common/constants';
// passing in the menu data vs hard-coding it (could be db driven)
// and also cleans up the ui
import { menuItems } from './data';
import MenuContainer from './MenuContainer';
import SearchInputContainerWithRef from './SearchInputContainer';
import SearchProductCards from './SearchProductCards';
// custom hook for auto-search
import useAutoSearch from './useAutoSearch';
const Menu = ({ minSearchChars, searchEndpoint, cacheSettings, searchCharsToExcludeRegEx }) => {
// We add state and functionality to a custom search hook,
// which allows us to reuse in other search components
const {
isIdle,
isLoading,
isResolved,
onSearch,
hasError,
results,
searchInputRef,
searchValue,
showingSearch,
showNoResultsMessage,
showSearchContainer,
totalResultsCount,
} = useAutoSearch({ minSearchChars, searchEndpoint, cacheSettings, searchCharsToExcludeRegEx });
// We need to know when to show the drop-down below the search input.
// Since I include an error message, no results message, and the
// results themselves, there are a few conditions for when to show.
const openProductSearch = (isResolved || isLoading || showNoResultsMessage) && !isIdle;
return (
// We use react context provider here so that we can get access to
// the change event handler(onSearch) from a nested child component.
// The context provider re-renders the wrapped components when we call
// onSearch, but it should be okay here since we are doing a search
// that updates the results everytime.
<SearchStateContext.Provider value={{ onSearch }}>
<header className="menu">
{/* Top Menu and Search Icon. Passing in the menu text to
clean things up a bit */}
<MenuContainer logo="ELC" items={menuItems} onShowSearchContainer={showSearchContainer} />
{/* We have create a forward ref here so that we can pass a
ref to it and set focus to the input contained in this component */}
<SearchInputContainerWithRef
ref={searchInputRef}
showingSearch={showingSearch}
onSearch={(e) => onSearch(e.target.value)}
searchValue={searchValue}
onShowSearchContainer={showSearchContainer}
>
{openProductSearch && (
// We have some search results, or a no results
// message, or an error message to show
<SearchProductCards
data={results}
showCount={MAX_AUTO_SEARCH_PREVIEW_ITEMS}
totalCount={totalResultsCount}
isLoading={isLoading}
hasError={hasError}
showNoResultsMessage={showNoResultsMessage}
/>
)}
</SearchInputContainerWithRef>
</header>
</SearchStateContext.Provider>
);
};
Menu.propTypes = {
minSearchChars: PropTypes.number,
searchEndpoint: PropTypes.string.isRequired,
cacheSettings: PropTypes.object,
searchCharsToExcludeRegEx: PropTypes.object,
};
export default Menu;
Called by Menu to handle the auto search functionality
/**
* Custom hook to handle the auto-search state and functionality for
* the Menu component. This hook calls another custom useFetch hook
* that allows for the caching (with expiration) of searches along
* with their responses.
*
* I'm over commenting here to explain my thought process for the sake
* of the challenge.
*/
import { useEffect, useRef, useReducer } from 'react';
import {
DEBOUNCE_DELAY_IN_MS,
NON_ALPHA_PATTERN,
MIN_SEARCH_CHAR_COUNT,
CACHE_EXPIRATION_MINUTES,
} from './../../common/constants';
import { debounce } from './../../common/utils';
import useFetch from './../../hooks/useFetch';
function reducer(state, action) {
const { searchValue, type } = action;
switch (type) {
case 'open': {
return { ...state, showingSearch: true };
}
case 'close': {
return { ...state, searchValue: '', showingSearch: false };
}
case 'change': {
return { ...state, searchValue };
}
default:
throw new Error(`Unhandled action type: ${type}`);
}
}
function useAutoSearch({
minSearchChars = MIN_SEARCH_CHAR_COUNT,
searchEndpoint,
cacheSettings,
searchCharsToExcludeRegEx = NON_ALPHA_PATTERN,
}) {
// state management using reducer
const [{ showingSearch, searchValue }, dispatch] = useReducer(reducer, {
showingSearch: false,
searchValue: '',
});
// create ref for the search input (we'll handle setting focus
// when clicking on question mark icon) - could be moved into a useFocus hook
const searchInputRef = useRef(null);
// could be moved into a useCache hook that would clean up the merges
const mergedCacheSettings = {
...{
storageType: window.sessionStorage,
enabled: true,
keyPrefix: 'ELC:',
expireMinutes: CACHE_EXPIRATION_MINUTES,
},
...cacheSettings,
};
// let our useFetch hook handle the data part
const {
count: totalResultsCount,
fetchData,
isIdle,
isLoading,
isResolved,
results,
status,
} = useFetch({
url: searchEndpoint,
minSearchChars,
cacheSettings: mergedCacheSettings,
});
// event handler for opening/closing search input
const showSearchContainer = (e) => {
e.preventDefault();
const type = showingSearch ? 'close' : 'open';
dispatch({ type, showingSearch: !showingSearch });
};
// we wrap our fetch data in a debounce function (adds a delay)
// so that the UI isn't janky and locking up when we type search chars
const getData = debounce((body) => {
fetchData(body);
}, DEBOUNCE_DELAY_IN_MS);
// event handler for the search input change event
const onSearch = (value) => {
// only allow letters and spaces in search input
const sanitized = value.replace(searchCharsToExcludeRegEx, '');
if (sanitized.length === 0)
return dispatch({ type: 'change', searchValue: '', showingSearch: true });
// convert search terms into an array of terms and find
// products where terms exist in names (whole words only)
const terms = sanitized.split(' ');
// make the request
getData({ terms });
// trigger change
dispatch({
type: 'change',
searchValue: sanitized,
showingSearch: true,
showNoResultsMessage: false,
});
};
// set focus to input if clicking on question mark icon
useEffect(() => {
if (showingSearch) {
searchInputRef.current.focus();
}
}, [showingSearch]);
return {
dispatch,
fetchData,
isIdle,
isLoading,
isResolved,
onSearch,
hasError: results?.message !== undefined,
results: results?.data || [],
searchInputRef,
searchValue,
showingSearch,
showNoResultsMessage: totalResultsCount === 0 && !isLoading,
showSearchContainer,
status,
totalResultsCount,
};
}
export default useAutoSearch;
Shows the Error Message, No Results Message, or a preview of the search results (first 5)
import React from 'react';
import PropTypes from 'prop-types';
import { LoadingIndicator } from './../../components';
import { Divider } from '../../core-ui';
import SearchProductCard from './SearchProductCard';
import TotalResults from './TotalResults';
import ErrorMessage from './ErrorMessage';
import NoResultsMessage from './NoResultsMessage';
import {
AutoSearchMenuContainer,
AutoSearchMenuItemsContainer,
ResultsWrapper,
} from './SearchProductCards.styles';
const SearchProductCards = ({
data,
showCount,
totalCount,
isLoading,
showNoResultsMessage,
hasError,
}) => {
if (hasError) return <ErrorMessage />;
if (showNoResultsMessage) return <NoResultsMessage />;
return (
<AutoSearchMenuContainer>
<Divider width="75vw" margin="0px 0px 15px 0px" />
<ResultsWrapper>
<TotalResults searchText="SEE ALL RESULTS" showing={showCount} totalResults={totalCount}>
<LoadingIndicator isLoading={isLoading} />
</TotalResults>
</ResultsWrapper>
{totalCount > 0 && (
<AutoSearchMenuItemsContainer>
{data?.slice(0, showCount)?.map(({ _id: id, name, about, picture, tags, price }) => (
<SearchProductCard
key={id}
name={name}
about={about}
picture={picture}
tags={tags}
price={price}
/>
))}
</AutoSearchMenuItemsContainer>
)}
</AutoSearchMenuContainer>
);
};
SearchProductCards.propTypes = {
data: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
showCount: PropTypes.number,
totalCount: PropTypes.number,
hasError: PropTypes.bool,
isLoading: PropTypes.bool,
showNoResultsMessage: PropTypes.bool,
};
SearchProductCards.defaultProps = {
data: [],
showCount: 4,
totalCount: 0,
hasError: false,
isLoading: false,
showNoResultsMessage: false,
};
export default SearchProductCards;