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
This commit is contained in:
John Wu
2021-03-11 12:53:29 -08:00
committed by GitHub
parent 39e779dc1d
commit 2e1f4a7a7c
31 changed files with 2666 additions and 377 deletions

View File

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

View File

@@ -1,5 +1,6 @@
jest.mock("../Contexts/UserContext"); jest.mock("../Contexts/UserContext");
jest.mock("../Contexts/FileUploadContext"); jest.mock("../Contexts/FileUploadContext");
jest.mock("../Contexts/VehicleContext");
import { render, screen, cleanup, waitForElementToBeRemoved } from "@testing-library/react"; import { render, screen, cleanup, waitForElementToBeRemoved } from "@testing-library/react";
import { setToken } from "../Contexts/UserContext"; import { setToken } from "../Contexts/UserContext";
@@ -26,27 +27,40 @@ describe("App", () => {
it("Route / unauthenticated", async () => { it("Route / unauthenticated", async () => {
const container = await renderRoute("/"); const container = await renderRoute("/");
expect(container.querySelector("h1").innerHTML).toEqual("Fisker OTA Portal"); expect(container.querySelector("span.MuiButton-label").innerHTML).toEqual("Sign In");
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it("Route /home unauthenticated", async () => { it("Route /home unauthenticated", async () => {
const container = await renderRoute("/home"); const container = await renderRoute("/home");
expect(container.querySelector("h1").innerHTML).toEqual("Fisker OTA Portal"); 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(); expect(container).toMatchSnapshot();
}); });
it("Route / authenticated", async () => { it("Route / authenticated", async () => {
setToken(TEST_TOKEN); setToken(TEST_TOKEN);
const container = await renderRoute("/"); const container = await renderRoute("/");
expect(container.querySelector("h1").innerHTML).toEqual("Upload file"); expect(container.querySelector("h1").innerHTML).toEqual("Upload Update Package");
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it("Route /home authenticated", async () => { it("Route /home authenticated", async () => {
setToken(TEST_TOKEN); setToken(TEST_TOKEN);
const container = await renderRoute("/home"); 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(); expect(container).toMatchSnapshot();
}); });
@@ -62,5 +76,4 @@ describe("App", () => {
expect(container.querySelector("h1").innerHTML).toEqual("Page Not Found"); expect(container.querySelector("h1").innerHTML).toEqual("Page Not Found");
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
}) })

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,23 @@
import React from "react"; import React from "react";
import { BrowserRouter } from "react-router-dom";
import { UserProvider } from "../Contexts/UserContext"; 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"; import SiteRoutes from "../Routes/SiteRoutes";
function App() { function App() {
return ( return (
<UserProvider> <StatusProvider>
<SiteRoutes /> <UserProvider>
</UserProvider> <CssBaseline />
<BrowserRouter>
<MenuDrawer>
<SiteRoutes />
</MenuDrawer>
</BrowserRouter>
</UserProvider>
</StatusProvider>
); );
} }

View File

@@ -9,6 +9,7 @@ export const FileUploadProvider = ({ children }) => {
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [cancelUpload, setCancelUpload] = useState(null); const [cancelUpload, setCancelUpload] = useState(null);
const [linkURL, setLinkURL] = useState(null); const [linkURL, setLinkURL] = useState(null);
const [files, setFiles] = useState(null);
const done = () => { const done = () => {
setCancelUpload(null); setCancelUpload(null);
@@ -24,15 +25,37 @@ export const FileUploadProvider = ({ children }) => {
done(); done();
}; };
const upload = async (files, accessToken) => { const validateUpload = (formData, accessToken, uploadFiles) => {
if (!formData) {
throw new Error("Missing package update data");
}
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 { try {
if (!files || files.length === 0) { const file = uploadFiles[0];
throw new Error("File required");
}
if (!accessToken || accessToken.length === 0) {
throw new Error("Access token required");
}
const file = files[0].file;
const filename = file.name; const filename = file.name;
setUploading(true); setUploading(true);
@@ -43,6 +66,7 @@ export const FileUploadProvider = ({ children }) => {
const { data } = await uploadFile( const { data } = await uploadFile(
file, file,
formData,
accessToken, accessToken,
setProgress, setProgress,
cancelUpload cancelUpload
@@ -56,18 +80,12 @@ export const FileUploadProvider = ({ children }) => {
setCancelUpload(null); setCancelUpload(null);
setProgress(100); setProgress(100);
} catch (e) { } catch (e) {
setUploading(true);
setStatus(`Error occured: ${e.message}`); setStatus(`Error occured: ${e.message}`);
setProgress(-1); setProgress(-1);
} }
}; };
const rejectedFile = (files) => {
if (files.length === 0) return;
setUploading(true);
setStatus(`Rejected ${files[0].name} too large`);
setProgress(-1);
};
return ( return (
<FileUploadContext.Provider <FileUploadContext.Provider
value={{ value={{
@@ -75,9 +93,10 @@ export const FileUploadProvider = ({ children }) => {
progress, progress,
status, status,
linkURL, linkURL,
files,
upload, upload,
cancel, cancel,
rejectedFile, setFiles,
}} }}
> >
{children} {children}

View File

@@ -1,10 +1,5 @@
jest.mock("../../services/uploadFile"); jest.mock("../../services/uploadFile");
import { setUploadFileDelay } from "../../services/uploadFile";
import {
FileUploadProvider,
useFileUploadContext,
} from "../Contexts/FileUploadContext";
import { import {
render, render,
cleanup, cleanup,
@@ -13,11 +8,19 @@ import {
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
const checkState = (uploading, progress, status, linkURL) => { 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("uploading").innerHTML).toEqual(uploading);
expect(screen.getByTestId("progress").innerHTML).toEqual(progress); expect(screen.getByTestId("progress").innerHTML).toEqual(progress);
expect(screen.getByTestId("status").innerHTML).toEqual(status); expect(screen.getByTestId("status").innerHTML).toEqual(status);
expect(screen.getByTestId("linkURL").innerHTML).toEqual(linkURL); expect(screen.getByTestId("linkURL").innerHTML).toEqual(linkURL);
expect(screen.getByTestId("message").innerHTML).toEqual(message);
}; };
describe("FileUploadContext", () => { describe("FileUploadContext", () => {
@@ -30,32 +33,63 @@ describe("FileUploadContext", () => {
linkURL, linkURL,
upload, upload,
cancel, cancel,
setFiles,
} = useFileUploadContext(); } = useFileUploadContext();
const TEST_FILE = [{ file: { name: "test.jpg", size: 0 } }]; const { message, setMessage } = useStatusContext();
const TEST_FILE = [{ name: "test.jpg", size: 0 }];
const TEST_ACCESSTOKEN = "ACCESSTOKEN"; 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 ( return (
<> <>
<div data-testid="uploading">{uploading.toString()}</div> <div data-testid="uploading">{uploading.toString()}</div>
<div data-testid="progress">{progress.toString()}</div> <div data-testid="progress">{progress.toString()}</div>
<div data-testid="status">{status}</div> <div data-testid="status">{status}</div>
<div data-testid="message">{message}</div>
<div data-testid="linkURL">{linkURL}</div> <div data-testid="linkURL">{linkURL}</div>
<button data-testid="uploadNoFile" onClick={() => upload()} /> <button
data-testid="uploadNoFile"
onClick={() => {
exec(TEST_FORMDATA, TEST_ACCESSTOKEN, null);
}}
/>
<button <button
data-testid="uploadNoToken" data-testid="uploadNoToken"
onClick={() => upload(TEST_FILE)} onClick={() => {
exec(TEST_FORMDATA, null, TEST_FILE);
}}
/>
<button
data-testid="uploadNoFormData"
onClick={() => {
exec({}, TEST_ACCESSTOKEN, TEST_FILE);
}}
/> />
<button <button
data-testid="upload" data-testid="upload"
onClick={() => upload(TEST_FILE, TEST_ACCESSTOKEN)} onClick={() => exec(TEST_FORMDATA, TEST_ACCESSTOKEN, TEST_FILE)}
/> />
<button data-testid="cancel" onClick={() => cancel()} /> <button data-testid="cancel" onClick={() => cancel()} />
</> </>
); );
}; };
render( render(
<FileUploadProvider> <StatusProvider>
<TestComp /> <FileUploadProvider>
</FileUploadProvider> <TestComp />
</FileUploadProvider>
</StatusProvider>
); );
}); });
@@ -64,17 +98,31 @@ describe("FileUploadContext", () => {
}); });
it("Initial state", async () => { it("Initial state", async () => {
checkState("false", "0", "", ""); checkState("false", "0", "", "", "");
}); });
it("Upload no file", async () => { it("Upload no file", async () => {
fireEvent.click(screen.getByTestId("uploadNoFile")); fireEvent.click(screen.getByTestId("uploadNoFile"));
checkState("false", "-1", "Error occured: File required", ""); await waitFor(() =>
expect(screen.getByTestId("message").innerHTML).not.toBe("")
);
checkState("false", "0", "", "", "File required");
}); });
it("Upload no access token", async () => { it("Upload no access token", async () => {
fireEvent.click(screen.getByTestId("uploadNoToken")); fireEvent.click(screen.getByTestId("uploadNoToken"));
checkState("false", "-1", "Error occured: Access token required", ""); 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 () => { it("Upload file", async () => {
@@ -82,7 +130,7 @@ describe("FileUploadContext", () => {
await waitFor(() => await waitFor(() =>
expect(screen.getByTestId("progress").innerHTML).toEqual("100") expect(screen.getByTestId("progress").innerHTML).toEqual("100")
); );
checkState("true", "100", "Uploaded test.jpg", "CLOUDFRONT_URL"); checkState("true", "100", "Uploaded test.jpg", "CLOUDFRONT_URL", "");
}); });
it("Cancel upload", async () => { it("Cancel upload", async () => {
@@ -91,12 +139,12 @@ describe("FileUploadContext", () => {
await waitFor(() => await waitFor(() =>
expect(screen.getByTestId("progress").innerHTML).toEqual("50") expect(screen.getByTestId("progress").innerHTML).toEqual("50")
); );
checkState("true", "50", "Uploading test.jpg", ""); checkState("true", "50", "Uploading test.jpg", "", "");
fireEvent.click(screen.getByTestId("cancel")); fireEvent.click(screen.getByTestId("cancel"));
await waitFor(() => await waitFor(() =>
expect(screen.getByTestId("progress").innerHTML).toEqual("0") expect(screen.getByTestId("progress").innerHTML).toEqual("0")
); );
checkState("false", "0", "Upload cancelled", ""); 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

@@ -34,13 +34,13 @@ export const UserProvider = ({ children }) => {
if (!token || !token.refreshToken || !token.refreshToken.token) return null; if (!token || !token.refreshToken || !token.refreshToken.token) return null;
const result = await refresh(token.refreshToken.token); const result = await refresh(token.refreshToken.token);
return result; return result;
} };
const isError = (resp) => { const isError = (resp) => {
if (resp === null) return true; if (resp === null) return true;
if (resp && resp.error) return true; if (resp && resp.error) return true;
return false; return false;
} };
const startSessionTimer = () => { const startSessionTimer = () => {
const duration = 1000 * token.idToken.payload.exp - new Date().getTime(); const duration = 1000 * token.idToken.payload.exp - new Date().getTime();
@@ -62,7 +62,7 @@ export const UserProvider = ({ children }) => {
const result = await auth.verify(idToken); const result = await auth.verify(idToken);
if ( if (
!result.authenticated || (!result.valid && !result.authenticated) ||
!token.idToken.payload || !token.idToken.payload ||
!token.idToken.payload.exp !token.idToken.payload.exp
) { ) {
@@ -73,8 +73,7 @@ export const UserProvider = ({ children }) => {
} }
startSessionTimer(); startSessionTimer();
} } catch (e) {
catch (e) {
setError(e.message); setError(e.message);
} }
}; };

View File

@@ -26,14 +26,14 @@ const INVALID_TOKEN_RESPONSE = {
message: "Bad Request Message", message: "Bad Request Message",
}; };
const setupRefreshEnv = (refreshResponse, authenticated) => { const setupRefreshEnv = (refreshResponse, valid) => {
auth.setRefreshResponse(refreshResponse); auth.setRefreshResponse(refreshResponse);
auth.setVerifyResponse({ authenticated }); auth.setVerifyResponse({ valid });
}; };
const setupSignInEnv = (refreshResponse, authenticated) => { const setupSignInEnv = (refreshResponse, valid) => {
auth.setSignInResponse(refreshResponse); auth.setSignInResponse(refreshResponse);
auth.setVerifyResponse({ authenticated }); auth.setVerifyResponse({ valid });
}; };
const checkBaseResults = (error, fetching, token) => { const checkBaseResults = (error, fetching, token) => {
@@ -57,12 +57,7 @@ describe("UseContext", () => {
describe("Signin", () => { describe("Signin", () => {
beforeEach(() => { beforeEach(() => {
const TestComp = () => { const TestComp = () => {
const { const { signIn, error, token, fetching } = useUserContext();
signIn,
error,
token,
fetching,
} = useUserContext();
return ( return (
<> <>
@@ -171,12 +166,7 @@ describe("UseContext", () => {
describe("Refresh", () => { describe("Refresh", () => {
beforeEach(() => { beforeEach(() => {
const TestComp = () => { const TestComp = () => {
const { const { refresh, error, token, fetching } = useUserContext();
refresh,
error,
token,
fetching,
} = useUserContext();
return ( return (
<> <>
@@ -188,7 +178,10 @@ describe("UseContext", () => {
data-testid="refreshInvalidToken" data-testid="refreshInvalidToken"
onClick={() => refresh("INVALID_TOKEN")} onClick={() => refresh("INVALID_TOKEN")}
/> />
<button data-testid="refreshValidToken" onClick={() => refresh("TEST_TOKEN")} /> <button
data-testid="refreshValidToken"
onClick={() => refresh("TEST_TOKEN")}
/>
</> </>
); );
}; };

View File

@@ -0,0 +1,60 @@
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 {
data: { data },
} = await api.getVehicles(search, token);
setVehicles(data);
} finally {
setBusy(false);
}
};
const addVehicle = async (vehicle, token) => {
try {
setBusy(true);
validateAdd(vehicle);
await api.addVehicle(vehicle, token);
} finally {
setBusy(false);
}
};
return (
<VehicleContext.Provider
value={{
busy,
vehicles,
getVehicles,
addVehicle,
}}
>
{children}
</VehicleContext.Provider>
);
};
export const useVehicleContext = () => useContext(VehicleContext);

View File

@@ -0,0 +1,142 @@
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 = [
{ vin: "3C4PDCBG0ET127145" },
{ vin: "1G1FP87S3GN100062" },
{ vin: "1HGCG325XYA062256" },
{ vin: "1J4GZ78YXWC160024" },
{ vin: "2C3CCAAG8CH222800" },
{ vin: "KNADM4A39C6028108" },
{ vin: "1G11C5SL9FF153507" },
];

View File

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

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

View File

@@ -2,24 +2,238 @@
exports[`File Upload Form Should render 1`] = ` exports[`File Upload Form Should render 1`] = `
<div> <div>
<main <div
class="MuiContainer-root MuiContainer-maxWidthXs" data-testid="mocked-vehicleprovider"
> >
<div <div
class="makeStyles-paper-1" data-testid="mocked-fileuploadprovider"
> >
<h1
class="MuiTypography-root MuiTypography-h5"
>
Upload file
</h1>
<div <div
data-testid="mocked-fileuploadprovider" class="makeStyles-paper-1"
> >
<h1
class="MuiTypography-root MuiTypography-h5"
>
Upload Update Package
</h1>
<form <form
action="{onSubmit}"
class="makeStyles-form-3" class="makeStyles-form-3"
novalidate="" 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 <div
class="MuiDropzoneArea-root" class="MuiDropzoneArea-root"
tabindex="0" tabindex="0"
@@ -51,31 +265,23 @@ exports[`File Upload Form Should render 1`] = `
</svg> </svg>
</div> </div>
</div> </div>
</form>
</div>
<div
class="MuiGrid-root MuiGrid-container"
>
<div
class="MuiGrid-root MuiGrid-item"
>
<button <button
class="MuiButtonBase-root MuiButton-root MuiButton-text" class="MuiButtonBase-root MuiButton-root MuiButton-contained makeStyles-submit-4 MuiButton-containedPrimary MuiButton-fullWidth"
tabindex="0" tabindex="0"
type="button" type="submit"
> >
<span <span
class="MuiButton-label" class="MuiButton-label"
> >
Sign Out Submit
</span> </span>
<span <span
class="MuiTouchRipple-root" class="MuiTouchRipple-root"
/> />
</button> </button>
</div> </form>
</div> </div>
</div> </div>
</main> </div>
</div> </div>
`; `;

View File

@@ -1,65 +1,224 @@
import React from "react"; import React, { useEffect, useRef, useState } from "react";
import { import {
Button, Button,
Container, Chip,
CssBaseline, FormControl,
Grid, Input,
InputLabel,
MenuItem,
Select,
TextField,
Typography, Typography,
useTheme,
} from "@material-ui/core"; } from "@material-ui/core";
import { DropzoneAreaBase } from "material-ui-dropzone"; import { DropzoneArea } from "material-ui-dropzone";
import { useUserContext } from "../Contexts/UserContext"; import { useUserContext } from "../Contexts/UserContext";
import { useStatusContext } from "../Contexts/StatusContext";
import { useVehicleContext, VehicleProvider } from "../Contexts/VehicleContext";
import { import {
useFileUploadContext, useFileUploadContext,
FileUploadProvider, FileUploadProvider,
} from "../Contexts/FileUploadContext"; } from "../Contexts/FileUploadContext";
import ModalProgressBar from "../ModalProgressBar"; import ModalProgressBar from "../ModalProgressBar";
import useStyles from "../useStyles"; import useStyles from "../useStyles";
import menuItemStyle from "../menuItemStyle";
const FileUploadZone = ({ classes, token }) => { const FileUploadZone = ({ classes, token }) => {
const { upload, rejectedFile } = useFileUploadContext(); const { setFiles } = useFileUploadContext();
const { const { setMessage } = useStatusContext();
token: {
idToken: { jwtToken: authToken },
},
} = useUserContext();
return ( return (
<form className={classes.form} noValidate> <>
<DropzoneAreaBase <DropzoneArea
id="dropzone" id="dropzone"
showPreviews={true}
showPreviewsInDropzone={false}
useChipsForPreview
previewGridProps={{ container: { spacing: 1, direction: "row" } }}
previewChipProps={{ classes: { root: classes.previewChip } }}
previewText="Selected files"
maxFileSize={1e9} maxFileSize={1e9}
filesLimit={1} filesLimit={1}
showAlerts={false} showAlerts={false}
onAdd={(files) => upload(files, authToken)} onChange={(files) => setFiles(files)}
onDelete={(files) => setFiles(files)}
onDropRejected={(files) => { onDropRejected={(files) => {
rejectedFile(files); console.log("Rejected files", files);
setMessage(`Rejected ${files[0].name} too large`);
}} }}
/> />
<ModalProgressBar /> <ModalProgressBar />
</form> </>
);
};
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() { export default function FileUploadForm() {
const { signOut } = useUserContext();
const classes = useStyles();
return ( return (
<Container component="main" maxWidth="xs"> <VehicleProvider>
<CssBaseline /> <FileUploadProvider>
<div className={classes.paper}> <MainForm />
<Typography component="h1" variant="h5"> </FileUploadProvider>
Upload file </VehicleProvider>
</Typography>
<FileUploadProvider>
<FileUploadZone classes={classes} />
</FileUploadProvider>
<Grid container>
<Grid item>
<Button onClick={signOut}>Sign Out</Button>
</Grid>
</Grid>
</div>
</Container>
); );
} }

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

View File

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

View File

@@ -1,5 +1,5 @@
import React, { Suspense } from "react"; import React, { Suspense } from "react";
import { BrowserRouter, Switch } from "react-router-dom"; import { Switch } from "react-router-dom";
import { AuthRoute, TYPES } from "../Routes/AuthRoute"; import { AuthRoute, TYPES } from "../Routes/AuthRoute";
import { MessageBar } from "../MessageBar"; import { MessageBar } from "../MessageBar";
@@ -7,6 +7,7 @@ import { useUserContext } from "../Contexts/UserContext";
const SSOForm = React.lazy(() => import("../SSOForm")); const SSOForm = React.lazy(() => import("../SSOForm"));
const FileUploadForm = React.lazy(() => import("../FileUploadForm")); const FileUploadForm = React.lazy(() => import("../FileUploadForm"));
const VehicleAddForm = React.lazy(() => import("../VehicleAddForm"));
const PageNotFound = React.lazy(() => import("../404")); const PageNotFound = React.lazy(() => import("../404"));
const SiteRoutes = () => { const SiteRoutes = () => {
@@ -14,24 +15,28 @@ const SiteRoutes = () => {
return ( return (
<Suspense fallback={"Loading..."}> <Suspense fallback={"Loading..."}>
<MessageBar /> <MessageBar />
<BrowserRouter> <Switch>
<Switch> <AuthRoute
<AuthRoute path="/"
path="/" exact
exact render={() => <SSOForm />}
render={() => <SSOForm />} type={TYPES.GUEST}
type={TYPES.GUEST} token={token}
token={token} />
/> <AuthRoute
<AuthRoute path="/home"
path="/home" render={() => <FileUploadForm />}
render={() => <FileUploadForm />} type={TYPES.PROTECTED}
type={TYPES.PROTECTED} token={token}
token={token} />
/> <AuthRoute
<PageNotFound /> path="/vehicle-add"
</Switch> render={() => <VehicleAddForm />}
</BrowserRouter> type={TYPES.PROTECTED}
token={token}
/>
<PageNotFound />
</Switch>
</Suspense> </Suspense>
); );
}; };

View File

@@ -2,39 +2,25 @@
exports[`Sign In Form Should render 1`] = ` exports[`Sign In Form Should render 1`] = `
<div> <div>
<main <div
class="MuiContainer-root MuiContainer-maxWidthXs" class="makeStyles-paper-1"
style="justify-content: center;"
> >
<div <a
class="makeStyles-paper-1" 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"
> >
<h1 <span
class="MuiTypography-root MuiTypography-h5" class="MuiButton-label"
> >
Fisker OTA Portal Sign In
</h1> </span>
<form <span
action="{onSubmit}" class="MuiTouchRipple-root"
class="makeStyles-form-3" />
novalidate="" </a>
> </div>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiButton-root MuiButton-contained makeStyles-submit-4 MuiButton-containedPrimary MuiButton-fullWidth"
href="https://cognito.com/authorize?redirect=https://example.com/callback"
tabindex="0"
>
<span
class="MuiButton-label"
>
Sign In
</span>
<span
class="MuiTouchRipple-root"
/>
</a>
</form>
</div>
</main>
</div> </div>
`; `;

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Button, Container, CssBaseline, Typography } from "@material-ui/core"; import { Button } from "@material-ui/core";
import { useUserContext } from "../Contexts/UserContext"; import { useUserContext } from "../Contexts/UserContext";
import useStyles from "../useStyles"; import useStyles from "../useStyles";
@@ -11,7 +11,7 @@ const getCode = (search) => {
export default function SignInForm() { export default function SignInForm() {
const classes = useStyles(); const classes = useStyles();
const { getAuthorizeURL, signIn } = useUserContext(); const { getAuthorizeURL, signIn, fetching } = useUserContext();
useEffect(() => { useEffect(() => {
const code = getCode(document.location.search); const code = getCode(document.location.search);
@@ -21,25 +21,17 @@ export default function SignInForm() {
}, []); }, []);
return ( return (
<Container component="main" maxWidth="xs"> <div className={classes.paper} style={{ justifyContent: "center" }}>
<CssBaseline /> <Button
<div className={classes.paper}> type="submit"
<Typography component="h1" variant="h5"> variant="contained"
Fisker OTA Portal color="primary"
</Typography> className={classes.submit}
<form className={classes.form} noValidate action="{onSubmit}"> href={getAuthorizeURL()}
<Button disabled={fetching}
type="submit" >
fullWidth {fetching ? "Please wait..." : "Sign In"}
variant="contained" </Button>
color="primary" </div>
className={classes.submit}
href={getAuthorizeURL()}
>
Sign In
</Button>
</form>
</div>
</Container>
); );
} }

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,
};
await addVehicle(formData, authToken);
setMessage(`Added ${vinEl.current.value}`);
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

@@ -1,5 +1,9 @@
import { makeStyles } from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
const MENUITEM_HEIGHT = 48;
const MENUITEM_PADDING_TOP = 8;
const DRAWER_WIDTH = 240;
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
paper: { paper: {
marginTop: theme.spacing(8), marginTop: theme.spacing(8),
@@ -17,6 +21,89 @@ const useStyles = makeStyles((theme) => ({
}, },
submit: { submit: {
margin: theme.spacing(3, 0, 2), 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,
}, },
})); }));

View File

@@ -11,7 +11,7 @@ export const getCancelToken = () => {
return issuedCancelToken; return issuedCancelToken;
} }
export const uploadFile = async (file, token, onProgress, cancelToken) => { export const uploadFile = async (file, data, token, onProgress, cancelToken) => {
if (!uploadFileDelay) return uploadFileResponse; if (!uploadFileDelay) return uploadFileResponse;
onProgress(50); onProgress(50);
await delay(10000); await delay(10000);

View File

@@ -0,0 +1,17 @@
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); },
};
export default vehiclesAPI;

View File

@@ -7,7 +7,7 @@ export const getCancelToken = () => {
return token.source(); return token.source();
} }
export const uploadFile = (file, token, onProgress, cancelToken) => { export const uploadFile = (file, data, token, onProgress, cancelToken) => {
const form = new FormData(); const form = new FormData();
let options = { let options = {
method: "POST", method: "POST",
@@ -25,6 +25,9 @@ export const uploadFile = (file, token, onProgress, cancelToken) => {
} }
} }
} }
form.append('file', file); for (let key in data) {
return axios.post(UPLOAD_ENDPOINT, form, options); 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 axios from 'axios';
const API_ENDPOINT = process.env.REACT_APP_UPLOAD_SERVICE_URL || "https://gw-dev.fiskerdps.com/ota_update";
const getOptions = (token) => ({
headers: {
"Authorization": `Bearer ${token}`,
},
});
const vehiclesAPI = {
getVehicles: async (search, token) => {
return axios.get(`${API_ENDPOINT}/vehicles`, getOptions(token));
},
addVehicle: async (vehicle, token) => {
return axios.post(`${API_ENDPOINT}/vehicle`, vehicle, getOptions(token));
}
};
export default vehiclesAPI;