Merge to main (#17)

* Fix sign up form bug

* Add run.sh to run setup and run web app

* Output node version

* Update readme with run.sh

* Fix file upload form to handle ota_update service

* Enable file upload form

Enable error boundary to catch React errors (#7)
Fix warning for link noreferrer
Include authorization header with file upload

* Remove default localhost settings (#8)

* Remove default localhost settings
Replace with deployment settings

* Fix for upload data format

* Fix test data for last commit

* Fix json link format and remove localhost default settings (#10)

* Remove default localhost settings
Replace with deployment settings

* Fix for upload data format

* Fix test data for last commit

* Fix link data format

* Fix link json again (#12)

Use id token instead of access token

* nginx things

* Web Worker Sign Out and Use Go API (#13)

* Calculate checksum and send with file upload

* Limit file upload and display rejected file error

* Add sign in timeout

* Check auth token structure before setting
Clean up

* Use web worker timer to sign out
Remove checksum
Point to Go ota update

* Remove checksum dependency

* Use compute auth service and fix static code analyzer warnings (#15)

* Clean up formatting

* Use new compute_auth service
Implment SSO
Implement token refresh
Clean up unit tests

* Fix unit tests

* Fix auth test
Fix warnings

* Update default settings for compute_auth

* Change main UI layout and add VINs to add and upload forms (#16)

* Add new upload update package form
Add new add vehicle form
Add new side menu layout
Add new toolbar layout
Update and add unit tests

* Enable add get and add vehicles

* Integration issues with ota_update service

* Update get vehicle JSON format

* Fix related unit test
Add release notes field

* Add StatusContext to display error and status messages

* Handle api error json (#18)

* Handle api error json

* Fix get vehicles error handling
Update .env.template

Co-authored-by: Rafi Greenberg <rgreenberg@fiskerinc.com>
This commit is contained in:
John Wu
2021-03-17 15:16:08 -07:00
committed by GitHub
parent 86d65b887c
commit 30155887cb
62 changed files with 4800 additions and 3716 deletions

View File

@@ -1,2 +1,3 @@
REACT_APP_AUTH_SERVICE_URL = https://dev-auth.fiskerdps.com
REACT_APP_UPLOAD_SERVICE_URL = http://localhost:8080/api/upload
REACT_APP_UPLOAD_SERVICE_URL = https://gw-dev.fiskerdps.com
REACT_APP_AUTH_CALLBACK_URL = https://dev-ota-admin.fiskerdps.com/

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@
# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"editor.formatOnSave": true
}

View File

@@ -3,9 +3,9 @@ FROM node:12-alpine as builder
COPY package*.json ./
RUN npm install
COPY . .
COPY .env.template .env
RUN npm run build
FROM nginx:alpine
COPY --from=builder build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -4,6 +4,8 @@ Front-end web application for administarting OTA services
# Setup
Run `./run.sh` from the terminal or
1. Install Node 12
2. Run `npm install`
3. Setup environment variables listed in .env.template

View File

@@ -1,6 +1,3 @@
events { worker_connections 1024; }
http {
server {
listen 80;
root /usr/share/nginx/html;
@@ -10,4 +7,3 @@ http {
try_files $uri /index.html;
}
}
}

3591
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
"@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^12.6.0",
"axios": "^0.21.1",
"clsx": "^1.1.1",
"material-ui-dropzone": "^3.5.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",

6
run.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
node -v
npm install
cp .env.template .env
npm start

View File

@@ -1,6 +1,6 @@
import { Typography } from "@material-ui/core";
import React from "react";
import useStyles from '../Styles';
import useStyles from "../useStyles";
const PageNotFound = () => {
const classes = useStyles();
@@ -12,6 +12,6 @@ const PageNotFound = () => {
</Typography>
</div>
);
}
};
export default PageNotFound;

View File

@@ -1,11 +1,12 @@
jest.mock("../Contexts/UserContext");
jest.mock("../Contexts/FileUploadContext");
jest.mock("../Contexts/VehicleContext");
import { render, screen, cleanup, waitForElementToBeRemoved, waitFor } from "@testing-library/react"
import { render, screen, cleanup, waitForElementToBeRemoved } from "@testing-library/react";
import { setToken } from "../Contexts/UserContext";
import App from ".";
const TEST_TOKEN = { accessToken: { jwtToken: "TEST" }};
const TEST_TOKEN = { idToken: { jwtToken: "TEST" } };
const LOADING_STATUS = "Loading...";
const renderRoute = async (route) => {
@@ -26,40 +27,40 @@ describe("App", () => {
it("Route / unauthenticated", async () => {
const container = await renderRoute("/");
expect(container.querySelector("h1").innerHTML).toEqual("Sign in");
expect(container).toMatchSnapshot();
});
it("Route /signup unauthenticated", async () => {
const container = await renderRoute("/signup");
expect(container.querySelector("h1").innerHTML).toEqual("Sign up");
expect(container.querySelector("span.MuiButton-label").innerHTML).toEqual("Sign In");
expect(container).toMatchSnapshot();
});
it("Route /home unauthenticated", async () => {
const container = await renderRoute("/home");
expect(container.querySelector("h1").innerHTML).toEqual("Sign in");
expect(container.querySelector("span.MuiButton-label").innerHTML).toEqual("Sign In");
expect(container).toMatchSnapshot();
});
it("Route /vehicle-add unauthenticated", async () => {
const container = await renderRoute("/vehicle-add");
expect(container.querySelector("span.MuiButton-label").innerHTML).toEqual("Sign In");
expect(container).toMatchSnapshot();
});
it("Route / authenticated", async () => {
setToken(TEST_TOKEN);
const container = await renderRoute("/");
expect(container.querySelector("h1").innerHTML).toEqual("Upload file");
expect(container).toMatchSnapshot();
});
it("Route /signup authenticated", async () => {
setToken(TEST_TOKEN);
const container = await renderRoute("/signup");
expect(container.querySelector("h1").innerHTML).toEqual("Upload file");
expect(container.querySelector("h1").innerHTML).toEqual("Upload Update Package");
expect(container).toMatchSnapshot();
});
it("Route /home authenticated", async () => {
setToken(TEST_TOKEN);
const container = await renderRoute("/home");
expect(container.querySelector("h1").innerHTML).toEqual("Upload file");
expect(container.querySelector("h1").innerHTML).toEqual("Upload Update Package");
expect(container).toMatchSnapshot();
});
it("Route /vehicle-add authenticated", async () => {
setToken(TEST_TOKEN);
const container = await renderRoute("/vehicle-add");
expect(container.querySelector("h1").innerHTML).toEqual("Add Vehicle");
expect(container).toMatchSnapshot();
});
@@ -75,5 +76,4 @@ describe("App", () => {
expect(container.querySelector("h1").innerHTML).toEqual("Page Not Found");
expect(container).toMatchSnapshot();
});
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,23 @@
import React from 'react';
import { UserProvider } from '../Contexts/UserContext';
import SiteRoutes from '../Routes/SiteRoutes';
import React from "react";
import { BrowserRouter } from "react-router-dom";
import { UserProvider } from "../Contexts/UserContext";
import { StatusProvider } from "../Contexts/StatusContext";
import { CssBaseline } from "@material-ui/core";
import MenuDrawer from "../Layouts/MenuDrawer";
import SiteRoutes from "../Routes/SiteRoutes";
function App() {
return (
<StatusProvider>
<UserProvider>
<CssBaseline />
<BrowserRouter>
<MenuDrawer>
<SiteRoutes />
</MenuDrawer>
</BrowserRouter>
</UserProvider>
</StatusProvider>
);
}

View File

@@ -8,6 +8,8 @@ export const FileUploadProvider = ({ children }) => {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState(null);
const [cancelUpload, setCancelUpload] = useState(null);
const [linkURL, setLinkURL] = useState(null);
const [files, setFiles] = useState(null);
const done = () => {
setCancelUpload(null);
@@ -23,37 +25,80 @@ export const FileUploadProvider = ({ children }) => {
done();
};
const upload = async (files) => {
try {
if (!files || files.length === 0) throw new Error("No file provided");
const validateUpload = (formData, accessToken, uploadFiles) => {
if (!formData) {
throw new Error("Missing package update data");
}
const file = files[0].file;
if (!formData.packagename || formData.packagename.length === 0) {
throw new Error("Package name required");
}
if (!formData.version || formData.version.length === 0) {
throw new Error("Package update version required");
}
if (!formData.vehicles || formData.vehicles.length === 0) {
throw new Error("Vehicles required");
}
if (!uploadFiles || uploadFiles.length === 0) {
throw new Error("File required");
}
if (!accessToken || accessToken.length === 0) {
throw new Error("Access token required");
}
};
const upload = async (formData, accessToken, uploadFiles) => {
validateUpload(formData, accessToken, uploadFiles);
try {
const file = uploadFiles[0];
const filename = file.name;
setUploading(true);
setLinkURL(null);
setProgress(0);
setStatus(`Uploading ${filename}`);
setCancelUpload(getCancelToken());
const result = await uploadFile(file, setProgress, cancelUpload);
const url = ((result && result.url) ? result.url : "No URL available");
setStatus(`Uploaded ${filename}\n${url}`);
const { data } = await uploadFile(
file,
formData,
accessToken,
setProgress,
cancelUpload
);
if (data.message) {
throw new Error(`${data.error}. ${data.message}`);
}
const url = data && data.link ? data.link : "No URL available";
setLinkURL(url);
setStatus(`Uploaded ${filename}`);
setCancelUpload(null);
setProgress(100);
}
catch (e) {
} catch (e) {
setUploading(true);
setStatus(`Error occured: ${e.message}`);
setProgress(-1);
}
};
return (
<FileUploadContext.Provider value={{
<FileUploadContext.Provider
value={{
uploading,
progress,
status,
linkURL,
files,
upload,
cancel,
}}>
setFiles,
}}
>
{children}
</FileUploadContext.Provider>
);

View File

@@ -1,26 +1,96 @@
jest.mock("../../services/uploadFile");
import {uploadFile, getCancelToken, setUploadFileResponse, setUploadFileDelay, getIssuedCancelToken } from "../../services/uploadFile"
import { FileUploadProvider, useFileUploadContext } from "../Contexts/FileUploadContext";
import {render, cleanup, screen, fireEvent, waitFor} from "@testing-library/react"
import {
render,
cleanup,
screen,
fireEvent,
waitFor,
} from "@testing-library/react";
import { setUploadFileDelay } from "../../services/uploadFile";
import {
FileUploadProvider,
useFileUploadContext,
} from "../Contexts/FileUploadContext";
import { StatusProvider, useStatusContext } from "../Contexts/StatusContext";
const checkState = (uploading, progress, status, linkURL, message) => {
expect(screen.getByTestId("uploading").innerHTML).toEqual(uploading);
expect(screen.getByTestId("progress").innerHTML).toEqual(progress);
expect(screen.getByTestId("status").innerHTML).toEqual(status);
expect(screen.getByTestId("linkURL").innerHTML).toEqual(linkURL);
expect(screen.getByTestId("message").innerHTML).toEqual(message);
};
describe("FileUploadContext", () => {
beforeEach(() => {
const TestComp = () => {
const { progress, uploading, status, upload, cancel } = useFileUploadContext();
const {
progress,
uploading,
status,
linkURL,
upload,
cancel,
setFiles,
} = useFileUploadContext();
const { message, setMessage } = useStatusContext();
const TEST_FILE = [{ name: "test.jpg", size: 0 }];
const TEST_ACCESSTOKEN = "ACCESSTOKEN";
const TEST_FORMDATA = {
packagename: "TEST",
version: "VERSION",
vehicles: ["VIN"],
};
const exec = async (form, token, file) => {
try {
await upload(form, token, file);
} catch (e) {
setMessage(e.message);
}
};
return (
<>
<div data-testid="uploading">{uploading.toString()}</div>
<div data-testid="progress">{progress.toString()}</div>
<div data-testid="status">{status}</div>
<button data-testid="uploadNoFile" onClick={() => upload()}/>
<button data-testid="upload" onClick={() => upload([{ file: { name: "test.jpg" }}])}/>
<div data-testid="message">{message}</div>
<div data-testid="linkURL">{linkURL}</div>
<button
data-testid="uploadNoFile"
onClick={() => {
exec(TEST_FORMDATA, TEST_ACCESSTOKEN, null);
}}
/>
<button
data-testid="uploadNoToken"
onClick={() => {
exec(TEST_FORMDATA, null, TEST_FILE);
}}
/>
<button
data-testid="uploadNoFormData"
onClick={() => {
exec({}, TEST_ACCESSTOKEN, TEST_FILE);
}}
/>
<button
data-testid="upload"
onClick={() => exec(TEST_FORMDATA, TEST_ACCESSTOKEN, TEST_FILE)}
/>
<button data-testid="cancel" onClick={() => cancel()} />
</>
);
};
render(<FileUploadProvider><TestComp /></FileUploadProvider>);
render(
<StatusProvider>
<FileUploadProvider>
<TestComp />
</FileUploadProvider>
</StatusProvider>
);
});
afterEach(() => {
@@ -28,34 +98,53 @@ describe("FileUploadContext", () => {
});
it("Initial state", async () => {
expect(screen.getByTestId("uploading").innerHTML).toEqual("false");
expect(screen.getByTestId("progress").innerHTML).toEqual("0");
expect(screen.getByTestId("status").innerHTML).toEqual("");
})
checkState("false", "0", "", "", "");
});
it("Upload no file", async () => {
fireEvent.click(screen.getByTestId("uploadNoFile"));
expect(screen.getByTestId("uploading").innerHTML).toEqual("false");
expect(screen.getByTestId("progress").innerHTML).toEqual("0");
expect(screen.getByTestId("status").innerHTML).toEqual("Error occured: No file provided");
})
await waitFor(() =>
expect(screen.getByTestId("message").innerHTML).not.toBe("")
);
checkState("false", "0", "", "", "File required");
});
it("Upload no access token", async () => {
fireEvent.click(screen.getByTestId("uploadNoToken"));
await waitFor(() =>
expect(screen.getByTestId("message").innerHTML).not.toBe("")
);
checkState("false", "0", "", "", "Access token required");
});
it("Upload no form data", async () => {
fireEvent.click(screen.getByTestId("uploadNoFormData"));
await waitFor(() =>
expect(screen.getByTestId("message").innerHTML).not.toBe("")
);
checkState("false", "0", "", "", "Package name required");
});
it("Upload file", async () => {
fireEvent.click(screen.getByTestId("upload"));
await waitFor(() => expect(screen.getByTestId("progress").innerHTML).toEqual("100"));
expect(screen.getByTestId("uploading").innerHTML).toEqual("true");
expect(screen.getByTestId("status").innerHTML).toEqual("Uploaded test.jpg\nCLOUDFRONT_URL");
})
await waitFor(() =>
expect(screen.getByTestId("progress").innerHTML).toEqual("100")
);
checkState("true", "100", "Uploaded test.jpg", "CLOUDFRONT_URL", "");
});
it("Cancel upload", async () => {
setUploadFileDelay(true);
fireEvent.click(screen.getByTestId("upload"));
await waitFor(() => expect(screen.getByTestId("progress").innerHTML).toEqual("50"));
expect(screen.getByTestId("uploading").innerHTML).toEqual("true");
expect(screen.getByTestId("status").innerHTML).toEqual("Uploading test.jpg");
await waitFor(() =>
expect(screen.getByTestId("progress").innerHTML).toEqual("50")
);
checkState("true", "50", "Uploading test.jpg", "", "");
fireEvent.click(screen.getByTestId("cancel"));
await waitFor(() => expect(screen.getByTestId("progress").innerHTML).toEqual("0"));
expect(screen.getByTestId("uploading").innerHTML).toEqual("false");
expect(screen.getByTestId("status").innerHTML).toEqual("Upload cancelled");
})
})
await waitFor(() =>
expect(screen.getByTestId("progress").innerHTML).toEqual("0")
);
checkState("false", "0", "Upload cancelled", "", "");
});
});

View File

@@ -0,0 +1,20 @@
import React, { useContext, useState } from "react";
const StatusContext = React.createContext();
export const StatusProvider = ({ children }) => {
const [message, setMessage] = useState(null);
return (
<StatusContext.Provider
value={{
message,
setMessage,
}}
>
{children}
</StatusContext.Provider>
);
};
export const useStatusContext = () => useContext(StatusContext);

View File

@@ -1,5 +1,6 @@
import React, { useContext, useEffect, useState } from 'react';
import auth from '../../services/auth';
import React, { useContext, useEffect, useState } from "react";
import auth from "../../services/auth";
import getTimerWorker from "../../services/timer";
const UserContext = React.createContext();
@@ -7,87 +8,153 @@ export const UserProvider = ({ children }) => {
const [fetching, setFetching] = useState(false);
const [token, setToken] = useState(null);
const [error, setError] = useState(null);
let timer;
useEffect(() => {
if (!localStorage) return;
const token = JSON.parse(localStorage.getItem("token"));
if (!token) return;
const { accessToken: { jwtToken }} = token;
const verifyToken = async (accessToken) => {
const result = await auth.verify(accessToken);
if (result.authenticated) {
setToken(token);
} else {
await signOut();
}
};
verifyToken(jwtToken);
return () => {};
const t = JSON.parse(localStorage.getItem("token"));
if (!t || !t.idToken || !t.idToken.jwtToken) return;
if (!t.idToken.payload || !t.idToken.payload.exp) return;
setToken(t);
}, []);
const signIn = async (username, password) => {
useEffect(() => {
if (!token) return;
verifyToken();
return () => {
if (timer) timer.terminate();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const refreshTokens = async () => {
if (!token || !token.refreshToken || !token.refreshToken.token) return null;
const result = await refresh(token.refreshToken.token);
return result;
};
const isError = (resp) => {
if (resp === null) return true;
if (resp && resp.error) return true;
return false;
};
const startSessionTimer = () => {
const duration = 1000 * token.idToken.payload.exp - new Date().getTime();
if (!timer) {
timer = getTimerWorker();
timer.onMessage(async (e) => {
if (e.data === "timeout") {
const t = await refreshTokens();
if (!isError(t)) return;
signOut();
}
});
}
timer.start(duration);
};
const verifyToken = async () => {
try {
if (!username) throw new Error('Email is required');
if (!password) throw new Error('Password is required');
const {
idToken: { jwtToken: idToken },
} = token;
const result = await auth.verify(idToken);
if (!result && !result.valid) {
const t = await refreshTokens();
if (!isError(t)) return;
signOut();
return;
}
startSessionTimer();
} catch (e) {
signOut();
setError(`Verify error. ${e.message}`);
}
};
const signIn = async (code) => {
let result = null;
try {
if (!code) return;
setFetching(true);
setError(null);
const result = await auth.signIn(username, password);
result = await auth.signIn(code);
if (result.message) {
throw new Error(result.message);
}
if (result.message) throw new Error(result.message);
signedIn(result);
}
catch (error) {
setError(error.message);
}
finally {
} catch (err) {
setError(`Sign in error. ${err.message}`);
} finally {
setFetching(false);
}
return result;
};
const signUp = async (username, password, confirmPassword) => {
try {
if (!username) throw new Error('Email is required');
if (!password) throw new Error('Password is required');
if (password !== confirmPassword) throw new Error('Passwords do not match');
const signOut = () => {
setToken(null);
if (localStorage) {
localStorage.removeItem("token");
}
return getLogoutURL();
};
const signedIn = (value) => {
setToken(value);
if (!localStorage || !value || !value.idToken) return;
localStorage.setItem("token", JSON.stringify(value));
};
const refresh = async (value) => {
let result = null;
try {
if (!value) {
throw new Error("Token required");
}
setFetching(true);
setError(null);
const result = await auth.signUp(username, password);
if (result.message) throw new Error(result.message);
result = await auth.refresh(value);
if (result.message) {
throw new Error(result.message);
}
catch (error) {
setError(error.message);
}
finally {
signedIn(result);
} catch (err) {
setError(`Refresh error. ${err.message}`);
} finally {
setFetching(false);
}
return result;
};
const signOut = async () => {
setToken(null);
if (!localStorage) return;
localStorage.removeItem("token");
};
const signedIn = (token) => {
setToken(token);
if (!localStorage || !token || !token.accessToken) return;
localStorage.setItem("token", JSON.stringify(token));
}
const getAuthorizeURL = () => auth.ssoAuthorize();
const getLogoutURL = () => auth.ssoLogout();
return (
<UserContext.Provider value={{
<UserContext.Provider
value={{
fetching,
token,
error,
setError,
signIn,
signUp,
signOut,
}}>
refresh,
getAuthorizeURL,
getLogoutURL,
}}
>
{children}
</UserContext.Provider>
);

View File

@@ -1,90 +1,83 @@
jest.mock("../../services/auth");
jest.mock("../../services/timer");
import {render, cleanup, screen, fireEvent, waitFor} from "@testing-library/react"
import {
render,
cleanup,
screen,
fireEvent,
waitFor,
} from "@testing-library/react";
import { UserProvider, useUserContext } from "../Contexts/UserContext";
import auth from "../../services/auth";
import getTimerWorker from "../../services/timer";
const TEST_TOKEN = { accessToken: { jwtToken: "TEST" }};
const TEST_TOKEN = {
idToken: {
jwtToken: "TEST",
payload: {
exp: new Date().getTime() / 1000,
},
},
};
const INVALID_TOKEN_RESPONSE = {
error: "Bad Request Error",
message: "Bad Request Message",
};
const setupRefreshEnv = (refreshResponse, valid) => {
auth.setRefreshResponse(refreshResponse);
auth.setVerifyResponse({ valid });
};
const setupSignInEnv = (refreshResponse, valid) => {
auth.setSignInResponse(refreshResponse);
auth.setVerifyResponse({ valid });
};
const checkBaseResults = (error, fetching, token) => {
expect(screen.getByTestId("error").innerHTML).toEqual(error);
expect(screen.getByTestId("fetching").innerHTML).toEqual(fetching);
expect(screen.getByTestId("token").innerHTML).toEqual(token);
};
const checkTokenResults = (timer, token) => {
expect(timer.start.mock.calls.length).toEqual(1);
expect(timer.onMessage.mock.calls.length).toEqual(1);
expect(timer.stop.mock.calls.length).toEqual(0);
expect(timer.terminate.mock.calls.length).toEqual(0);
if (!localStorage) {
expect(localStorage.getItem("token")).toEqual(token);
localStorage.removeItem("token");
}
};
describe("UseContext", () => {
describe("Signup", () => {
beforeEach(() => {
const TestComp = () => {
const { signUp, error, fetching } = useUserContext();
return (
<>
<div data-testid="error">{error}</div>
<div data-testid="fetching">{fetching.toString()}</div>
<button data-testid="signUpNoEmail" onClick={() => signUp("")}/>
<button data-testid="signUpNoPassword" onClick={() => signUp("test@test.com", "")}/>
<button data-testid="signUpBadConfirm" onClick={() => signUp("test@test.com", "password", "")}/>
<button data-testid="signUp" onClick={() => signUp("test@test.com", "password", "password")}/>
</>
);
};
render(<UserProvider><TestComp /></UserProvider>);
});
afterEach(() => {
cleanup();
});
it("Initial state", () => {
expect(screen.getByTestId("error").innerHTML).toEqual("");
expect(screen.getByTestId("fetching").innerHTML).toEqual("false");
});
it("Error with no email address", () => {
fireEvent.click(screen.getByTestId("signUpNoEmail"));
expect(screen.getByTestId("error").innerHTML).toEqual("Email is required");
expect(screen.getByTestId("fetching").innerHTML).toEqual("false");
});
it("Error with no password", () => {
fireEvent.click(screen.getByTestId("signUpNoPassword"));
expect(screen.getByTestId("error").innerHTML).toEqual("Password is required");
expect(screen.getByTestId("fetching").innerHTML).toEqual("false");
});
it("Error with non-matching password", () => {
fireEvent.click(screen.getByTestId("signUpBadConfirm"));
expect(screen.getByTestId("error").innerHTML).toEqual("Passwords do not match");
expect(screen.getByTestId("fetching").innerHTML).toEqual("false");
});
it("No error sign up", async () => {
fireEvent.click(screen.getByTestId("signUp"));
await waitFor(() => expect(screen.getByTestId("fetching").innerHTML).toEqual("false"));
expect(screen.getByTestId("error").innerHTML).toEqual("");
});
it("Handle server error", async () => {
auth.setSignUpResponse({ message: "SERVER-ERROR", error: "ERR" });
fireEvent.click(screen.getByTestId("signUp"));
await waitFor(() => expect(screen.getByTestId("fetching").innerHTML).toEqual("false"));
expect(screen.getByTestId("error").innerHTML).toEqual("SERVER-ERROR");
auth.setSignUpResponse({});
});
});
describe("Signin", () => {
beforeEach(() => {
const TestComp = () => {
const { signIn, error, token, fetching } = useUserContext();
return (
<>
<div data-testid="error">{error}</div>
<div data-testid="fetching">{fetching.toString()}</div>
<div data-testid="token">{JSON.stringify(token)}</div>
<button data-testid="signInNoEmail" onClick={() => signIn("")}/>
<button data-testid="signInNoPassword" onClick={() => signIn("test@test.com", "")}/>
<button data-testid="signIn" onClick={() => signIn("test@test.com", "password", "password")}/>
<button data-testid="signInNoCode" onClick={() => signIn("")} />
<button
data-testid="signInInvalidCode"
onClick={() => signIn("INVALID_CODE")}
/>
<button data-testid="signIn" onClick={() => signIn("TEST_CODE")} />
</>
);
};
render(<UserProvider><TestComp /></UserProvider>);
render(
<UserProvider>
<TestComp />
</UserProvider>
);
});
afterEach(() => {
@@ -92,43 +85,41 @@ describe("UseContext", () => {
});
it("Initial state", () => {
expect(screen.getByTestId("error").innerHTML).toEqual("");
expect(screen.getByTestId("fetching").innerHTML).toEqual("false");
expect(screen.getByTestId("token").innerHTML).toEqual("null");
checkBaseResults("", "false", "null");
});
it("Error with no email address", () => {
fireEvent.click(screen.getByTestId("signInNoEmail"));
expect(screen.getByTestId("error").innerHTML).toEqual("Email is required");
expect(screen.getByTestId("fetching").innerHTML).toEqual("false");
expect(screen.getByTestId("token").innerHTML).toEqual("null");
it("No auth code", () => {
fireEvent.click(screen.getByTestId("signInNoCode"));
checkBaseResults("", "false", "null");
});
it("Error with no password", () => {
fireEvent.click(screen.getByTestId("signInNoPassword"));
expect(screen.getByTestId("error").innerHTML).toEqual("Password is required");
expect(screen.getByTestId("fetching").innerHTML).toEqual("false");
expect(screen.getByTestId("token").innerHTML).toEqual("null");
it("Invalid auth code", async () => {
setupSignInEnv(INVALID_TOKEN_RESPONSE, false);
fireEvent.click(screen.getByTestId("signInInvalidCode"));
await waitFor(() =>
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("Sign in error. Bad Request Message", "false", "null");
});
it("No error sign in", async () => {
it("Sign in form", async () => {
const TOKEN_STRING = JSON.stringify(TEST_TOKEN);
auth.setSignInResponse(TEST_TOKEN);
fireEvent.click(screen.getByTestId("signIn"));
await waitFor(() => expect(screen.getByTestId("fetching").innerHTML).toEqual("false"));
expect(screen.getByTestId("error").innerHTML).toEqual("");
expect(screen.getByTestId("token").innerHTML).toEqual(TOKEN_STRING);
if (!localStorage) return;
expect(localStorage.getItem("token")).toEqual(TOKEN_STRING);
localStorage.removeItem("token");
});
const timer = getTimerWorker();
setupSignInEnv(TEST_TOKEN, true);
it("Handle server error", async () => {
auth.setSignInResponse({ message: "SERVER-ERROR", error: "ERR" });
fireEvent.click(screen.getByTestId("signIn"));
await waitFor(() => expect(screen.getByTestId("fetching").innerHTML).toEqual("false"));
expect(screen.getByTestId("error").innerHTML).toEqual("SERVER-ERROR");
auth.setSignUpResponse({});
await waitFor(() =>
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("", "false", TOKEN_STRING);
checkTokenResults(timer, TOKEN_STRING);
});
});
@@ -141,15 +132,21 @@ describe("UseContext", () => {
<div data-testid="error">{error}</div>
<div data-testid="fetching">{fetching.toString()}</div>
<div data-testid="token">{JSON.stringify(token)}</div>
<button data-testid="signIn" onClick={() => signIn("test@test.com", "password", "password")}/>
<button data-testid="signIn" onClick={() => signIn("TEST_CODE")} />
<button data-testid="signOut" onClick={() => signOut()} />
</>
);
};
render(<UserProvider><TestComp /></UserProvider>);
render(
<UserProvider>
<TestComp />
</UserProvider>
);
auth.setSignInResponse(TEST_TOKEN);
fireEvent.click(screen.getByTestId("signIn"));
await waitFor(() => expect(screen.getByTestId("fetching").innerHTML).toEqual("false"));
await waitFor(() =>
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
});
afterEach(() => {
@@ -159,11 +156,79 @@ describe("UseContext", () => {
it("Token cleared", () => {
fireEvent.click(screen.getByTestId("signOut"));
expect(screen.getByTestId("error").innerHTML).toEqual("");
expect(screen.getByTestId("fetching").innerHTML).toEqual("false");
expect(screen.getByTestId("token").innerHTML).toEqual("null");
checkBaseResults("", "false", "null");
if (!localStorage) return;
expect(localStorage.getItem('token')).toBeNull();
})
})
expect(localStorage.getItem("token")).toBeNull();
});
});
describe("Refresh", () => {
beforeEach(() => {
const TestComp = () => {
const { refresh, error, token, fetching } = useUserContext();
return (
<>
<div data-testid="error">{error}</div>
<div data-testid="fetching">{fetching.toString()}</div>
<div data-testid="token">{JSON.stringify(token)}</div>
<button data-testid="refreshNoToken" onClick={() => refresh("")} />
<button
data-testid="refreshInvalidToken"
onClick={() => refresh("INVALID_TOKEN")}
/>
<button
data-testid="refreshValidToken"
onClick={() => refresh("TEST_TOKEN")}
/>
</>
);
};
render(
<UserProvider>
<TestComp />
</UserProvider>
);
});
afterEach(() => {
cleanup();
});
it("Initial state", () => {
checkBaseResults("", "false", "null");
});
it("No refresh token", () => {
fireEvent.click(screen.getByTestId("refreshNoToken"));
checkBaseResults("Refresh error. Token required", "false", "null");
});
it("Invalid refresh token", async () => {
setupRefreshEnv(INVALID_TOKEN_RESPONSE, false);
fireEvent.click(screen.getByTestId("refreshInvalidToken"));
await waitFor(() =>
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("Refresh error. Bad Request Message", "false", "null");
});
it("Valid refresh token", async () => {
const TOKEN_STRING = JSON.stringify(TEST_TOKEN);
const timer = getTimerWorker();
setupRefreshEnv(TEST_TOKEN, true);
fireEvent.click(screen.getByTestId("refreshValidToken"));
await waitFor(() =>
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("", "false", TOKEN_STRING);
checkTokenResults(timer, TOKEN_STRING);
});
});
});

View File

@@ -0,0 +1,65 @@
import React, { useContext, useState } from "react";
import api from "../../services/vehicles";
const VehicleContext = React.createContext();
const validateAdd = (vehicle) => {
if (vehicle === null) {
throw new Error("No vehicle data");
}
if (!vehicle.vin || vehicle.vin.length === 0) {
throw new Error("VIN required");
}
if (vehicle.vin.length > 17) {
throw new Error("VIN cannot be larger than 17 characters");
}
};
export const VehicleProvider = ({ children }) => {
const [busy, setBusy] = useState(false);
const [vehicles, setVehicles] = useState([]);
const getVehicles = async (search, token) => {
try {
setBusy(true);
const result = await api.getVehicles(search, token);
if (result.error) {
setVehicles([]);
throw new Error(`Get vehicles error. ${result.message}`);
} else {
setVehicles(result.data);
}
} finally {
setBusy(false);
}
};
const addVehicle = async (vehicle, token) => {
try {
setBusy(true);
validateAdd(vehicle);
const result = await api.addVehicle(vehicle, token);
if (result.error) throw new Error(`Add vehicle error. ${result.message}`);
return result;
} finally {
setBusy(false);
}
};
return (
<VehicleContext.Provider
value={{
busy,
vehicles,
getVehicles,
addVehicle,
}}
>
{children}
</VehicleContext.Provider>
);
};
export const useVehicleContext = () => useContext(VehicleContext);

View File

@@ -0,0 +1,144 @@
jest.mock("../../services/vehicles");
import {
render,
cleanup,
screen,
fireEvent,
waitFor,
} from "@testing-library/react";
import { VehicleProvider, useVehicleContext } from "./VehicleContext";
import { StatusProvider, useStatusContext } from "./StatusContext";
const checkVehicleResults = (error, busy, vehicles) => {
checkBaseResults(error, busy);
expect(screen.getByTestId("vehicles").innerHTML).toEqual(vehicles);
};
const checkBaseResults = (error, busy) => {
expect(screen.getByTestId("error").innerHTML).toEqual(error);
expect(screen.getByTestId("busy").innerHTML).toEqual(busy);
};
describe("VehicleContext", () => {
describe("getVehicles", () => {
beforeEach(() => {
const TestComp = () => {
const { busy, error, vehicles, getVehicles } = useVehicleContext();
return (
<>
<div data-testid="error">{error}</div>
<div data-testid="busy">{busy.toString()}</div>
<div data-testid="vehicles">{JSON.stringify(vehicles)}</div>
<button
data-testid="getVehicles"
onClick={() => getVehicles(null)}
/>
</>
);
};
render(
<VehicleProvider>
<TestComp />
</VehicleProvider>
);
});
afterEach(() => {
cleanup();
});
it("Initial state", () => {
checkVehicleResults("", "false", "[]");
});
it("getVehicles", async () => {
fireEvent.click(screen.getByTestId("getVehicles"));
await waitFor(() =>
expect(screen.getByTestId("vehicles").innerHTML).not.toBe("[]")
);
checkVehicleResults("", "false", JSON.stringify(expectedVehicleData));
});
});
describe("AddVehicles", () => {
beforeEach(async () => {
const TestComp = () => {
const { busy, addVehicle } = useVehicleContext();
const { message, setMessage } = useStatusContext();
const add = async (data) => {
try {
await addVehicle(data);
} catch (e) {
setMessage(e.message);
}
};
return (
<>
<div data-testid="error">{message}</div>
<div data-testid="busy">{busy.toString()}</div>
<button data-testid="addVehiclesNull" onClick={() => add(null)} />
<button data-testid="addVehiclesNoVIN" onClick={() => add({})} />
<button
data-testid="addVehicles"
onClick={() => add({ vin: "XXXXXXXXXXX" })}
/>
</>
);
};
render(
<StatusProvider>
<VehicleProvider>
<TestComp />
</VehicleProvider>
</StatusProvider>
);
});
afterEach(() => {
cleanup();
});
it("Initial state", () => {
checkBaseResults("", "false");
});
it("addVehiclesNull", async () => {
fireEvent.click(screen.getByTestId("addVehiclesNull"));
await waitFor(() =>
expect(screen.getByTestId("busy").innerHTML).toEqual("false")
);
checkBaseResults("No vehicle data", "false");
});
it("addVehiclesNoVIN", async () => {
fireEvent.click(screen.getByTestId("addVehiclesNoVIN"));
await waitFor(() =>
expect(screen.getByTestId("busy").innerHTML).toEqual("false")
);
checkBaseResults("VIN required", "false");
});
it("addVehicles", async () => {
fireEvent.click(screen.getByTestId("addVehicles"));
await waitFor(() =>
expect(screen.getByTestId("busy").innerHTML).toEqual("false")
);
checkBaseResults("", "false");
});
});
});
const expectedVehicleData = {
data: [
{ vin: "3C4PDCBG0ET127145" },
{ vin: "1G1FP87S3GN100062" },
{ vin: "1HGCG325XYA062256" },
{ vin: "1J4GZ78YXWC160024" },
{ vin: "2C3CCAAG8CH222800" },
{ vin: "KNADM4A39C6028108" },
{ vin: "1G11C5SL9FF153507" },
],
};

View File

@@ -3,19 +3,20 @@ import React from "react";
let uploading = false;
let progress = 0;
let status = null;
let files = null;
export const FileUploadProvider = ({ children }) => {
return (
<div data-testid="mocked-fileuploadprovider">
{children}
</div>
);
return <div data-testid="mocked-fileuploadprovider">{children}</div>;
};
export const useFileUploadContext = () => ({
uploading,
progress,
status,
files,
upload: jest.fn(),
cancel: jest.fn(),
setFiles: jest.fn((value) => {
files = value;
}),
});

View File

@@ -1,25 +1,27 @@
import React from 'react';
import React from "react";
let token = null;
let fetching = false;
let error = null;
let signInResp = {};
let authorizeURL = "https://cognito.com/authorize?redirect=https://example.com/callback";
let logoutURL = "https://cognito.com/logout?redirect=https://example.com/callback";
export const UserProvider = ({ children }) => {
return (
<div data-testid="mocked-userprovider">
{children}
</div>
);
return <div data-testid="mocked-userprovider">{children}</div>;
};
export const useUserContext = () => ({
token,
fetching,
error,
setError: jest.fn(),
signIn: jest.fn(),
signUp: jest.fn(),
signIn: jest.fn(() => signInResp),
signOut: jest.fn(),
getAuthorizeURL: jest.fn(() => authorizeURL),
getLogoutURL: jest.fn(() => logoutURL),
setError: jest.fn((value) => {
error = value;
}),
});
export const setToken = (val) => {

View File

@@ -0,0 +1,24 @@
import React from "react";
let busy = false;
let vehicles = [];
let error = null;
export const VehicleProvider = ({ children }) => {
return <div data-testid="mocked-vehicleprovider">{children}</div>;
};
export const useVehicleContext = () => ({
busy,
vehicles,
getVehicles: jest.fn(() => vehicles),
addVehicle: jest.fn(),
});
export const setBusy = (val) => {
busy = val;
};
export const setVehicles = (val) => {
vehicles = val;
};

View File

@@ -1,10 +1,11 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import React, { Component } from "react";
import PropTypes from "prop-types";
import { Typography } from "@material-ui/core";
export default class ErrorBoundary extends Component {
state = {
error: '',
errorInfo: '',
error: "",
errorInfo: "",
hasError: false,
};
static getDerivedStateFromError(error) {
@@ -14,7 +15,12 @@ export default class ErrorBoundary extends Component {
this.setState({ errorInfo });
}
render() {
if (this.state.hasError) return (<h1>Oops. An Error Occured</h1>);
if (this.state.hasError)
return (
<Typography variant="h3" align="center">
Oops. An React JS Error Occured.
</Typography>
);
return this.props.children;
}
}

View File

@@ -1,14 +1,18 @@
jest.mock("../Contexts/UserContext");
jest.mock("../Contexts/FileUploadContext");
jest.mock("../Contexts/VehicleContext");
import { BrowserRouter } from 'react-router-dom';
import { render, cleanup } from "@testing-library/react"
import FileUploadForm from './index';
import { BrowserRouter } from "react-router-dom";
import { render, cleanup, waitFor } from "@testing-library/react";
import FileUploadForm from "./index";
import { setToken } from "../Contexts/UserContext";
import { StatusProvider } from "../Contexts/StatusContext";
describe("File Upload Form", () => {
it("Should render", () => {
const { container } = render(<BrowserRouter><FileUploadForm /></BrowserRouter>);
it("Should render", async () => {
setToken({ idToken: { jwtToken: "TEST" } });
const { container } = render(<StatusProvider><BrowserRouter><FileUploadForm /></BrowserRouter></StatusProvider>);
await waitFor(() => {});
expect(container).toMatchSnapshot();
cleanup();
})

View File

@@ -2,8 +2,11 @@
exports[`File Upload Form Should render 1`] = `
<div>
<main
class="MuiContainer-root MuiContainer-maxWidthXs"
<div
data-testid="mocked-vehicleprovider"
>
<div
data-testid="mocked-fileuploadprovider"
>
<div
class="makeStyles-paper-1"
@@ -11,15 +14,226 @@ exports[`File Upload Form Should render 1`] = `
<h1
class="MuiTypography-root MuiTypography-h5"
>
Upload file
Upload Update Package
</h1>
<div
data-testid="mocked-fileuploadprovider"
>
<form
action="{onSubmit}"
class="makeStyles-form-3"
novalidate=""
>
<div
class="MuiFormControl-root MuiTextField-root MuiFormControl-marginNormal MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined Mui-required Mui-required"
data-shrink="false"
for="packagename"
id="packagename-label"
>
Package name
<span
aria-hidden="true"
class="MuiFormLabel-asterisk MuiInputLabel-asterisk"
>
*
</span>
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth MuiInputBase-formControl"
>
<input
aria-invalid="false"
class="MuiInputBase-input MuiOutlinedInput-input"
id="packagename"
maxlength="255"
name="packagename"
required=""
type="text"
value=""
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-21 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-23"
>
<span>
Package name
 *
</span>
</legend>
</fieldset>
</div>
</div>
<div
class="MuiFormControl-root MuiTextField-root MuiFormControl-marginNormal MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined Mui-required Mui-required"
data-shrink="false"
for="version"
id="version-label"
>
Version
<span
aria-hidden="true"
class="MuiFormLabel-asterisk MuiInputLabel-asterisk"
>
*
</span>
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth MuiInputBase-formControl"
>
<input
aria-invalid="false"
class="MuiInputBase-input MuiOutlinedInput-input"
id="version"
maxlength="255"
name="version"
required=""
type="text"
value=""
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-21 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-23"
>
<span>
Version
 *
</span>
</legend>
</fieldset>
</div>
</div>
<div
class="MuiFormControl-root MuiTextField-root MuiFormControl-marginNormal MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined"
data-shrink="false"
for="description"
id="description-label"
>
Description
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth MuiInputBase-formControl MuiInputBase-multiline MuiOutlinedInput-multiline"
>
<textarea
aria-invalid="false"
class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputMultiline MuiOutlinedInput-inputMultiline"
id="description"
maxlength="5120"
name="description"
placeholder="Package description"
rows="4"
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-21 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-23"
>
<span>
Description
</span>
</legend>
</fieldset>
</div>
</div>
<div
class="MuiFormControl-root MuiTextField-root MuiFormControl-marginNormal MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined"
data-shrink="false"
for="releasenotes"
id="releasenotes-label"
>
Release Notes URL
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth MuiInputBase-formControl"
>
<input
aria-invalid="false"
class="MuiInputBase-input MuiOutlinedInput-input"
id="releasenotes"
maxlength="1024"
name="releasenotes"
placeholder="Release Notes URL"
type="text"
value=""
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-21 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-23"
>
<span>
Release Notes URL
</span>
</legend>
</fieldset>
</div>
</div>
<div
class="MuiFormControl-root makeStyles-formControl-5 MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined"
data-shrink="false"
for="vehicles"
>
Vehicles
</label>
<div
class="MuiInputBase-root MuiInput-root MuiInput-underline makeStyles-menuProps-8 MuiInputBase-formControl MuiInput-formControl"
>
<div
aria-haspopup="listbox"
aria-labelledby="vehicles"
class="MuiSelect-root MuiSelect-select MuiSelect-selectMenu MuiSelect-outlined MuiInputBase-input MuiInput-input"
id="vehicles"
role="button"
tabindex="0"
>
<span>
</span>
</div>
<input
aria-hidden="true"
class="MuiSelect-nativeInput"
id="select-multiple-chip"
name="vehicles"
placeholder="Select vehicles"
tabindex="-1"
value=""
/>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSelect-icon MuiSelect-iconOutlined"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M7 10l5 5 5-5z"
/>
</svg>
</div>
</div>
<div
class="MuiDropzoneArea-root"
tabindex="0"
@@ -27,7 +241,6 @@ exports[`File Upload Form Should render 1`] = `
<input
accept=""
autocomplete="off"
multiple=""
style="display: none;"
tabindex="-1"
type="file"
@@ -52,31 +265,23 @@ exports[`File Upload Form Should render 1`] = `
</svg>
</div>
</div>
</form>
</div>
<div
class="MuiGrid-root MuiGrid-container"
>
<div
class="MuiGrid-root MuiGrid-item"
>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-text"
class="MuiButtonBase-root MuiButton-root MuiButton-contained makeStyles-submit-4 MuiButton-containedPrimary MuiButton-fullWidth"
tabindex="0"
type="button"
type="submit"
>
<span
class="MuiButton-label"
>
Sign Out
Submit
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
</form>
</div>
</div>
</div>
</main>
</div>
`;

View File

@@ -1,46 +1,223 @@
import React from "react";
import { Button, Container, CssBaseline, Grid, Typography } from "@material-ui/core";
import { DropzoneAreaBase } from "material-ui-dropzone";
import React, { useEffect, useRef, useState } from "react";
import {
Button,
Chip,
FormControl,
Input,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
useTheme,
} from "@material-ui/core";
import { DropzoneArea } from "material-ui-dropzone";
import { useUserContext } from "../Contexts/UserContext";
import { useFileUploadContext, FileUploadProvider } from "../Contexts/FileUploadContext";
import { useStatusContext } from "../Contexts/StatusContext";
import { useVehicleContext, VehicleProvider } from "../Contexts/VehicleContext";
import {
useFileUploadContext,
FileUploadProvider,
} from "../Contexts/FileUploadContext";
import ModalProgressBar from "../ModalProgressBar";
import useStyles from "../Styles";
import useStyles from "../useStyles";
import menuItemStyle from "../menuItemStyle";
const FileUploadZone = ({ classes }) => {
const { uploading, progress, status, upload, cancel } = useFileUploadContext();
const FileUploadZone = ({ classes, token }) => {
const { setFiles } = useFileUploadContext();
const { setMessage } = useStatusContext();
return (
<form className={classes.form} noValidate>
<DropzoneAreaBase
maxFileSize={5e+7}
<>
<DropzoneArea
id="dropzone"
showPreviews={true}
showPreviewsInDropzone={false}
useChipsForPreview
previewGridProps={{ container: { spacing: 1, direction: "row" } }}
previewChipProps={{ classes: { root: classes.previewChip } }}
previewText="Selected files"
maxFileSize={1e9}
filesLimit={1}
showAlerts={false}
onAdd={upload}
onChange={(files) => setFiles(files)}
onDelete={(files) => setFiles(files)}
onDropRejected={(files) => {
setMessage(`Rejected ${files[0].name} too large`);
}}
/>
<ModalProgressBar uploading={uploading} progress={progress} onCancel={cancel} status={status} />
<ModalProgressBar />
</>
);
};
const MainForm = () => {
const { uploading, upload, files } = useFileUploadContext();
const { token } = useUserContext();
const { getVehicles, vehicles } = useVehicleContext();
const { setMessage } = useStatusContext();
const [selectedVehicles, setSelectedVehicles] = useState([]);
const theme = useTheme();
const classes = useStyles();
const packagenameEl = useRef(null);
const versionEl = useRef(null);
const descEl = useRef(null);
const releasenotesEl = useRef(null);
const handleVehiclesChange = (event) => {
setSelectedVehicles(event.target.value);
};
const onSubmit = async (event) => {
try {
event.preventDefault();
const {
idToken: { jwtToken: authToken },
} = token;
const formData = {
packagename: packagenameEl.current.value,
version: versionEl.current.value,
description: descEl.current.value,
releasenotes: releasenotesEl.current.value,
vehicles: selectedVehicles,
};
await upload(formData, authToken, files);
} catch (e) {
setMessage(e.message);
}
};
useEffect(() => {
const {
idToken: { jwtToken: authToken },
} = token;
(async () => {
try {
await getVehicles(null, authToken);
} catch (e) {
setMessage(e.message);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Upload Update Package
</Typography>
<form className={classes.form} noValidate action="{onSubmit}">
<TextField
id="packagename"
name="packagename"
label="Package name"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "255",
}}
required
fullWidth
inputRef={packagenameEl}
/>
<TextField
id="version"
name="version"
label="Version"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "255",
}}
required
fullWidth
inputRef={versionEl}
/>
<TextField
id="description"
name="description"
label="Description"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "5120",
}}
fullWidth
multiline
rows={4}
placeholder="Package description"
inputRef={descEl}
/>
<TextField
id="releasenotes"
name="releasenotes"
label="Release Notes URL"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "1024",
}}
fullWidth
placeholder="Release Notes URL"
inputRef={releasenotesEl}
/>
<FormControl
className={classes.formControl}
variant="outlined"
fullWidth
>
<InputLabel htmlFor="vehicles">Vehicles</InputLabel>
<Select
label="Vehicles"
placeholder="Select vehicles"
id="vehicles"
name="vehicles"
multiple
className={classes.menuProps}
onChange={handleVehiclesChange}
value={selectedVehicles}
input={<Input id="select-multiple-chip" />}
renderValue={(selected) => (
<div className={classes.chips}>
{selected.map((value) => (
<Chip key={value} label={value} className={classes.chip} />
))}
</div>
)}
>
{vehicles.map((vehicle) => (
<MenuItem
key={vehicle.vin}
value={vehicle.vin}
style={menuItemStyle(vehicle, selectedVehicles, theme)}
>
{vehicle.vin}
</MenuItem>
))}
</Select>
</FormControl>
<FileUploadZone classes={classes} />
<Button
type="submit"
disabled={uploading}
fullWidth
variant="contained"
color="primary"
className={classes.submit}
onClick={onSubmit}
>
{uploading ? "Uploading..." : "Submit"}
</Button>
</form>
</div>
);
};
export default function FileUploadForm() {
const { signOut } = useUserContext();
const classes = useStyles();
return (
<Container component="main" maxWidth="xs">
<CssBaseline />
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Upload file
</Typography>
<VehicleProvider>
<FileUploadProvider>
<FileUploadZone classes={classes} />
<MainForm />
</FileUploadProvider>
<Grid container>
<Grid item >
<Button onClick={signOut}>Sign Out</Button>
</Grid>
</Grid>
</div>
</Container>
</VehicleProvider>
);
}

View File

@@ -0,0 +1,105 @@
import React from "react";
import clsx from "clsx";
import { useTheme } from "@material-ui/core/styles";
import Drawer from "@material-ui/core/Drawer";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import Divider from "@material-ui/core/Divider";
import IconButton from "@material-ui/core/IconButton";
import MenuIcon from "@material-ui/icons/Menu";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import ChevronRightIcon from "@material-ui/icons/ChevronRight";
import SideMenu from "./SideMenu";
import useStyles from "../useStyles";
import { useUserContext } from "../Contexts/UserContext";
import { Button, Container } from "@material-ui/core";
export default function MenuDrawer({ children }) {
const classes = useStyles();
const theme = useTheme();
const { signOut, token } = useUserContext();
const [open, setOpen] = React.useState(false);
const handleDrawerOpen = () => {
setOpen(true);
};
const handleDrawerClose = () => {
setOpen(false);
};
return (
<div className={classes.root}>
<AppBar
position="fixed"
className={clsx(classes.appBar, {
[classes.appBarShift]: open && token !== null,
})}
>
<Toolbar>
{token !== null && (
<IconButton
color="inherit"
aria-label="open drawer"
onClick={handleDrawerOpen}
edge="start"
className={clsx(
classes.menuButton,
open && classes.hide && token !== null
)}
>
<MenuIcon />
</IconButton>
)}
<Typography variant="h6" noWrap>
Fisker OTA Portal
</Typography>
{token !== null && (
<Button
color="inherit"
onClick={signOut}
className={classes.rightToolbar}
>
Sign Out
</Button>
)}
</Toolbar>
</AppBar>
{token !== null && (
<Drawer
className={classes.drawer}
variant="persistent"
anchor="left"
open={open}
classes={{
paper: classes.drawerPaper,
}}
>
<div className={classes.drawerHeader}>
<IconButton onClick={handleDrawerClose}>
{theme.direction === "ltr" ? (
<ChevronLeftIcon />
) : (
<ChevronRightIcon />
)}
</IconButton>
</div>
<Divider />
<SideMenu />
</Drawer>
)}
<main
className={clsx(classes.content, {
[classes.contentShift]: open && token !== null,
})}
>
<div className={classes.drawerHeader} />
<Container component="main" maxWidth="md">
{children}
</Container>
</main>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import { List } from "@material-ui/core";
import ListItemLink from "../ListItemLink";
export default function SideMenu() {
const menuData = [
{ label: "Upload Update Package", to: "/home" },
{ label: "Add Vehicles", to: "/vehicle-add" },
];
return (
<List>
{menuData.map((item, index) => (
<ListItemLink key={index} primary={item.label} to={item.to} />
))}
</List>
);
}

View File

@@ -0,0 +1,35 @@
import React from "react";
import PropTypes from "prop-types";
import ListItem from "@material-ui/core/ListItem";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText";
import { Link as RouterLink } from "react-router-dom";
function ListItemLink(props) {
const { icon, primary, to } = props;
const renderLink = React.useMemo(
() =>
React.forwardRef((itemProps, ref) => (
<RouterLink to={to} ref={ref} {...itemProps} />
)),
[to]
);
return (
<li>
<ListItem button component={renderLink}>
{icon ? <ListItemIcon>{icon}</ListItemIcon> : null}
<ListItemText primary={primary} />
</ListItem>
</li>
);
}
ListItemLink.propTypes = {
icon: PropTypes.element,
primary: PropTypes.string.isRequired,
to: PropTypes.string.isRequired,
};
export default ListItemLink;

View File

@@ -1,15 +1,24 @@
import React from 'react';
import React from "react";
import { Snackbar } from "@material-ui/core";
import { useUserContext } from './Contexts/UserContext';
import { useStatusContext } from "./Contexts/StatusContext";
import { useUserContext } from "./Contexts/UserContext";
export const MessageBar = () => {
const { message, setMessage } = useStatusContext();
const { error, setError } = useUserContext();
const open = (error !== null);
const open = message !== null || error !== null;
const msg = message || error;
return (<Snackbar
return (
<Snackbar
open={open}
message={error}
message={msg}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
autoHideDuration={10000}
onClose={() => setError(null)}/>)
}
onClose={() => {
setMessage(null);
setError(null);
}}
/>
);
};

View File

@@ -1,7 +1,8 @@
import React from "react";
import Modal from '@material-ui/core/Modal';
import Modal from "@material-ui/core/Modal";
import { Button, LinearProgress } from "@material-ui/core";
import { useFileUploadContext } from "../Contexts/FileUploadContext";
const getModalStyle = () => {
const top = 30;
@@ -21,24 +22,36 @@ const getModalStyle = () => {
};
};
const ModalProgressBar = ({ onCancel, uploading, progress, status }) => {
const ModalProgressBar = () => {
const {
uploading,
progress,
status,
linkURL,
cancel,
} = useFileUploadContext();
const modalStyle = getModalStyle();
const onClickCancel = () => {
if (onCancel) onCancel();
}
const onClickCancel = cancel;
return (
<Modal open={uploading}>
<div style={modalStyle}>
{status && <p>{status}</p>}
{linkURL && (
<p>
<a href={linkURL} target="_blank" rel="noreferrer">
View
</a>
</p>
)}
<LinearProgress variant="determinate" value={progress} />
<Button onClick={onClickCancel}>
{ progress < 100 ? "Cancel" : "Done" }
{progress === 100 || progress === -1 ? "Done" : "Cancel"}
</Button>
</div>
</Modal>
);
}
};
export default ModalProgressBar;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import React from "react";
import { Redirect, Route } from "react-router-dom";
export const TYPES = {
PUBLIC: 0,
@@ -10,9 +10,8 @@ export const TYPES = {
export const AuthRoute = ({ token, type, ...others }) => {
if (!token && type === TYPES.PROTECTED) {
return <Redirect to="/" />;
}
else if (token && type === TYPES.GUEST) {
} else if (token && type === TYPES.GUEST) {
return <Redirect to="/home" />;
}
return <Route render {...others} />;
}
};

View File

@@ -1,13 +1,14 @@
import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import { useUserContext } from '../Contexts/UserContext';
import React from "react";
import { Redirect, Route } from "react-router-dom";
import { useUserContext } from "../Contexts/UserContext";
import { useStatusContext } from "../Contexts/StatusContext";
export const ProtectedRoute = ({ render, ...others }) => {
const context = useUserContext();
const { token, setError } = context;
const { token } = useUserContext();
const { setMessage } = useStatusContext();
if (!token) {
setError('Please sign in to access');
setMessage("Please sign in to access");
return <Redirect to="/" />;
}
return <Route render {...others} />;
}
};

View File

@@ -1,34 +1,44 @@
import React, { Suspense } from 'react';
import {
BrowserRouter,
Switch,
} from 'react-router-dom';
import React, { Suspense } from "react";
import { Switch } from "react-router-dom";
import { AuthRoute, TYPES } from '../Routes/AuthRoute'
import { MessageBar } from '../MessageBar';
import { useUserContext } from '../Contexts/UserContext';
import { AuthRoute, TYPES } from "../Routes/AuthRoute";
import { MessageBar } from "../MessageBar";
import { useUserContext } from "../Contexts/UserContext";
const SignInForm = React.lazy(() => import('../SignInForm'));
const SignUpForm = React.lazy(() => import('../SignUpForm'));
const FileUploadForm = React.lazy(() => import('../FileUploadForm'));
const PageNotFound = React.lazy(() => import('../404'));
const SSOForm = React.lazy(() => import("../SSOForm"));
const FileUploadForm = React.lazy(() => import("../FileUploadForm"));
const VehicleAddForm = React.lazy(() => import("../VehicleAddForm"));
const PageNotFound = React.lazy(() => import("../404"));
const SiteRoutes = () => {
const { token } = useUserContext();
return (
<Suspense fallback={"Loading..."}>
<MessageBar />
<BrowserRouter>
<Switch>
<AuthRoute path="/" exact render={() => <SignInForm />} type={TYPES.GUEST} token={token} />
<AuthRoute path="/signup" exact render={() => <SignUpForm />} type={TYPES.GUEST} token={token} />
<AuthRoute path="/home" render={() => <FileUploadForm />} type={TYPES.PROTECTED} token={token} />
<AuthRoute
path="/"
exact
render={() => <SSOForm />}
type={TYPES.GUEST}
token={token}
/>
<AuthRoute
path="/home"
render={() => <FileUploadForm />}
type={TYPES.PROTECTED}
token={token}
/>
<AuthRoute
path="/vehicle-add"
render={() => <VehicleAddForm />}
type={TYPES.PROTECTED}
token={token}
/>
<PageNotFound />
</Switch>
</BrowserRouter>
</Suspense>
);
};
export default SiteRoutes;

View File

@@ -0,0 +1,19 @@
jest.mock("../Contexts/UserContext");
import React from "react";
import { BrowserRouter } from "react-router-dom";
import { render, cleanup } from "@testing-library/react";
import SSOForm from "./index";
describe("Sign In Form", () => {
it("Should render", () => {
const { container } = render(
<BrowserRouter>
<SSOForm />
</BrowserRouter>
);
expect(container).toMatchSnapshot();
cleanup();
});
});

View File

@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sign In Form Should render 1`] = `
<div>
<div
class="makeStyles-paper-1"
style="justify-content: center;"
>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiButton-root MuiButton-contained makeStyles-submit-4 MuiButton-containedPrimary"
href="https://cognito.com/authorize?redirect=https://example.com/callback"
tabindex="0"
>
<span
class="MuiButton-label"
>
Sign In
</span>
<span
class="MuiTouchRipple-root"
/>
</a>
</div>
</div>
`;

View File

@@ -0,0 +1,37 @@
import React, { useEffect } from "react";
import { Button } from "@material-ui/core";
import { useUserContext } from "../Contexts/UserContext";
import useStyles from "../useStyles";
const getCode = (search) => {
if (!search) return null;
const s = new URLSearchParams(search);
return s.get("code");
};
export default function SignInForm() {
const classes = useStyles();
const { getAuthorizeURL, signIn, fetching } = useUserContext();
useEffect(() => {
const code = getCode(document.location.search);
if (!code) return;
signIn(code);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={classes.paper} style={{ justifyContent: "center" }}>
<Button
type="submit"
variant="contained"
color="primary"
className={classes.submit}
href={getAuthorizeURL()}
disabled={fetching}
>
{fetching ? "Please wait..." : "Sign In"}
</Button>
</div>
);
}

View File

@@ -1,15 +0,0 @@
jest.mock("../Contexts/UserContext");
import React from "react";
import { BrowserRouter } from 'react-router-dom';
import { render, cleanup } from "@testing-library/react"
import SignInForm from './index';
describe("Sign In Form", () => {
it("Should render", () => {
const { container } = render(<BrowserRouter><SignInForm /></BrowserRouter>);
expect(container).toMatchSnapshot();
cleanup();
})
})

View File

@@ -1,145 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sign In Form Should render 1`] = `
<div>
<main
class="MuiContainer-root MuiContainer-maxWidthXs"
>
<div
class="makeStyles-paper-1"
>
<h1
class="MuiTypography-root MuiTypography-h5"
>
Sign in
</h1>
<form
action="{onSubmit}"
class="makeStyles-form-3"
novalidate=""
>
<div
class="MuiFormControl-root MuiTextField-root MuiFormControl-marginNormal MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-shrink MuiInputLabel-outlined Mui-focused Mui-focused Mui-required Mui-required"
data-shrink="true"
for="email"
id="email-label"
>
Email Address
<span
aria-hidden="true"
class="MuiFormLabel-asterisk MuiInputLabel-asterisk"
>
*
</span>
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth Mui-focused Mui-focused MuiInputBase-formControl"
>
<input
aria-invalid="false"
autocomplete="email"
class="MuiInputBase-input MuiOutlinedInput-input"
id="email"
name="email"
required=""
type="text"
value=""
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-5 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-7 PrivateNotchedOutline-legendNotched-8"
>
<span>
Email Address
 *
</span>
</legend>
</fieldset>
</div>
</div>
<div
class="MuiFormControl-root MuiTextField-root MuiFormControl-marginNormal MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined Mui-required Mui-required"
data-shrink="false"
for="password"
id="password-label"
>
Password
<span
aria-hidden="true"
class="MuiFormLabel-asterisk MuiInputLabel-asterisk"
>
*
</span>
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth MuiInputBase-formControl"
>
<input
aria-invalid="false"
autocomplete="current-password"
class="MuiInputBase-input MuiOutlinedInput-input"
id="password"
name="password"
required=""
type="password"
value=""
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-5 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-7"
>
<span>
Password
 *
</span>
</legend>
</fieldset>
</div>
</div>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained makeStyles-submit-4 MuiButton-containedPrimary MuiButton-fullWidth"
tabindex="0"
type="submit"
>
<span
class="MuiButton-label"
>
Sign In
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
<div
class="MuiGrid-root MuiGrid-container"
>
<div
class="MuiGrid-root MuiGrid-item"
>
<a
class="MuiTypography-root MuiLink-root MuiLink-underlineHover MuiTypography-body2 MuiTypography-colorPrimary"
href="/signup"
>
Don't have an account? Sign Up
</a>
</div>
</div>
</form>
</div>
</main>
</div>
`;

View File

@@ -1,78 +0,0 @@
import React, { useRef } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Button, Container, CssBaseline, Grid, Link, TextField, Typography } from '@material-ui/core';
import { useUserContext } from '../Contexts/UserContext';
import useStyles from '../Styles';
export default function SignInForm() {
const classes = useStyles();
const emailEl = useRef(null);
const passwordEl = useRef(null);
const { fetching, signIn, setError } = useUserContext();
const onSubmit = async (event) => {
try {
event.preventDefault();
const username = emailEl.current.value;
const password = passwordEl.current.value;
await signIn(username, password);
}
catch (e) {
setError(e.message);
}
};
return (
<Container component="main" maxWidth="xs">
<CssBaseline />
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Sign in
</Typography>
<form className={classes.form} noValidate action="{onSubmit}">
<TextField
variant="outlined"
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
autoFocus
inputRef={emailEl}
/>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="current-password"
inputRef={passwordEl}
/>
<Button
type="submit"
disabled={fetching}
fullWidth
variant="contained"
color="primary"
className={classes.submit}
onClick={onSubmit}
>
{ fetching ? "Signing In..." : "Sign In" }
</Button>
<Grid container>
<Grid item>
<Link component={RouterLink} to="/signup" variant="body2">
{"Don't have an account? Sign Up"}
</Link>
</Grid>
</Grid>
</form>
</div>
</Container>
);
}

View File

@@ -1,14 +0,0 @@
jest.mock("../Contexts/UserContext");
import { BrowserRouter } from 'react-router-dom';
import { render, cleanup } from "@testing-library/react"
import SignUpForm from './index';
describe("Sign Up Form", () => {
it("Should render", () => {
const { container } = render(<BrowserRouter><SignUpForm /></BrowserRouter>);
expect(container).toMatchSnapshot();
cleanup();
})
})

View File

@@ -1,189 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sign Up Form Should render 1`] = `
<div>
<main
class="MuiContainer-root MuiContainer-maxWidthXs"
>
<div
class="makeStyles-paper-1"
>
<h1
class="MuiTypography-root MuiTypography-h5"
>
Sign up
</h1>
<form
class="makeStyles-form-3"
novalidate=""
>
<div
class="MuiFormControl-root MuiTextField-root MuiFormControl-marginNormal MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-shrink MuiInputLabel-outlined Mui-focused Mui-focused Mui-required Mui-required"
data-shrink="true"
for="email"
id="email-label"
>
Email Address
<span
aria-hidden="true"
class="MuiFormLabel-asterisk MuiInputLabel-asterisk"
>
*
</span>
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth Mui-focused Mui-focused MuiInputBase-formControl"
>
<input
aria-invalid="false"
autocomplete="email"
class="MuiInputBase-input MuiOutlinedInput-input"
id="email"
name="email"
required=""
type="text"
value=""
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-5 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-7 PrivateNotchedOutline-legendNotched-8"
>
<span>
Email Address
 *
</span>
</legend>
</fieldset>
</div>
</div>
<div
class="MuiFormControl-root MuiTextField-root MuiFormControl-marginNormal MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined Mui-required Mui-required"
data-shrink="false"
for="password"
id="password-label"
>
Password
<span
aria-hidden="true"
class="MuiFormLabel-asterisk MuiInputLabel-asterisk"
>
*
</span>
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth MuiInputBase-formControl"
>
<input
aria-invalid="false"
autocomplete="new-password"
class="MuiInputBase-input MuiOutlinedInput-input"
id="password"
name="password"
required=""
type="password"
value=""
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-5 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-7"
>
<span>
Password
 *
</span>
</legend>
</fieldset>
</div>
</div>
<div
class="MuiFormControl-root MuiTextField-root MuiFormControl-marginNormal MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined Mui-required Mui-required"
data-shrink="false"
for="passwordConfirm"
id="passwordConfirm-label"
>
Confirm Password
<span
aria-hidden="true"
class="MuiFormLabel-asterisk MuiInputLabel-asterisk"
>
*
</span>
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth MuiInputBase-formControl"
>
<input
aria-invalid="false"
class="MuiInputBase-input MuiOutlinedInput-input"
id="passwordConfirm"
name="password"
required=""
type="password"
value=""
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-5 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-7"
>
<span>
Confirm Password
 *
</span>
</legend>
</fieldset>
</div>
</div>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained makeStyles-submit-4 MuiButton-containedPrimary MuiButton-fullWidth"
tabindex="0"
type="submit"
>
<span
class="MuiButton-label"
>
Sign Up
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
<div
class="MuiGrid-root MuiGrid-container"
>
<div
class="MuiGrid-root MuiGrid-item"
>
<a
class="MuiTypography-root MuiLink-root MuiLink-underlineHover MuiTypography-body2 MuiTypography-colorPrimary"
href="/"
>
Already have an account? Sign In
</a>
</div>
</div>
</form>
</div>
</main>
</div>
`;

View File

@@ -1,91 +0,0 @@
import React, { useRef } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Button, Container, CssBaseline, Grid, Link, TextField, Typography } from '@material-ui/core';
import useStyles from '../Styles';
import { useUserContext } from '../Contexts/UserContext';
export default function SignInForm() {
const { signUp, signIn, fetching, setError } = useUserContext();
const classes = useStyles();
const emailEl = useRef(null);
const passwordEl = useRef(null);
const confirmEl = useRef(null);
const onSubmit = async (event) => {
try {
event.preventDefault();
const email = emailEl.current.value;
const password = passwordEl.current.value;
const confirm = confirmEl.current.value;
await signUp(email, password, confirm);
await signIn(email, password);
}
catch (e) {
setError(e.message);
}
};
return (
<Container component="main" maxWidth="xs">
<CssBaseline />
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Sign up
</Typography>
<form className={classes.form} noValidate onSubmit={onSubmit}>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
autoFocus
inputRef={emailEl}
/>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="new-password"
inputRef={passwordEl}
/>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
name="password"
label="Confirm Password"
type="password"
id="passwordConfirm"
inputRef={confirmEl}
/>
<Button
type="submit"
fullWidth
variant="contained"
color="primary"
className={classes.submit}
disabled={fetching}
>
{ fetching ? "Signing Up..." : "Sign Up" }
</Button>
<Grid container>
<Grid item>
<Link component={RouterLink} to="/" variant="body2">
{"Already have an account? Sign In"}
</Link>
</Grid>
</Grid>
</form>
</div>
</Container>
);
}

View File

@@ -1,23 +0,0 @@
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles((theme) => ({
paper: {
marginTop: theme.spacing(8),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
avatar: {
margin: theme.spacing(1),
backgroundColor: theme.palette.primary.main,
},
form: {
width: '100%', // Fix IE 11 issue.
marginTop: theme.spacing(1),
},
submit: {
margin: theme.spacing(3, 0, 2),
},
}));
export default useStyles;

View File

@@ -0,0 +1,77 @@
import React, { useRef } from "react";
import useStyles from "../useStyles";
import { useVehicleContext, VehicleProvider } from "../Contexts/VehicleContext";
import { useStatusContext } from "../Contexts/StatusContext";
import { useUserContext } from "../Contexts/UserContext";
import { Button, TextField, Typography } from "@material-ui/core";
const MainForm = () => {
const { addVehicle, busy } = useVehicleContext();
const { setMessage } = useStatusContext();
const { token } = useUserContext();
const classes = useStyles();
const vinEl = useRef(null);
const onSubmit = async (event) => {
try {
event.preventDefault();
const {
idToken: { jwtToken: authToken },
} = token;
const formData = {
vin: vinEl.current.value,
};
const result = await addVehicle(formData, authToken);
setMessage(`Added ${result.vin}`);
vinEl.current.value = "";
} catch (e) {
setMessage(e.message);
}
};
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Add Vehicle
</Typography>
<form className={classes.form} noValidate action="{onSubmit}">
<TextField
id="vin"
name="vin"
label="VIN"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "17",
}}
required
fullWidth
inputRef={vinEl}
/>
<Button
type="submit"
disabled={busy}
fullWidth
variant="contained"
color="primary"
className={classes.submit}
onClick={onSubmit}
>
{busy ? "Submitting..." : "Submit"}
</Button>
</form>
</div>
);
};
const VehicleAddForm = () => (
<VehicleProvider>
<MainForm />
</VehicleProvider>
);
export default VehicleAddForm;

View File

@@ -0,0 +1,10 @@
function menuItemStyle(item, selectedItems, theme) {
return {
fontWeight:
selectedItems.indexOf(item) === -1
? theme.typography.fontWeightRegular
: theme.typography.fontWeightMedium,
};
};
export default menuItemStyle

View File

@@ -0,0 +1,110 @@
import { makeStyles } from "@material-ui/core/styles";
const MENUITEM_HEIGHT = 48;
const MENUITEM_PADDING_TOP = 8;
const DRAWER_WIDTH = 240;
const useStyles = makeStyles((theme) => ({
paper: {
marginTop: theme.spacing(8),
display: "flex",
flexDirection: "column",
alignItems: "center",
},
avatar: {
margin: theme.spacing(1),
backgroundColor: theme.palette.primary.main,
},
form: {
width: "100%", // Fix IE 11 issue.
marginTop: theme.spacing(1),
},
submit: {
margin: theme.spacing(3, 0, 2),
textAlign: "center",
},
formControl: {
margin: theme.spacing(1),
width: "100%",
minWidth: 120,
},
chips: {
display: "flex",
flexWrap: "wrap",
},
chip: {
margin: 2,
},
menuProps: {
PaperProps: {
style: {
maxHeight: MENUITEM_HEIGHT * 4.5 + MENUITEM_PADDING_TOP,
width: 250,
},
},
},
previewChip: {
minWidth: 160,
maxWidth: 210,
},
root: {
display: "flex",
},
appBar: {
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
},
appBarShift: {
width: `calc(100% - ${DRAWER_WIDTH}px)`,
marginLeft: DRAWER_WIDTH,
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
},
menuButton: {
marginRight: theme.spacing(2),
},
hide: {
display: "none",
},
drawer: {
width: DRAWER_WIDTH,
flexShrink: 0,
},
drawerPaper: {
width: DRAWER_WIDTH,
},
drawerHeader: {
display: "flex",
alignItems: "center",
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
justifyContent: "flex-end",
},
content: {
flexGrow: 1,
padding: theme.spacing(3),
transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
marginLeft: -DRAWER_WIDTH,
},
contentShift: {
transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
marginLeft: 0,
},
rightToolbar: {
marginLeft: "auto",
marginRight: -12,
},
}));
export default useStyles;

View File

@@ -1,13 +1,10 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
}

View File

@@ -2,12 +2,14 @@ import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './components/App';
// import ErrorBoundary from './components/ErrorBoundary';
import ErrorBoundary from './components/ErrorBoundary';
import reportWebVitals from './reportWebVitals';
ReactDOM.render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
document.getElementById('root')
);

View File

@@ -1,16 +1,21 @@
const AUTH_URL = process.env.REACT_APP_AUTH_SERVICE_URL || "https://gw-dev.fiskerdps.com/compute_auth";
const CALLBACK_URL = process.env.REACT_APP_AUTH_CALLBACK_URL || "";
let signInResponse = {};
let signUpResponse = {};
let verifyResponse = {};
let refreshResponse = {};
const logResponse = (response) => {
return response;
};
export default {
ssoAuthorize: () => `${AUTH_URL}/authorize?redirect=${CALLBACK_URL}`,
ssoLogout: () => `${AUTH_URL}/logout?redirect=${CALLBACK_URL}`,
signIn: async (username, password) => logResponse(signInResponse),
signUp: async (username, password) => logResponse(signUpResponse),
verify: async (accessToken) => logResponse(verifyResponse),
verify: async (idToken) => logResponse(verifyResponse),
refresh: async (refreshToken) => logResponse(refreshResponse),
setSignInResponse: (value) => { signInResponse = value; },
setSignUpResponse: (value) => { signUpResponse = value; },
setVerifyResponse: (value) => { verifyResponse = value; },
setRefreshResponse: (value) => { refreshResponse = value; },
}

View File

@@ -0,0 +1,12 @@
const timer = {
start: jest.fn(),
stop: jest.fn(),
onMessage: jest.fn(),
terminate: jest.fn()
}
const getTimerWorker = () => {
return timer;
}
export default getTimerWorker;

View File

@@ -1,6 +1,6 @@
import delay from "../../utils/delay";
let uploadFileResponse = { url: "CLOUDFRONT_URL" };
let uploadFileResponse = { data: { link: "CLOUDFRONT_URL" } };
let uploadFileDelay = false;
let issuedCancelToken = null;
@@ -11,7 +11,7 @@ export const getCancelToken = () => {
return issuedCancelToken;
}
export const uploadFile = async (file, onProgress, cancelToken) => {
export const uploadFile = async (file, data, token, onProgress, cancelToken) => {
if (!uploadFileDelay) return uploadFileResponse;
onProgress(50);
await delay(10000);

View File

@@ -0,0 +1,20 @@
const data = [
{ vin: "3C4PDCBG0ET127145" },
{ vin: "1G1FP87S3GN100062" },
{ vin: "1HGCG325XYA062256" },
{ vin: "1J4GZ78YXWC160024" },
{ vin: "2C3CCAAG8CH222800" },
{ vin: "KNADM4A39C6028108" },
{ vin: "1G11C5SL9FF153507" },
];
const vehiclesAPI = {
getVehicles: async (search, token) => { return { data: { data } }; },
addVehicle: async (vehicle, token) => {
data.push(vehicle);
return vehicle;
},
};
export default vehiclesAPI;

View File

@@ -1,35 +1,37 @@
const AUTH_URL = process.env.REACT_APP_AUTH_SERVICE_URL;
import { fetchRespHandler } from "../utils/http";
const AUTH_URL = process.env.REACT_APP_AUTH_SERVICE_URL || "https://gw-dev.fiskerdps.com/compute_auth";
const CALLBACK_URL = process.env.REACT_APP_AUTH_CALLBACK_URL || "https://dev-ota-admin.fiskerdps.com";
const auth = {
signIn: (username, password) => fetch(`${AUTH_URL}/auth/login`, {
ssoAuthorize: () => `${AUTH_URL}/authorize?redirect=${CALLBACK_URL}`,
ssoLogout: () => `${AUTH_URL}/logout?redirect=${CALLBACK_URL}`,
signIn: (code) => fetch(`${AUTH_URL}/token`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username,
password,
code,
redirect: CALLBACK_URL,
})
}).then((response) => response.json()),
}).then(fetchRespHandler),
signUp: (username, password) => fetch(`${AUTH_URL}/auth/register`, {
verify: (idToken) => fetch(`${AUTH_URL}/verify`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username,
password,
})
}).then((response) => response.json()),
body: JSON.stringify({ token: idToken })
}).then(fetchRespHandler),
verify: (accessToken) => fetch(`${AUTH_URL}/auth/verify`, {
refresh: (refreshToken) => fetch(`${AUTH_URL}/refresh`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ token: accessToken })
}).then((response) => response.json()),
body: JSON.stringify({ refresh_token: refreshToken })
}).then(fetchRespHandler),
};
export default auth;

23
src/services/auth.test.js Normal file
View File

@@ -0,0 +1,23 @@
import auth from "./auth";
const testAuthURL = (url, endpoint) => {
const u = new URL(url);
const path = u.pathname.split("/");
expect(u.protocol).toMatch(/^http/);
expect(u.host).toBeTruthy();
expect(path[path.length - 1]).toEqual(endpoint);
expect(u.searchParams.get("redirect")).toBeTruthy();
};
describe("Auth service", () => {
describe("Auth URLs", () => {
it("Authorize URL", () => {
testAuthURL(auth.ssoAuthorize(), "authorize");
});
it("Logout URL", () => {
testAuthURL(auth.ssoLogout(), "logout");
});
});
});

View File

@@ -0,0 +1,41 @@
const workerscript = () => {
var INTERVAL = 60000;
// eslint-disable-next-line no-restricted-globals
var me = self;
var timerID = 0;
var deadline;
function startTimer(duration) {
deadline = new Date(Date.now() + duration - INTERVAL);
stopTimer();
timerID = setInterval(function() {
var now = new Date();
if (now > deadline) {
me.postMessage("timeout");
stopTimer();
}
}, INTERVAL);
}
function stopTimer() {
if (timerID > 0) clearInterval(timerID);
timerID = 0;
}
me.onmessage = function(e) {
if (e.data.action === "start") {
startTimer(e.data.duration);
}
else if (e.data.action === "stop") {
stopTimer();
}
}
};
let code = workerscript.toString();
code = code.substring(code.indexOf("{")+1, code.lastIndexOf("}"));
const blob = new Blob([code], {type: "application/javascript"});
const timeout_script = URL.createObjectURL(blob);
module.exports = timeout_script;

40
src/services/timer.js Normal file
View File

@@ -0,0 +1,40 @@
import worker_script from "./timeoutScript";
const getTimerWorker = () => {
const worker = new Worker(worker_script);
let messageHandler = null;
const workerHandler = (e) => {
if (messageHandler === null) return;
messageHandler(e);
}
worker.addEventListener("message", workerHandler);
return {
start: (duration) => {
if (!worker) return;
worker.postMessage({
action: "start",
duration,
});
},
stop: () => {
worker.postMessage({
action: "stop",
});
},
onMessage: (handler) => {
messageHandler = handler;
},
terminate: () => {
worker.removeEventListener("message", workerHandler);
worker.terminate();
messageHandler = null;
}
}
}
export default getTimerWorker;

View File

@@ -1,18 +1,19 @@
import axios from 'axios';
const UPLOAD_ENDPOINT = process.env.REACT_APP_UPLOAD_SERVICE_URL;
const UPLOAD_ENDPOINT = process.env.REACT_APP_UPLOAD_SERVICE_URL || "https://gw-dev.fiskerdps.com/ota_update";
export const getCancelToken = () => {
const token = axios.CancelToken;
return token.source();
}
export const uploadFile = (file, onProgress, cancelToken) => {
export const uploadFile = (file, data, token, onProgress, cancelToken) => {
const form = new FormData();
let options = {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'multipart/form-data',
"Content-Type": "multipart/form-data",
"Authorization": `Bearer ${token}`,
},
cancelToken,
};
@@ -24,6 +25,9 @@ export const uploadFile = (file, onProgress, cancelToken) => {
}
}
}
form.append('file', file);
return axios.post(UPLOAD_ENDPOINT, form, options);
for (let key in data) {
form.append(key, data[key]);
}
form.append("file", file);
return axios.post(`${UPLOAD_ENDPOINT}/upload`, form, options);
};

20
src/services/vehicles.js Normal file
View File

@@ -0,0 +1,20 @@
import { getAuthHeaderOptions, fetchRespHandler } from "../utils/http"
const API_ENDPOINT = process.env.REACT_APP_UPLOAD_SERVICE_URL || "https://gw-dev.fiskerdps.com/ota_update";
const vehiclesAPI = {
addVehicle: async (vehicle, token) => fetch(`${API_ENDPOINT}/vehicle`, {
method: "POST",
headers: Object.assign({ "Content-Type": "application/json" }, getAuthHeaderOptions(token)),
body: JSON.stringify(vehicle),
})
.then(fetchRespHandler),
getVehicles: async (search, token) => fetch(`${API_ENDPOINT}/vehicles`, {
method: "GET",
headers: Object.assign({ "Content-Type": "application/json" }, getAuthHeaderOptions(token)),
})
.then(fetchRespHandler)
};
export default vehiclesAPI;

View File

@@ -2,4 +2,4 @@
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
import "@testing-library/jest-dom";

14
src/utils/http.js Normal file
View File

@@ -0,0 +1,14 @@
export const getAuthHeaderOptions = (token) => ({
"Authorization": `Bearer ${token}`,
});
export const fetchRespHandler = (response) => {
if (response.ok) return response.json();
return response.text()
.then((text) => JSON.parse(text))
.catch((e) => ({
error: response.statusText,
message: `${response.status} ${response.statusText}`,
}))
}