diff --git a/.env b/.env
new file mode 100644
index 0000000..22fc1c6
--- /dev/null
+++ b/.env
@@ -0,0 +1 @@
+REACT_APP_API_ENDPOINT="http://localhost:8000"
diff --git a/README.md b/README.md
index 6bbece7..d866719 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,15 @@ Generated using [create-react-app](https://github.com/facebookincubator/create-r
Now you can visit `localhost:3000` from your browser.
+## Environment Variables
+
+The environment variables are embedded during the build time. For more information, please refer to the [docs](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#adding-custom-environment-variables.
+
+| Environment Variable | Default Value | Description |
+| ------------------------- |:-------------------------:| -------------------:|
+| `REACT_APP_API_ENDPOINT` | `"http://localhost:8000"` | Server API endpoint |
+| `REACT_APP_REDUX_LOGGING` | `` | Set for Redux Log |
+
## Testing
To test the react app, call `yarn test`.
diff --git a/package.json b/package.json
index 1bfc12c..ed0c9c2 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"dependencies": {
+ "axios": "^0.16.2",
+ "localStorage": "^1.0.3",
"react": "^15.6.1",
"react-dom": "^15.6.1",
"react-redux": "^5.0.6",
diff --git a/src/actions/auth/reducer.actions.js b/src/actions/auth/reducer.actions.js
new file mode 100644
index 0000000..63d8e79
--- /dev/null
+++ b/src/actions/auth/reducer.actions.js
@@ -0,0 +1,91 @@
+import {
+ IS_SENDING_AUTH_REQUEST,
+ SET_AUTH_REQUEST_ERROR,
+ CLEAR_AUTH_REQUEST_ERROR,
+ SET_AUTH_REQUEST_SUCCESS,
+ CLEAR_AUTH_REQUEST_SUCCESS,
+ SET_SELF_USER,
+ SET_FORM_EMAIL,
+ SET_FORM_PASSWORD,
+ SET_FORM_PASSWORD_CONFIRMATION
+} from "../../constants/auth.constants";
+import { parseError } from "../common.actions";
+
+export function isSendingAuthRequest(sendingRequest) {
+ return {
+ type: IS_SENDING_AUTH_REQUEST,
+ data: sendingRequest
+ };
+}
+
+export function setAuthRequestError(exception) {
+ let rawError = parseError(exception);
+ if (rawError.email) {
+ rawError["Email"] = rawError.email;
+ delete rawError["email"];
+ }
+ if (rawError.password1) {
+ rawError["Password"] = rawError.password1;
+ delete rawError["password1"];
+ }
+ if (rawError.password2) {
+ rawError["Password Confirmation"] = rawError.password2;
+ delete rawError["password2"];
+ }
+ if (rawError.non_field_errors) {
+ rawError["Non Field Errors"] = rawError.non_field_errors;
+ delete rawError["non_field_errors"];
+ }
+
+ return {
+ type: SET_AUTH_REQUEST_ERROR,
+ data: parseError(exception)
+ };
+}
+
+export function clearAuthRequestError() {
+ return {
+ type: CLEAR_AUTH_REQUEST_ERROR
+ };
+}
+
+export function setAuthRequestSuccess(response) {
+ return {
+ type: SET_AUTH_REQUEST_SUCCESS,
+ data: response.detail || response
+ }
+}
+
+export function clearAuthRequestSuccess() {
+ return {
+ type: CLEAR_AUTH_REQUEST_SUCCESS
+ }
+}
+
+export function setSelfUser(selfUser) {
+ return {
+ type: SET_SELF_USER,
+ data: selfUser
+ };
+}
+
+export function setFormEmail(email) {
+ return {
+ type: SET_FORM_EMAIL,
+ data: email
+ };
+}
+
+export function setFormPassword(password) {
+ return {
+ type: SET_FORM_PASSWORD,
+ data: password
+ };
+}
+
+export function setFormPasswordConfirmation(passwordConfirmation) {
+ return {
+ type: SET_FORM_PASSWORD_CONFIRMATION,
+ data: passwordConfirmation
+ };
+}
diff --git a/src/actions/auth/saga.actions.js b/src/actions/auth/saga.actions.js
new file mode 100644
index 0000000..c887c72
--- /dev/null
+++ b/src/actions/auth/saga.actions.js
@@ -0,0 +1,10 @@
+import {
+ SEND_REGISTER_REQUEST
+} from "../../constants/auth.constants";
+
+export function sendRegisterRequest(postbody) {
+ return {
+ type: SEND_REGISTER_REQUEST,
+ data: postbody
+ }
+}
diff --git a/src/actions/common.actions.js b/src/actions/common.actions.js
new file mode 100644
index 0000000..99e9015
--- /dev/null
+++ b/src/actions/common.actions.js
@@ -0,0 +1,13 @@
+/**
+ * Given an exception return the list of errors, the singular error, or generic error
+ * @param {object|string} exception - axios returned exception
+ */
+export function parseError(exception) {
+ let response = exception.response || {};
+ let data = response.data || {};
+ let err = "" + exception;
+ if (response.status) {
+ err = `${response.status} ${response.statusText}`;
+ }
+ return data || err
+}
diff --git a/src/api/auth.api.js b/src/api/auth.api.js
new file mode 100644
index 0000000..ca11449
--- /dev/null
+++ b/src/api/auth.api.js
@@ -0,0 +1,17 @@
+import { post } from "./baseApi";
+
+/**
+ * Function wrapping POST request for user registration
+ * @param {string} email - email of user to register
+ * @param {string} password1 - password of user to register
+ * @param {string} password2 - server side password confirmation
+ */
+export function registerUser(email, password1, password2) {
+ return post("/rest-auth/registration/", {
+ email,
+ password1,
+ password2
+ }).then(response => {
+ return Promise.resolve(response);
+ });
+}
diff --git a/src/api/baseApi.js b/src/api/baseApi.js
new file mode 100644
index 0000000..1fe3634
--- /dev/null
+++ b/src/api/baseApi.js
@@ -0,0 +1,52 @@
+import axios from "axios";
+
+const API_ENDPOINT = process.env.REACT_APP_API_ENDPOINT;
+
+// If testing, use localStorage polyfill, else use browser localStorage
+const localStorage = global.process && process.env.NODE_ENV === "test"
+ ? // eslint-disable-next-line import/no-extraneous-dependencies
+ require("localStorage")
+ : global.window.localStorage;
+
+function headers() {
+ const token = localStorage.getItem("token") || "";
+
+ return {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ Authorization: `Token: ${token}`
+ };
+}
+
+const apiInstance = axios.create({
+ baseURL: API_ENDPOINT,
+ timeout: 3000,
+});
+
+export function get(url, params = {}) {
+ return apiInstance
+ .get(url, {params, headers: headers()})
+ .then(response => response.data)
+ .catch(error => Promise.reject(error));
+}
+
+export function post(url, data) {
+ return apiInstance
+ .post(url, data, {headers: headers()})
+ .then(response => response.data)
+ .catch(error => Promise.reject(error));
+}
+
+export function patch(url, data) {
+ return apiInstance
+ .patch(url, data, {headers: headers()})
+ .then(response => response.data)
+ .catch(error => Promise.reject(error));
+}
+
+export function del(url) {
+ return apiInstance
+ .delete(url, {headers: headers()})
+ .then(response => response.data)
+ .catch(error => Promise.reject(error));
+}
diff --git a/src/components/App.jsx b/src/components/App.jsx
index 91c3a07..b5d96e6 100644
--- a/src/components/App.jsx
+++ b/src/components/App.jsx
@@ -1,23 +1,32 @@
import React, { Component } from "react";
import { Route, Switch } from "react-router-dom";
+import Login from "./Auth/Login";
+import Register from "./Auth/Register";
+import About from "./Static/About";
+import Footer from "./Static/Footer";
+import Home from "./Static/Home";
+import NoMatch from "./Static/NoMatch";
import Navbar from "./Navbar";
-import Footer from "./Footer";
-import Home from "./Home";
-import About from "./About";
-import Topics from "./Topics";
class App extends Component {
render() {
+ const footSmash = {
+ display: "flex",
+ minHeight: "calc(100vh - 1px)",
+ flexDirection: "column"
+ };
return (
-
+
-
+
+
+
diff --git a/src/components/Auth/Login.jsx b/src/components/Auth/Login.jsx
new file mode 100644
index 0000000..ddc9ebd
--- /dev/null
+++ b/src/components/Auth/Login.jsx
@@ -0,0 +1,16 @@
+import React, { Component } from "react";
+import { Container } from "semantic-ui-react";
+
+class Login extends Component {
+ render() {
+ return
;
+ }
+}
+
+const LoginView = () => (
+
+ Login
+
+);
+
+export default Login;
diff --git a/src/components/Auth/Register.jsx b/src/components/Auth/Register.jsx
new file mode 100644
index 0000000..490eac6
--- /dev/null
+++ b/src/components/Auth/Register.jsx
@@ -0,0 +1,135 @@
+import React, { Component } from "react";
+import { connect } from "react-redux";
+import { Container, Form, Header, Message } from "semantic-ui-react";
+
+import {
+ clearAuthRequestError,
+ setFormEmail,
+ setFormPassword,
+ setFormPasswordConfirmation
+} from "../../actions/auth/reducer.actions";
+import { sendRegisterRequest } from "../../actions/auth/saga.actions";
+import Error from "../Shared/Error";
+
+class Register extends Component {
+ constructor(props) {
+ super(props);
+ this.props.dispatch(clearAuthRequestError());
+ }
+
+ changeEmail = event => {
+ this.props.dispatch(setFormEmail(event.target.value));
+ };
+
+ changePassword = event => {
+ this.props.dispatch(setFormPassword(event.target.value));
+ };
+
+ changePasswordConfirmation = event => {
+ this.props.dispatch(setFormPasswordConfirmation(event.target.value));
+ };
+
+ onSubmitRegistration = event => {
+ event.preventDefault();
+ const { dispatch, email, password, passwordConfirmation } = this.props;
+ dispatch(
+ sendRegisterRequest({
+ email,
+ password1: password,
+ password2: passwordConfirmation
+ })
+ );
+ };
+
+ render() {
+ const {
+ isSendingAuthRequest,
+ authRequestError,
+ authRequestSuccess,
+ email,
+ password,
+ passwordConfirmation
+ } = this.props;
+ return (
+
+ );
+ }
+}
+
+function mapStateToProps(state) {
+ return { ...state.auth };
+}
+
+/**
+ * Functional view component for Register logic
+ */
+const RegisterView = ({
+ isSendingAuthRequest,
+ authRequestError,
+ authRequestSuccess,
+ email,
+ password,
+ passwordConfirmation,
+ changeEmail,
+ changePassword,
+ changePasswordConfirmation,
+ onSubmitRegistration
+}) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+);
+
+export default connect(mapStateToProps)(Register);
diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx
index cbd4d6e..dd168e9 100644
--- a/src/components/Navbar.jsx
+++ b/src/components/Navbar.jsx
@@ -12,9 +12,14 @@ class Navbar extends Component {
About
-
- Topics
-
+
+
+ Login
+
+
+ Register
+
+
);
}
diff --git a/src/components/Shared/Error.jsx b/src/components/Shared/Error.jsx
new file mode 100644
index 0000000..febd1bd
--- /dev/null
+++ b/src/components/Shared/Error.jsx
@@ -0,0 +1,41 @@
+import PropTypes from "prop-types";
+import React from "react";
+import { Message } from "semantic-ui-react";
+
+const propTypes = {
+ error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ header: PropTypes.string
+};
+
+const defaultProps = {
+ error: "",
+ header: ""
+};
+
+const Error = ({ error, header }) => {
+ if (typeof error === "string") {
+ const hasError = !!error;
+ if (hasError) {
+ return
;
+ }
+ } else if (typeof error === "object" && Object.keys(error).length > 0) {
+ const hasError = !!Object.keys(error);
+ if (hasError) {
+ return (
+
(
+ {p}: {error[p]}
+ ))}
+ />
+ );
+ }
+ }
+ return null;
+};
+
+Error.propTypes = propTypes;
+Error.defaultProps = defaultProps;
+
+export default Error;
diff --git a/src/components/About.jsx b/src/components/Static/About.jsx
similarity index 100%
rename from src/components/About.jsx
rename to src/components/Static/About.jsx
diff --git a/src/components/Footer.jsx b/src/components/Static/Footer.jsx
similarity index 100%
rename from src/components/Footer.jsx
rename to src/components/Static/Footer.jsx
diff --git a/src/components/Home.jsx b/src/components/Static/Home.jsx
similarity index 100%
rename from src/components/Home.jsx
rename to src/components/Static/Home.jsx
diff --git a/src/components/Static/NoMatch.jsx b/src/components/Static/NoMatch.jsx
new file mode 100644
index 0000000..72723df
--- /dev/null
+++ b/src/components/Static/NoMatch.jsx
@@ -0,0 +1,13 @@
+import React from "react";
+import { Link } from "react-router-dom";
+import { Container } from "semantic-ui-react";
+
+const NoMatch = ({ location }) => (
+
+ Page not found!
+ No match found for {location.pathname}
+ Go to the home page →
+
+);
+
+export default NoMatch;
diff --git a/src/components/Topics.jsx b/src/components/Topics.jsx
deleted file mode 100644
index 5923230..0000000
--- a/src/components/Topics.jsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from "react";
-import { Route, Link } from "react-router-dom";
-import { Container } from "semantic-ui-react";
-
-const Topics = ({ match }) => (
-
- Topics
-
- -
-
- Rendering with React
-
-
- -
-
- Components
-
-
- -
-
- Props v. State
-
-
-
-
-
- Please select a topic.
}
- />
-
-);
-
-const Topic = ({ match }) => (
-
-
{match.params.topicId}
-
-);
-
-export default Topics;
diff --git a/src/constants/auth.constants.js b/src/constants/auth.constants.js
new file mode 100644
index 0000000..22b1946
--- /dev/null
+++ b/src/constants/auth.constants.js
@@ -0,0 +1,13 @@
+// Reducer Auth Action Constants
+export const IS_SENDING_AUTH_REQUEST = "IS_SENDING_AUTH_REQUEST";
+export const SET_AUTH_REQUEST_ERROR = "SET_AUTH_REQUEST_ERROR";
+export const CLEAR_AUTH_REQUEST_ERROR = "CLEAR_AUTH_REQUEST_ERROR";
+export const SET_AUTH_REQUEST_SUCCESS = "SET_AUTH_REQUEST_SUCCESS";
+export const CLEAR_AUTH_REQUEST_SUCCESS = "CLEAR_AUTH_REQUEST_SUCCESS";
+export const SET_SELF_USER = "SET_SELF_USER";
+export const SET_FORM_EMAIL = "SET_FORM_EMAIL";
+export const SET_FORM_PASSWORD = "SET_FORM_PASSWORD";
+export const SET_FORM_PASSWORD_CONFIRMATION = "SET_FORM_PASSWORD_CONFIRMATION";
+
+// Saga Auth Action Constants
+export const SEND_REGISTER_REQUEST = "SEND_REGISTER_REQUEST";
diff --git a/src/index.js b/src/index.js
index aded6b1..68e4aa0 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,26 +1,15 @@
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
-import { createStore, applyMiddleware } from "redux";
-import createSagaMiddleware from "redux-saga";
import { Provider } from "react-redux";
-import { createLogger } from "redux-logger";
-import rootReducer from "./reducers";
-import rootSaga from "./sagas";
+import configureStore from "./store";
import App from "./components/App";
import { unregister } from "./registerServiceWorker";
-const sagaMiddleware = createSagaMiddleware();
-const debugLogger = createLogger();
-const store = createStore(
- rootReducer,
- applyMiddleware(debugLogger, sagaMiddleware)
-);
+const store = configureStore();
const supportsHistory = "pushState" in window.history;
-sagaMiddleware.run(rootSaga);
-
const rootElement = document.getElementById("root");
ReactDOM.render(
diff --git a/src/reducers/authReducer.js b/src/reducers/authReducer.js
index b8866cd..21325f2 100644
--- a/src/reducers/authReducer.js
+++ b/src/reducers/authReducer.js
@@ -1,7 +1,72 @@
-const initialState = {};
+import {
+ IS_SENDING_AUTH_REQUEST,
+ SET_AUTH_REQUEST_ERROR,
+ CLEAR_AUTH_REQUEST_ERROR,
+ SET_AUTH_REQUEST_SUCCESS,
+ CLEAR_AUTH_REQUEST_SUCCESS,
+ SET_SELF_USER,
+ SET_FORM_EMAIL,
+ SET_FORM_PASSWORD,
+ SET_FORM_PASSWORD_CONFIRMATION
+} from "../constants/auth.constants";
+
+const initialState = {
+ isSendingAuthRequest: false,
+ authRequestError: "",
+ authRequestSuccess: "",
+ currentUser: {},
+ email: "",
+ password: "",
+ passwordConfirmation: ""
+};
function authReducer(state = initialState, action) {
switch (action.type) {
+ case IS_SENDING_AUTH_REQUEST:
+ return {
+ ...state,
+ isSendingAuthRequest: action.data
+ };
+ case SET_AUTH_REQUEST_ERROR:
+ return {
+ ...state,
+ authRequestError: action.data
+ };
+ case CLEAR_AUTH_REQUEST_ERROR:
+ return {
+ ...state,
+ authRequestError: ""
+ };
+ case SET_AUTH_REQUEST_SUCCESS:
+ return {
+ ...state,
+ authRequestSuccess: action.data
+ };
+ case CLEAR_AUTH_REQUEST_SUCCESS:
+ return {
+ ...state,
+ authRequestSuccess: ""
+ };
+ case SET_SELF_USER:
+ return {
+ ...state,
+ currentUser: action.data
+ };
+ case SET_FORM_EMAIL:
+ return {
+ ...state,
+ email: action.data
+ };
+ case SET_FORM_PASSWORD:
+ return {
+ ...state,
+ password: action.data
+ };
+ case SET_FORM_PASSWORD_CONFIRMATION:
+ return {
+ ...state,
+ passwordConfirmation: action.data
+ };
default:
return state;
}
diff --git a/src/sagas/auth.sagas.js b/src/sagas/auth.sagas.js
new file mode 100644
index 0000000..2ec22fd
--- /dev/null
+++ b/src/sagas/auth.sagas.js
@@ -0,0 +1,39 @@
+import { effects } from "redux-saga";
+import {
+ isSendingAuthRequest,
+ setAuthRequestError,
+ setAuthRequestSuccess,
+ clearAuthRequestError,
+ setFormEmail,
+ setFormPassword,
+ setFormPasswordConfirmation
+} from "../actions/auth/reducer.actions";
+import { registerUser } from "../api/auth.api";
+
+/**
+ * Saga for registering a new user.
+ * @param {*} postBody
+ */
+function* registerUserCall(postBody) {
+ yield effects.put(isSendingAuthRequest(true));
+ const { email, password1, password2 } = postBody;
+ try {
+ return yield effects.call(registerUser, email, password1, password2);
+ } catch (exception) {
+ yield effects.put(setAuthRequestError(exception));
+ return false;
+ } finally {
+ yield effects.put(isSendingAuthRequest(false));
+ }
+}
+
+export function* registerUserFlow(request) {
+ const wasSucessful = yield effects.call(registerUserCall, request.data);
+ if (wasSucessful) {
+ yield effects.put(setAuthRequestSuccess(wasSucessful));
+ yield effects.put(clearAuthRequestError());
+ yield effects.put(setFormEmail(""));
+ yield effects.put(setFormPassword(""));
+ yield effects.put(setFormPasswordConfirmation(""));
+ }
+}
diff --git a/src/sagas/index.js b/src/sagas/index.js
index 6b78098..4a981c6 100644
--- a/src/sagas/index.js
+++ b/src/sagas/index.js
@@ -1,4 +1,7 @@
+import { takeLatest } from "redux-saga/effects";
+import { SEND_REGISTER_REQUEST } from "../constants/auth.constants";
+import { registerUserFlow } from "./auth.sagas";
export default function* rootSaga() {
-
+ yield takeLatest(SEND_REGISTER_REQUEST, registerUserFlow);
}
diff --git a/src/store/index.js b/src/store/index.js
new file mode 100644
index 0000000..677257a
--- /dev/null
+++ b/src/store/index.js
@@ -0,0 +1,22 @@
+import { createStore, applyMiddleware, compose } from "redux";
+import createSagaMiddleware from "redux-saga";
+import { createLogger } from "redux-logger";
+import rootReducer from "../reducers";
+import rootSaga from "../sagas";
+
+export default function configureStore(initialState = {}) {
+ const sagaMiddleware = createSagaMiddleware();
+ const middlewares = [sagaMiddleware];
+ if (process.env.NODE_ENV === "development" && process.env.REACT_APP_REDUX_LOGGING) {
+ middlewares.push(createLogger());
+ }
+
+ const enhancers = [applyMiddleware(...middlewares)];
+ const store = createStore(rootReducer, initialState, compose(...enhancers));
+
+ // Extensions
+ store.asyncReducers = {}; // Async reducer registry
+ sagaMiddleware.run(rootSaga);
+
+ return store;
+}
diff --git a/yarn.lock b/yarn.lock
index cf306d9..5055aa2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -316,6 +316,13 @@ aws4@^1.2.1:
version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
+axios@^0.16.2:
+ version "0.16.2"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-0.16.2.tgz#ba4f92f17167dfbab40983785454b9ac149c3c6d"
+ dependencies:
+ follow-redirects "^1.2.3"
+ is-buffer "^1.1.5"
+
axobject-query@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-0.1.0.tgz#62f59dbc59c9f9242759ca349960e7a2fe3c36c0"
@@ -1757,7 +1764,7 @@ date-now@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
-debug@2.6.8, debug@^2.1.1, debug@^2.2.0, debug@^2.6.0, debug@^2.6.3, debug@^2.6.6, debug@^2.6.8:
+debug@2.6.8, debug@^2.1.1, debug@^2.2.0, debug@^2.4.5, debug@^2.6.0, debug@^2.6.3, debug@^2.6.6, debug@^2.6.8:
version "2.6.8"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc"
dependencies:
@@ -2592,6 +2599,12 @@ flatten@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
+follow-redirects@^1.2.3:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.2.4.tgz#355e8f4d16876b43f577b0d5ce2668b9723214ea"
+ dependencies:
+ debug "^2.4.5"
+
for-in@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@ -3881,6 +3894,10 @@ loader-utils@^1.0.2, loader-utils@^1.1.0:
emojis-list "^2.0.0"
json5 "^0.5.0"
+localStorage@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/localStorage/-/localStorage-1.0.3.tgz#e6b89a57bb760a156a38cc87e0f2550f6ed413d8"
+
locate-path@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"