Skip to content

Commit

Permalink
Merge pull request #125 from joberstein/#112
Browse files Browse the repository at this point in the history
#112: Convert project to Typescript
  • Loading branch information
joberstein authored Jan 22, 2023
2 parents eb710e4 + 413132e commit b683e20
Show file tree
Hide file tree
Showing 54 changed files with 1,163 additions and 688 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,5 @@ Thumbs.db
node_modules
build
coverage
dist
.pnp*
3 changes: 2 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ before_install:
- yarn cache clean

script:
- yarn build
- yarn tsc
- yarn test --ci
- yarn validate:commits
- yarn build

deploy:
- provider: script
Expand Down
7 changes: 0 additions & 7 deletions jsconfig.json

This file was deleted.

5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,11 @@
"@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.5",
"classnames": "^2.3.2",
"prop-types": "^15.7.2",
"react": "17.0.0",
"react-app-polyfill": "^3.0.0",
"react-dom": "17.0.0",
"react-ga": "^3.3.1",
"react-google-recaptcha-v3": "^1.10.1",
"react-image-lightbox": "^5.1.0",
"react-router": "^6.7.0",
"react-router-dom": "6.7.0"
},
Expand All @@ -30,7 +28,8 @@
"enzyme-adapter-react-16": "^1.15.7",
"react-scripts": "5.0.1",
"sass": "^1.57.1",
"typescript": "^4.9.4"
"typescript": "^4.9.4",
"typescript-plugin-css-modules": "^4.1.1"
},
"scripts": {
"start": "PORT=8080 react-scripts start",
Expand Down
File renamed without changes.
File renamed without changes.
4 changes: 2 additions & 2 deletions src/Analytics/hook.test.js → src/Analytics/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { renderHook } from '@testing-library/react-hooks';
import useAnalytics from "./hook";
import * as AnalyticsService from "./service";

jest.mock("./service.js");
jest.mock("./service.ts");

let props;
let props: UseAnalyticsProps;

describe("src/Analytics/hook", () => {
beforeEach(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/Analytics/hook.js → src/Analytics/hook.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { recordPageView } from "./service";
import { useEffect } from 'react';

export default ({ pathname, search }) => {
export default ({ pathname, search }: UseAnalyticsProps) => {
useEffect(() => {
recordPageView(pathname + search);
}, [ pathname, search ]);
Expand Down
17 changes: 11 additions & 6 deletions src/Analytics/service.test.js → src/Analytics/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,22 @@ import * as windowUtils from "windowUtils";
import * as analyticsService from "Analytics/service";

jest.mock("react-ga");
jest.mock("windowUtils");

describe("Initializing analytics", () => {
test("Not on localhost", () => {
const isLocalhost = jest.spyOn(windowUtils, 'isLocalhost');

beforeEach(() => {
isLocalhost.mockReturnValue(true);
});

it("Not on localhost", () => {
isLocalhost.mockReturnValue(false);
analyticsService.initializeAnalytics();

expect(GoogleAnalytics.initialize).toHaveBeenCalledWith("UA-145898568-1", expect.anything());
});

test("On localhost", () => {
windowUtils.isLocalhost.mockReturnValue(true);
it("On localhost", () => {
analyticsService.initializeAnalytics();

expect(GoogleAnalytics.initialize).toHaveBeenCalledWith("UA-145898568-2", expect.anything());
Expand All @@ -33,14 +38,14 @@ describe("Recording events", () => {

test("Interactions", () => {
const event = {action, label, category, nonInteraction: false};
analyticsService.recordInteraction(action, label, category);
analyticsService.recordInteraction(event);

expect(GoogleAnalytics.event).toBeCalledWith(event);
});

test("Non-Interactions", () => {
const event = {action, label, category, nonInteraction: true};
analyticsService.recordNonInteractionEvent(action, label, category);
analyticsService.recordNonInteractionEvent(event);

expect(GoogleAnalytics.event).toBeCalledWith(event);
});
Expand Down
14 changes: 7 additions & 7 deletions src/Analytics/service.js → src/Analytics/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@ export const initializeAnalytics = () => {
GoogleAnalytics.initialize(trackingCode, GA_CONFIG);
};

export const recordPageView = page => {
export const recordPageView = (page: string) => {
GoogleAnalytics.set({page});
GoogleAnalytics.pageview(page);
};

const recordEvent = (action, label, category, nonInteraction=false) =>
GoogleAnalytics.event({action, category, label, nonInteraction});
const recordEvent = (event: GoogleAnalytics.EventArgs) =>
GoogleAnalytics.event(event);

export const recordInteraction = (action, label, category) =>
recordEvent(action, label, category);
export const recordInteraction = (event: GoogleAnalytics.EventArgs) =>
recordEvent({ ...event, nonInteraction: false });

export const recordNonInteractionEvent = (action, label, category) =>
recordEvent(action, label, category, true);
export const recordNonInteractionEvent = (event: GoogleAnalytics.EventArgs) =>
recordEvent({ ...event, nonInteraction: true });
1 change: 1 addition & 0 deletions src/Analytics/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type UseAnalyticsProps = Pick<Location, 'pathname' | 'search'>;
9 changes: 6 additions & 3 deletions src/App/component.jsx → src/App/component.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, {useEffect} from 'react';
import { BrowserRouter as Router } from "react-router-dom";
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import styles from './styles.module.scss';
import Header from "Header/component";
import Footer from "Footer/component";
Expand Down Expand Up @@ -42,9 +43,11 @@ const App = () => {

const AppWrapper = () => (
<div className={styles.app}>
<Router basename={process.env.PUBLIC_URL}>
<App />
</Router>
<GoogleReCaptchaProvider reCaptchaKey={process.env.REACT_APP_RECAPTCHA_CLIENT_KEY ?? ''}>
<Router basename={process.env.PUBLIC_URL}>
<App />
</Router>
</GoogleReCaptchaProvider>
</div>
);

Expand Down
11 changes: 6 additions & 5 deletions src/Contact/component.jsx → src/Contact/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import Alert from "@mui/material/Alert";
import styles from "./styles.module.scss";
import ContactForm from "ContactForm/component";
import { useState, useEffect } from "react";
import { AlertColor } from "@mui/material";

const getSnackbarConfig = result => {
const getSnackbarConfig = (result: ContactFormResult | void): SnackbarConfig => {
switch (result) {
case "success":
return {
Expand All @@ -17,14 +18,14 @@ const getSnackbarConfig = result => {
message: "Uh-oh, it looks like there was an error sending your message. Please try again."
};
default:
return {};
return {} as never;
}
};

const Contact = () => {
const [result, setResult] = useState("");
const [result, setResult] = useState<ContactFormResult | void>();
const [snackbarMessage, setSnackbarMessage] = useState("");
const [snackbarSeverity, setSnackbarSeverity] = useState("");
const [snackbarSeverity, setSnackbarSeverity] = useState<AlertColor>();

useEffect(() => {
const { severity, message } = getSnackbarConfig(result);
Expand All @@ -45,7 +46,7 @@ const Contact = () => {
message={snackbarMessage}
open={!!snackbarMessage}
anchorOrigin={{vertical: "top", horizontal: "center"}}
onClose={() => setResult("")}
onClose={() => setResult(undefined)}
autoHideDuration={15000}
>
<Alert severity={snackbarSeverity} variant="filled">
Expand Down
6 changes: 6 additions & 0 deletions src/Contact/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type ContactFormResult = "success" | "failure";

interface SnackbarConfig {
severity: AlertColor,
message: string;
};
59 changes: 39 additions & 20 deletions src/ContactForm/component.jsx → src/ContactForm/component.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
import { useState } from "react";
import PropTypes from "prop-types";
import {GoogleReCaptchaProvider, GoogleReCaptcha} from 'react-google-recaptcha-v3';
import { useState, useCallback, useEffect } from "react";
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import Progress from "@mui/material/CircularProgress";
import sendMessage from "./service";
import {recordInteraction} from "Analytics/service";
import styles from "./styles.module.scss";

const ContactForm = ({ setResult }) => {
const ContactForm: React.FC<ContactFormProps> = ({
setResult,
}) => {
const [loading, setLoading] = useState(false);
const [captcha, setCaptcha] = useState("");
const [from, setFrom] = useState("");
const [replyToAddress, setReplyToAddress] = useState("");
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");

const onSendAttempt = formSent => {
const onSendAttempt = (formSent: boolean) => {
const action = !formSent ? "failure" : "success";

setLoading(false);
setResult(action);

const eventAction = !formSent ? "failure" : "success";
recordInteraction(eventAction, "Contact Form", "form");
setResult(eventAction);
recordInteraction({
action,
label: "Contact Form",
category: "form",
});

if (formSent) {
setFrom("");
Expand All @@ -29,11 +35,11 @@ const ContactForm = ({ setResult }) => {
}
}

const onSubmit = event => {
const onSubmit: React.FormEventHandler<HTMLFormElement> = event => {
event.preventDefault();

setLoading(true);
setResult("");
setResult(undefined);

const data = { subject, body, replyToAddress, from, captcha };

Expand All @@ -42,6 +48,22 @@ const ContactForm = ({ setResult }) => {
.catch(() => onSendAttempt(false));
};

const { executeRecaptcha } = useGoogleReCaptcha();

const handleReCaptchaVerify = useCallback(async () => {
if (!executeRecaptcha) {
console.warn('Execute recaptcha not yet available');
return;
}

const token = await executeRecaptcha('contact_form');
setCaptcha(token);
}, [ executeRecaptcha ]);

useEffect(() => {
handleReCaptchaVerify();
}, [ handleReCaptchaVerify ]);

return (
<form className={styles.form} onSubmit={onSubmit}>
{loading && (
Expand Down Expand Up @@ -93,7 +115,7 @@ const ContactForm = ({ setResult }) => {
<div className={styles.field}>
<textarea
name="body"
rows="5"
rows={5}
value={body}
onChange={({ target }) => setBody(target.value)}
className={styles.inputTextBox}
Expand All @@ -103,21 +125,18 @@ const ContactForm = ({ setResult }) => {
/>
</div>

<GoogleReCaptchaProvider reCaptchaKey={process.env.REACT_APP_RECAPTCHA_CLIENT_KEY}>
<GoogleReCaptcha action="contact_form" onVerify={setCaptcha} />
</GoogleReCaptchaProvider>

<div className={styles.field}>
<button className={styles.formButton} type="submit" disabled={loading}>
<button
type="submit"
className={styles.formButton}
disabled={loading || !captcha}
title={!captcha ? 'Sending is disabled until captcha verification has completed.' : ''}
>
Send Message
</button>
</div>
</form>
);
}

ContactForm.propTypes = {
showError: PropTypes.func.isRequired
};

export default ContactForm;
16 changes: 10 additions & 6 deletions src/ContactForm/service.js → src/ContactForm/service.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@

const getEndpointStage = () => {
const getEndpointStage = (): EndpointStage | void => {
switch (process.env.NODE_ENV) {
case "production":
return "prod";
case "development":
return "dev";
default:
return "";
return;
}
}

const SEND_MESSAGE_URL = `${process.env.REACT_APP_MESSAGES_API_ENDPOINT}/${getEndpointStage()}/messages`;
const endpointStage = getEndpointStage();
const SEND_MESSAGE_URL = endpointStage ? `${process.env.REACT_APP_MESSAGES_API_ENDPOINT}/${endpointStage}/messages` : undefined;

/**
* Send a message with the given data. Returns a promise with a boolean indicating if the send was successful or not.
* @param data The body of the request as an object.
* @returns {Promise<boolean>} indicating if the send was successful
*/
const sendMessage = data => {
const requestOptions = {
const sendMessage = (data: ContactFormData) => {
const requestOptions: RequestInit = {
method: 'POST',
mode: 'cors',
body: JSON.stringify(data),
Expand All @@ -27,6 +27,10 @@ const sendMessage = data => {
}
};

if (!SEND_MESSAGE_URL) {
throw new Error('Endpoint stage is missing.');
}

return fetch(SEND_MESSAGE_URL, requestOptions)
.then(({status}) => status >= 200 && status < 400);
};
Expand Down
7 changes: 7 additions & 0 deletions src/ContactForm/styles.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@
&:hover {
opacity: .8;
}

&:disabled {
background: gray;
color: white;
cursor: default;
opacity: .6;
}
}

.sendSuccess {
Expand Down
13 changes: 13 additions & 0 deletions src/ContactForm/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
type EndpointStage = "dev" | "prod";

interface ContactFormProps {
readonly setResult: (result: ContactFormResult | void) => void;
}

interface ContactFormData {
readonly body: string;
readonly captcha: string;
readonly from: string;
readonly replyToAddress: string;
readonly subject: string;
}
Loading

0 comments on commit b683e20

Please sign in to comment.