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:
@@ -1,5 +1,6 @@
|
||||
jest.mock("../Contexts/UserContext");
|
||||
jest.mock("../Contexts/FileUploadContext");
|
||||
jest.mock("../Contexts/VehicleContext");
|
||||
|
||||
import { render, screen, cleanup, waitForElementToBeRemoved } from "@testing-library/react";
|
||||
import { setToken } from "../Contexts/UserContext";
|
||||
@@ -26,27 +27,40 @@ describe("App", () => {
|
||||
|
||||
it("Route / unauthenticated", async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it("Route /home unauthenticated", async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it("Route / authenticated", async () => {
|
||||
setToken(TEST_TOKEN);
|
||||
const container = await renderRoute("/");
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -62,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
@@ -1,12 +1,23 @@
|
||||
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 (
|
||||
<UserProvider>
|
||||
<SiteRoutes />
|
||||
</UserProvider>
|
||||
<StatusProvider>
|
||||
<UserProvider>
|
||||
<CssBaseline />
|
||||
<BrowserRouter>
|
||||
<MenuDrawer>
|
||||
<SiteRoutes />
|
||||
</MenuDrawer>
|
||||
</BrowserRouter>
|
||||
</UserProvider>
|
||||
</StatusProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const FileUploadProvider = ({ children }) => {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [cancelUpload, setCancelUpload] = useState(null);
|
||||
const [linkURL, setLinkURL] = useState(null);
|
||||
const [files, setFiles] = useState(null);
|
||||
|
||||
const done = () => {
|
||||
setCancelUpload(null);
|
||||
@@ -24,15 +25,37 @@ export const FileUploadProvider = ({ children }) => {
|
||||
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 {
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error("File required");
|
||||
}
|
||||
if (!accessToken || accessToken.length === 0) {
|
||||
throw new Error("Access token required");
|
||||
}
|
||||
const file = files[0].file;
|
||||
const file = uploadFiles[0];
|
||||
const filename = file.name;
|
||||
|
||||
setUploading(true);
|
||||
@@ -43,6 +66,7 @@ export const FileUploadProvider = ({ children }) => {
|
||||
|
||||
const { data } = await uploadFile(
|
||||
file,
|
||||
formData,
|
||||
accessToken,
|
||||
setProgress,
|
||||
cancelUpload
|
||||
@@ -56,18 +80,12 @@ export const FileUploadProvider = ({ children }) => {
|
||||
setCancelUpload(null);
|
||||
setProgress(100);
|
||||
} catch (e) {
|
||||
setUploading(true);
|
||||
setStatus(`Error occured: ${e.message}`);
|
||||
setProgress(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const rejectedFile = (files) => {
|
||||
if (files.length === 0) return;
|
||||
setUploading(true);
|
||||
setStatus(`Rejected ${files[0].name} too large`);
|
||||
setProgress(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<FileUploadContext.Provider
|
||||
value={{
|
||||
@@ -75,9 +93,10 @@ export const FileUploadProvider = ({ children }) => {
|
||||
progress,
|
||||
status,
|
||||
linkURL,
|
||||
files,
|
||||
upload,
|
||||
cancel,
|
||||
rejectedFile,
|
||||
setFiles,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
jest.mock("../../services/uploadFile");
|
||||
|
||||
import { setUploadFileDelay } from "../../services/uploadFile";
|
||||
import {
|
||||
FileUploadProvider,
|
||||
useFileUploadContext,
|
||||
} from "../Contexts/FileUploadContext";
|
||||
import {
|
||||
render,
|
||||
cleanup,
|
||||
@@ -13,11 +8,19 @@ import {
|
||||
waitFor,
|
||||
} 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("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", () => {
|
||||
@@ -30,32 +33,63 @@ describe("FileUploadContext", () => {
|
||||
linkURL,
|
||||
upload,
|
||||
cancel,
|
||||
setFiles,
|
||||
} = 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_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>
|
||||
<div data-testid="message">{message}</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
|
||||
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
|
||||
data-testid="upload"
|
||||
onClick={() => upload(TEST_FILE, TEST_ACCESSTOKEN)}
|
||||
onClick={() => exec(TEST_FORMDATA, TEST_ACCESSTOKEN, TEST_FILE)}
|
||||
/>
|
||||
<button data-testid="cancel" onClick={() => cancel()} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
render(
|
||||
<FileUploadProvider>
|
||||
<TestComp />
|
||||
</FileUploadProvider>
|
||||
<StatusProvider>
|
||||
<FileUploadProvider>
|
||||
<TestComp />
|
||||
</FileUploadProvider>
|
||||
</StatusProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,17 +98,31 @@ describe("FileUploadContext", () => {
|
||||
});
|
||||
|
||||
it("Initial state", async () => {
|
||||
checkState("false", "0", "", "");
|
||||
checkState("false", "0", "", "", "");
|
||||
});
|
||||
|
||||
it("Upload no file", async () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
@@ -82,7 +130,7 @@ describe("FileUploadContext", () => {
|
||||
await waitFor(() =>
|
||||
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 () => {
|
||||
@@ -91,12 +139,12 @@ describe("FileUploadContext", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("progress").innerHTML).toEqual("50")
|
||||
);
|
||||
checkState("true", "50", "Uploading test.jpg", "");
|
||||
checkState("true", "50", "Uploading test.jpg", "", "");
|
||||
|
||||
fireEvent.click(screen.getByTestId("cancel"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("progress").innerHTML).toEqual("0")
|
||||
);
|
||||
checkState("false", "0", "Upload cancelled", "");
|
||||
checkState("false", "0", "Upload cancelled", "", "");
|
||||
});
|
||||
});
|
||||
|
||||
20
src/components/Contexts/StatusContext.jsx
Normal file
20
src/components/Contexts/StatusContext.jsx
Normal 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);
|
||||
@@ -34,13 +34,13 @@ export const UserProvider = ({ children }) => {
|
||||
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();
|
||||
@@ -62,7 +62,7 @@ export const UserProvider = ({ children }) => {
|
||||
const result = await auth.verify(idToken);
|
||||
|
||||
if (
|
||||
!result.authenticated ||
|
||||
(!result.valid && !result.authenticated) ||
|
||||
!token.idToken.payload ||
|
||||
!token.idToken.payload.exp
|
||||
) {
|
||||
@@ -71,10 +71,9 @@ export const UserProvider = ({ children }) => {
|
||||
signOut();
|
||||
return;
|
||||
}
|
||||
|
||||
startSessionTimer();
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
startSessionTimer();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,14 +26,14 @@ const INVALID_TOKEN_RESPONSE = {
|
||||
message: "Bad Request Message",
|
||||
};
|
||||
|
||||
const setupRefreshEnv = (refreshResponse, authenticated) => {
|
||||
const setupRefreshEnv = (refreshResponse, valid) => {
|
||||
auth.setRefreshResponse(refreshResponse);
|
||||
auth.setVerifyResponse({ authenticated });
|
||||
auth.setVerifyResponse({ valid });
|
||||
};
|
||||
|
||||
const setupSignInEnv = (refreshResponse, authenticated) => {
|
||||
const setupSignInEnv = (refreshResponse, valid) => {
|
||||
auth.setSignInResponse(refreshResponse);
|
||||
auth.setVerifyResponse({ authenticated });
|
||||
auth.setVerifyResponse({ valid });
|
||||
};
|
||||
|
||||
const checkBaseResults = (error, fetching, token) => {
|
||||
@@ -57,12 +57,7 @@ describe("UseContext", () => {
|
||||
describe("Signin", () => {
|
||||
beforeEach(() => {
|
||||
const TestComp = () => {
|
||||
const {
|
||||
signIn,
|
||||
error,
|
||||
token,
|
||||
fetching,
|
||||
} = useUserContext();
|
||||
const { signIn, error, token, fetching } = useUserContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -171,12 +166,7 @@ describe("UseContext", () => {
|
||||
describe("Refresh", () => {
|
||||
beforeEach(() => {
|
||||
const TestComp = () => {
|
||||
const {
|
||||
refresh,
|
||||
error,
|
||||
token,
|
||||
fetching,
|
||||
} = useUserContext();
|
||||
const { refresh, error, token, fetching } = useUserContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -188,7 +178,10 @@ describe("UseContext", () => {
|
||||
data-testid="refreshInvalidToken"
|
||||
onClick={() => refresh("INVALID_TOKEN")}
|
||||
/>
|
||||
<button data-testid="refreshValidToken" onClick={() => refresh("TEST_TOKEN")} />
|
||||
<button
|
||||
data-testid="refreshValidToken"
|
||||
onClick={() => refresh("TEST_TOKEN")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
60
src/components/Contexts/VehicleContext.jsx
Normal file
60
src/components/Contexts/VehicleContext.jsx
Normal 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);
|
||||
142
src/components/Contexts/VehicleContext.test.jsx
Normal file
142
src/components/Contexts/VehicleContext.test.jsx
Normal 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" },
|
||||
];
|
||||
@@ -3,6 +3,7 @@ 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>;
|
||||
@@ -12,6 +13,10 @@ export const useFileUploadContext = () => ({
|
||||
uploading,
|
||||
progress,
|
||||
status,
|
||||
files,
|
||||
upload: jest.fn(),
|
||||
cancel: jest.fn(),
|
||||
setFiles: jest.fn((value) => {
|
||||
files = value;
|
||||
}),
|
||||
});
|
||||
|
||||
24
src/components/Contexts/__mocks__/VehicleContext.jsx
Normal file
24
src/components/Contexts/__mocks__/VehicleContext.jsx
Normal 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;
|
||||
};
|
||||
@@ -1,16 +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 { 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", () => {
|
||||
it("Should render", async () => {
|
||||
setToken({ idToken: { jwtToken: "TEST" } });
|
||||
const { container } = render(<BrowserRouter><FileUploadForm /></BrowserRouter>);
|
||||
const { container } = render(<StatusProvider><BrowserRouter><FileUploadForm /></BrowserRouter></StatusProvider>);
|
||||
await waitFor(() => {});
|
||||
expect(container).toMatchSnapshot();
|
||||
cleanup();
|
||||
})
|
||||
|
||||
@@ -2,24 +2,238 @@
|
||||
|
||||
exports[`File Upload Form Should render 1`] = `
|
||||
<div>
|
||||
<main
|
||||
class="MuiContainer-root MuiContainer-maxWidthXs"
|
||||
<div
|
||||
data-testid="mocked-vehicleprovider"
|
||||
>
|
||||
<div
|
||||
class="makeStyles-paper-1"
|
||||
data-testid="mocked-fileuploadprovider"
|
||||
>
|
||||
<h1
|
||||
class="MuiTypography-root MuiTypography-h5"
|
||||
>
|
||||
Upload file
|
||||
</h1>
|
||||
<div
|
||||
data-testid="mocked-fileuploadprovider"
|
||||
class="makeStyles-paper-1"
|
||||
>
|
||||
<h1
|
||||
class="MuiTypography-root MuiTypography-h5"
|
||||
>
|
||||
Upload Update Package
|
||||
</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-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"
|
||||
@@ -51,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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,65 +1,224 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
CssBaseline,
|
||||
Grid,
|
||||
Chip,
|
||||
FormControl,
|
||||
Input,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@material-ui/core";
|
||||
import { DropzoneAreaBase } from "material-ui-dropzone";
|
||||
import { DropzoneArea } from "material-ui-dropzone";
|
||||
import { useUserContext } from "../Contexts/UserContext";
|
||||
import { useStatusContext } from "../Contexts/StatusContext";
|
||||
import { useVehicleContext, VehicleProvider } from "../Contexts/VehicleContext";
|
||||
import {
|
||||
useFileUploadContext,
|
||||
FileUploadProvider,
|
||||
} from "../Contexts/FileUploadContext";
|
||||
import ModalProgressBar from "../ModalProgressBar";
|
||||
import useStyles from "../useStyles";
|
||||
import menuItemStyle from "../menuItemStyle";
|
||||
|
||||
const FileUploadZone = ({ classes, token }) => {
|
||||
const { upload, rejectedFile } = useFileUploadContext();
|
||||
const {
|
||||
token: {
|
||||
idToken: { jwtToken: authToken },
|
||||
},
|
||||
} = useUserContext();
|
||||
const { setFiles } = useFileUploadContext();
|
||||
const { setMessage } = useStatusContext();
|
||||
|
||||
return (
|
||||
<form className={classes.form} noValidate>
|
||||
<DropzoneAreaBase
|
||||
<>
|
||||
<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={(files) => upload(files, authToken)}
|
||||
onChange={(files) => setFiles(files)}
|
||||
onDelete={(files) => setFiles(files)}
|
||||
onDropRejected={(files) => {
|
||||
rejectedFile(files);
|
||||
console.log("Rejected files", files);
|
||||
setMessage(`Rejected ${files[0].name} too large`);
|
||||
}}
|
||||
/>
|
||||
<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() {
|
||||
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>
|
||||
<FileUploadProvider>
|
||||
<FileUploadZone classes={classes} />
|
||||
</FileUploadProvider>
|
||||
<Grid container>
|
||||
<Grid item>
|
||||
<Button onClick={signOut}>Sign Out</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
</Container>
|
||||
<VehicleProvider>
|
||||
<FileUploadProvider>
|
||||
<MainForm />
|
||||
</FileUploadProvider>
|
||||
</VehicleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
105
src/components/Layouts/MenuDrawer.jsx
Normal file
105
src/components/Layouts/MenuDrawer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/Layouts/SideMenu.jsx
Normal file
18
src/components/Layouts/SideMenu.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/ListItemLink.jsx
Normal file
35
src/components/ListItemLink.jsx
Normal 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;
|
||||
@@ -1,18 +1,24 @@
|
||||
import React from "react";
|
||||
import { Snackbar } from "@material-ui/core";
|
||||
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
|
||||
open={open}
|
||||
message={error}
|
||||
message={msg}
|
||||
anchorOrigin={{ vertical: "top", horizontal: "center" }}
|
||||
autoHideDuration={10000}
|
||||
onClose={() => setError(null)}
|
||||
onClose={() => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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} />;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { MessageBar } from "../MessageBar";
|
||||
@@ -7,6 +7,7 @@ import { useUserContext } from "../Contexts/UserContext";
|
||||
|
||||
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 = () => {
|
||||
@@ -14,24 +15,28 @@ const SiteRoutes = () => {
|
||||
return (
|
||||
<Suspense fallback={"Loading..."}>
|
||||
<MessageBar />
|
||||
<BrowserRouter>
|
||||
<Switch>
|
||||
<AuthRoute
|
||||
path="/"
|
||||
exact
|
||||
render={() => <SSOForm />}
|
||||
type={TYPES.GUEST}
|
||||
token={token}
|
||||
/>
|
||||
<AuthRoute
|
||||
path="/home"
|
||||
render={() => <FileUploadForm />}
|
||||
type={TYPES.PROTECTED}
|
||||
token={token}
|
||||
/>
|
||||
<PageNotFound />
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
<Switch>
|
||||
<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>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,39 +2,25 @@
|
||||
|
||||
exports[`Sign In Form Should render 1`] = `
|
||||
<div>
|
||||
<main
|
||||
class="MuiContainer-root MuiContainer-maxWidthXs"
|
||||
<div
|
||||
class="makeStyles-paper-1"
|
||||
style="justify-content: center;"
|
||||
>
|
||||
<div
|
||||
class="makeStyles-paper-1"
|
||||
<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"
|
||||
>
|
||||
<h1
|
||||
class="MuiTypography-root MuiTypography-h5"
|
||||
<span
|
||||
class="MuiButton-label"
|
||||
>
|
||||
Fisker OTA Portal
|
||||
</h1>
|
||||
<form
|
||||
action="{onSubmit}"
|
||||
class="makeStyles-form-3"
|
||||
novalidate=""
|
||||
>
|
||||
<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>
|
||||
Sign In
|
||||
</span>
|
||||
<span
|
||||
class="MuiTouchRipple-root"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 useStyles from "../useStyles";
|
||||
|
||||
@@ -11,7 +11,7 @@ const getCode = (search) => {
|
||||
|
||||
export default function SignInForm() {
|
||||
const classes = useStyles();
|
||||
const { getAuthorizeURL, signIn } = useUserContext();
|
||||
const { getAuthorizeURL, signIn, fetching } = useUserContext();
|
||||
|
||||
useEffect(() => {
|
||||
const code = getCode(document.location.search);
|
||||
@@ -21,25 +21,17 @@ export default function SignInForm() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<CssBaseline />
|
||||
<div className={classes.paper}>
|
||||
<Typography component="h1" variant="h5">
|
||||
Fisker OTA Portal
|
||||
</Typography>
|
||||
<form className={classes.form} noValidate action="{onSubmit}">
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.submit}
|
||||
href={getAuthorizeURL()}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
77
src/components/VehicleAddForm/index.jsx
Normal file
77
src/components/VehicleAddForm/index.jsx
Normal 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;
|
||||
10
src/components/menuItemStyle.jsx
Normal file
10
src/components/menuItemStyle.jsx
Normal 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
|
||||
@@ -1,5 +1,9 @@
|
||||
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),
|
||||
@@ -17,6 +21,89 @@ const useStyles = makeStyles((theme) => ({
|
||||
},
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user