Sync to main (#26)

* Fix sign up form bug

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

* Output node version

* Update readme with run.sh

* Fix file upload form to handle ota_update service

* Enable file upload form

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

* Remove default localhost settings (#8)

* Remove default localhost settings
Replace with deployment settings

* Fix for upload data format

* Fix test data for last commit

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

* Remove default localhost settings
Replace with deployment settings

* Fix for upload data format

* Fix test data for last commit

* Fix link data format

* Fix link json again (#12)

Use id token instead of access token

* nginx things

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

* Calculate checksum and send with file upload

* Limit file upload and display rejected file error

* Add sign in timeout

* Check auth token structure before setting
Clean up

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

* Remove checksum dependency

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

* Clean up formatting

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

* Fix unit tests

* Fix auth test
Fix warnings

* Update default settings for compute_auth

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

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

* Enable add get and add vehicles

* Integration issues with ota_update service

* Update get vehicle JSON format

* Fix related unit test
Add release notes field

* Add StatusContext to display error and status messages

* Handle api error json (#18)

* Handle api error json

* Fix get vehicles error handling
Update .env.template

* Fix signout refresh (#20)

* Merge to main (#17)

* Fix sign up form bug

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

* Output node version

* Update readme with run.sh

* Fix file upload form to handle ota_update service

* Enable file upload form

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

* Remove default localhost settings (#8)

* Remove default localhost settings
Replace with deployment settings

* Fix for upload data format

* Fix test data for last commit

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

* Remove default localhost settings
Replace with deployment settings

* Fix for upload data format

* Fix test data for last commit

* Fix link data format

* Fix link json again (#12)

Use id token instead of access token

* nginx things

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

* Calculate checksum and send with file upload

* Limit file upload and display rejected file error

* Add sign in timeout

* Check auth token structure before setting
Clean up

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

* Remove checksum dependency

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

* Clean up formatting

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

* Fix unit tests

* Fix auth test
Fix warnings

* Update default settings for compute_auth

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

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

* Enable add get and add vehicles

* Integration issues with ota_update service

* Update get vehicle JSON format

* Fix related unit test
Add release notes field

* Add StatusContext to display error and status messages

* Handle api error json (#18)

* Handle api error json

* Fix get vehicles error handling
Update .env.template

Co-authored-by: Rafi Greenberg <rgreenberg@fiskerinc.com>

* Fix sign out and refresh

* Check for bad json

Co-authored-by: Rafi Greenberg <rgreenberg@fiskerinc.com>

* Add role checks (#21)

* Add role checks

* Remove moved Roles enum

* Add package updates, car updates, and vehicle screens (#25)

Co-authored-by: Rafi Greenberg <rgreenberg@fiskerinc.com>
This commit is contained in:
John Wu
2021-04-01 09:16:38 -07:00
committed by GitHub
parent 30155887cb
commit 0f19a62b32
43 changed files with 3243 additions and 1105 deletions

View File

@@ -4,9 +4,9 @@ jest.mock("../Contexts/VehicleContext");
import { render, screen, cleanup, waitForElementToBeRemoved } from "@testing-library/react";
import { setToken } from "../Contexts/UserContext";
import { TEST_AUTH_OBJECT } from "../../utils/testing"
import App from ".";
const TEST_TOKEN = { idToken: { jwtToken: "TEST" } };
const LOADING_STATUS = "Loading...";
const renderRoute = async (route) => {
@@ -37,6 +37,12 @@ describe("App", () => {
expect(container).toMatchSnapshot();
});
it("Route /package-upload unauthenticated", async () => {
const container = await renderRoute("/package-upload");
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");
@@ -44,21 +50,28 @@ describe("App", () => {
});
it("Route / authenticated", async () => {
setToken(TEST_TOKEN);
setToken(TEST_AUTH_OBJECT);
const container = await renderRoute("/");
expect(container.querySelector("h1").innerHTML).toEqual("Upload Update Package");
expect(container.querySelector("h1").innerHTML).toEqual("Welcome John!");
expect(container).toMatchSnapshot();
});
it("Route /home authenticated", async () => {
setToken(TEST_TOKEN);
setToken(TEST_AUTH_OBJECT);
const container = await renderRoute("/home");
expect(container.querySelector("h1").innerHTML).toEqual("Welcome John!");
expect(container).toMatchSnapshot();
});
it("Route /package-upload authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
const container = await renderRoute("/package-upload");
expect(container.querySelector("h1").innerHTML).toEqual("Upload Update Package");
expect(container).toMatchSnapshot();
});
it("Route /vehicle-add authenticated", async () => {
setToken(TEST_TOKEN);
setToken(TEST_AUTH_OBJECT);
const container = await renderRoute("/vehicle-add");
expect(container.querySelector("h1").innerHTML).toEqual("Add Vehicle");
expect(container).toMatchSnapshot();
@@ -71,7 +84,7 @@ describe("App", () => {
});
it("Route /page-not-found authenticated", async () => {
setToken(TEST_TOKEN);
setToken(TEST_AUTH_OBJECT);
const container = await renderRoute("/page-not-found");
expect(container.querySelector("h1").innerHTML).toEqual("Page Not Found");
expect(container).toMatchSnapshot();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router";
import {
Button,
Chip,
FormControl,
Input,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
useTheme,
} from "@material-ui/core";
import {
UpdatesProvider,
useUpdatesContext,
} from "../../Contexts/UpdatesContext";
import {
useVehicleContext,
VehicleProvider,
} from "../../Contexts/VehicleContext";
import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext";
import useStyles from "../../useStyles";
import { tsLocalDateTimeString } from "../../../utils/dates";
import menuItemStyle from "../../menuItemStyle";
const MainForm = () => {
const { packageid } = useParams();
const { getPackages, createCarUpdates, packages, busy } = useUpdatesContext();
const { getVehicles, vehicles } = useVehicleContext();
const {
token: {
idToken: { jwtToken: token },
},
} = useUserContext();
const { setMessage } = useStatusContext();
const [packageName, setPackageName] = useState("");
const [version, setVersion] = useState("");
const [link, setLink] = useState("");
const [description, setDescription] = useState("");
const [releaseNotesLink, setReleaseNotesLink] = useState("");
const [createDate, setCreateDate] = useState("");
const [selectedVehicles, setSelectedVehicles] = useState([]);
const classes = useStyles();
const theme = useTheme();
const handleVehiclesChange = (event) => {
setSelectedVehicles(event.target.value);
};
const getCarIDs = () => {
if (!selectedVehicles) return [];
return selectedVehicles.map((vin) => {
return vehicles.find((vehicle) => vehicle.vin === vin).id;
});
};
const onSubmit = async (event) => {
try {
event.preventDefault();
const data = {
package_id: parseInt(packageid),
car_ids: getCarIDs(),
};
await createCarUpdates(data, token);
setMessage(
`Deployed ${packageName} ${version} to ${selectedVehicles.length} cars`
);
setSelectedVehicles([]);
} catch (e) {
setMessage(e.message);
}
};
const getData = async () => {
try {
getPackages({ id: parseInt(packageid) }, token);
} catch (e) {
setMessage(e.message);
}
};
const handleCarOpen = async () => {
try {
await getVehicles(null, token);
} catch (e) {
setMessage(e.message);
}
};
useEffect(() => {
getData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
if (!packages || packages.length === 0) return;
var data = packages[0];
setPackageName(data.package_name);
setVersion(data.version);
setLink(data.link);
setDescription(data.desc || "");
setReleaseNotesLink(data.release_notes || "");
setCreateDate(tsLocalDateTimeString(data.timestamp));
}, [packages]);
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
{`[${packageid}] ${packageName} ${version}`}
</Typography>
<form className={classes.form} noValidate action="{onSubmit}">
<TextField
label="Create Date"
variant="filled"
margin="normal"
inputProps={{
readOnly: true,
}}
value={createDate}
size="small"
fullWidth
/>
<TextField
id="link"
name="link"
label="Package link"
variant="filled"
margin="normal"
inputProps={{
readOnly: true,
}}
value={link}
size="small"
fullWidth
/>
<TextField
id="description"
name="description"
label="Description"
variant="filled"
margin="normal"
inputProps={{
readOnly: true,
}}
value={description}
size="small"
fullWidth
multiline
rows={4}
placeholder="Package description"
/>
<TextField
id="releasenotes"
name="releasenotes"
label="Release Notes URL"
variant="filled"
margin="normal"
inputProps={{
readOnly: true,
}}
value={releaseNotesLink}
size="small"
fullWidth
placeholder="Release Notes URL"
/>
<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}
variant="outlined"
onOpen={handleCarOpen}
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>
<Button
type="submit"
disabled={busy}
fullWidth
variant="contained"
color="primary"
className={classes.submit}
onClick={onSubmit}
>
{busy ? "Deploying..." : "Deploy"}
</Button>
</form>
</div>
);
};
const UpdatePackageDeployForm = () => (
<VehicleProvider>
<UpdatesProvider>
<MainForm />
</UpdatesProvider>
</VehicleProvider>
);
export default UpdatePackageDeployForm;

View File

@@ -0,0 +1,139 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableFooter,
TableHead,
TablePagination,
TableRow,
Typography,
} from "@material-ui/core";
import {
UpdatesProvider,
useUpdatesContext,
} from "../../Contexts/UpdatesContext";
import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext";
import useStyles from "../../useStyles";
import { LocalDateTimeString } from "../../../utils/dates";
const MainForm = () => {
const { packageid } = useParams();
const classes = useStyles();
const [pageSize, setPageSize] = useState(10);
const [pageIndex, setPageIndex] = useState(0);
const {
getCarUpdates,
carUpdates,
totalCarUpdates,
getPackages,
packages,
} = useUpdatesContext();
const { setMessage } = useStatusContext();
const {
token: {
idToken: { jwtToken: token },
},
} = useUserContext();
useEffect(() => {
try {
getPackages({ id: packageid }, token);
} catch (e) {
setMessage(e.message);
}
// eslint-disable-next-line
}, [token]);
useEffect(() => {
try {
getCarUpdates(
{
packageid,
limit: pageSize,
offset: pageSize * pageIndex,
},
token
);
} catch (e) {
setMessage(e.message);
}
// eslint-disable-next-line
}, [pageIndex, pageSize, token]);
const handleChangePageIndex = (event, newIndex) => {
setPageIndex(newIndex);
};
const handleChangePageSize = (event) => {
setPageSize(parseInt(event.target.value, 10));
setPageIndex(0);
};
return (
<div className={classes.paper} style={{ height: 700, width: "100%" }}>
<Typography component="h1" variant="h5">
{packages &&
packages.length > 0 &&
`${packages[0].package_name} ${packages[0].version}`}
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell align="center">ID</TableCell>
<TableCell align="center">Vehicle</TableCell>
<TableCell align="center">Status</TableCell>
<TableCell align="center">Created</TableCell>
<TableCell align="center">Updated</TableCell>
</TableRow>
</TableHead>
<TableBody>
{carUpdates.map((row) => (
<TableRow key={row.id}>
<TableCell align="center">{row.id}</TableCell>
<TableCell align="center">{`${row.car.vin} ${row.car.model} ${row.car.year}`}</TableCell>
<TableCell align="center">{row.status}</TableCell>
<TableCell align="center">
{LocalDateTimeString(row.created)}
</TableCell>
<TableCell align="center">
{LocalDateTimeString(row.updated)}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
colSpan={5}
count={totalCarUpdates}
rowsPerPage={pageSize}
page={pageIndex}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePageIndex}
onChangeRowsPerPage={handleChangePageSize}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
</div>
);
};
const CarUpdatesStatus = () => (
<UpdatesProvider>
<MainForm />
</UpdatesProvider>
);
export default CarUpdatesStatus;

View File

@@ -1,30 +1,38 @@
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 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 {
token: {
idToken: { jwtToken: token },
},
} = useUserContext();
const classes = useStyles();
const vinEl = useRef(null);
const modelEl = useRef(null);
const yearEl = useRef(null);
const onSubmit = async (event) => {
try {
event.preventDefault();
const {
idToken: { jwtToken: authToken },
} = token;
const formData = {
vin: vinEl.current.value,
model: modelEl.current.value,
year: parseInt(yearEl.current.value),
};
const result = await addVehicle(formData, authToken);
const result = await addVehicle(formData, token);
setMessage(`Added ${result.vin}`);
vinEl.current.value = "";
@@ -52,6 +60,36 @@ const MainForm = () => {
fullWidth
inputRef={vinEl}
/>
<TextField
id="model"
name="model"
label="Model"
defaultValue="Ocean"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "255",
}}
required
fullWidth
inputRef={modelEl}
/>
<TextField
id="year"
name="year"
label="Year"
type="number"
defaultValue="2022"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "4",
minLength: "4",
}}
required
fullWidth
inputRef={yearEl}
/>
<Button
type="submit"
disabled={busy}

View File

@@ -0,0 +1,121 @@
import React, { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableFooter,
TableHead,
TablePagination,
TableRow,
Typography,
} from "@material-ui/core";
import {
useVehicleContext,
VehicleProvider,
} from "../../Contexts/VehicleContext";
import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext";
import useStyles from "../../useStyles";
import { LocalDateTimeString } from "../../../utils/dates";
const MainForm = () => {
const classes = useStyles();
const [pageSize, setPageSize] = useState(5);
const [pageIndex, setPageIndex] = useState(0);
const { getVehicles, vehicles, totalVehicles } = useVehicleContext();
const { setMessage } = useStatusContext();
const {
token: {
idToken: { jwtToken: token },
},
} = useUserContext();
useEffect(() => {
try {
getVehicles(
{
limit: pageSize,
offset: pageSize * pageIndex,
},
token
);
} catch (e) {
setMessage(e.message);
}
// eslint-disable-next-line
}, [pageIndex, pageSize, token]);
const handleChangePageIndex = (event, newIndex) => {
setPageIndex(newIndex);
};
const handleChangePageSize = (event) => {
setPageSize(parseInt(event.target.value, 10));
setPageIndex(0);
};
return (
<div className={classes.paper} style={{ height: 700, width: "100%" }}>
<Typography component="h1" variant="h5">
Vehicles
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell align="center">ID</TableCell>
<TableCell align="center">VIN</TableCell>
<TableCell align="center">Model</TableCell>
<TableCell align="center">Year</TableCell>
<TableCell align="center">Created</TableCell>
<TableCell align="center">Updated</TableCell>
</TableRow>
</TableHead>
<TableBody>
{vehicles.map((row) => (
<TableRow key={row.id}>
<TableCell align="center">{row.id}</TableCell>
<TableCell align="center">{row.vin}</TableCell>
<TableCell align="center">{row.model}</TableCell>
<TableCell align="center">{row.year}</TableCell>
<TableCell align="center">
{LocalDateTimeString(row.created)}
</TableCell>
<TableCell align="center">
{LocalDateTimeString(row.updated)}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
colSpan={6}
count={totalVehicles}
rowsPerPage={pageSize}
page={pageIndex}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePageIndex}
onChangeRowsPerPage={handleChangePageSize}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
</div>
);
};
const VehiclesList = () => (
<VehicleProvider>
<MainForm />
</VehicleProvider>
);
export default VehiclesList;

View File

@@ -38,10 +38,6 @@ export const FileUploadProvider = ({ children }) => {
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");
}
@@ -64,7 +60,7 @@ export const FileUploadProvider = ({ children }) => {
setStatus(`Uploading ${filename}`);
setCancelUpload(getCancelToken());
const { data } = await uploadFile(
const data = await uploadFile(
file,
formData,
accessToken,

View File

@@ -33,7 +33,6 @@ describe("FileUploadContext", () => {
linkURL,
upload,
cancel,
setFiles,
} = useFileUploadContext();
const { message, setMessage } = useStatusContext();
const TEST_FILE = [{ name: "test.jpg", size: 0 }];

View File

@@ -0,0 +1,291 @@
jest.mock("../../services/updates");
import {
render,
cleanup,
screen,
fireEvent,
waitFor,
} from "@testing-library/react";
import { UpdatesProvider, useUpdatesContext } from "../Contexts/UpdatesContext";
import { StatusProvider, useStatusContext } from "../Contexts/StatusContext";
import { TEST_AUTH_OBJECT } from "../../utils/testing";
describe("UpdatesContext", () => {
describe("getPackages", () => {
const expectedData = `[{"id":1,"package_name":"Test","version":"1.0","link":"http://cloudfront.com/download"},{"id":2,"package_name":"Test","version":"1.1","link":"http://cloudfront.com/download"},{"id":3,"package_name":"Test","version":"1.2","link":"http://cloudfront.com/download"}]`;
const checkState = (busy, packages, message) => {
expect(screen.getByTestId("busy").innerHTML).toEqual(busy);
expect(screen.getByTestId("packages").innerHTML).toEqual(packages);
expect(screen.getByTestId("message").innerHTML).toEqual(message);
};
beforeEach(() => {
const TestComp = () => {
const { busy, packages, getPackages } = useUpdatesContext();
const { message, setMessage } = useStatusContext();
const exec = async (data, token) => {
try {
await getPackages(data, token);
} catch (e) {
setMessage(e.message);
}
};
return (
<>
<div data-testid="busy">{busy.toString()}</div>
<div data-testid="packages">{JSON.stringify(packages)}</div>
<div data-testid="message">{message}</div>
<button
data-testid="no-filter"
onClick={() => {
exec(null, TEST_AUTH_OBJECT);
}}
/>
<button
data-testid="with-filter"
onClick={() => {
exec({ package_name: "Test" }, TEST_AUTH_OBJECT);
}}
/>
</>
);
};
render(
<StatusProvider>
<UpdatesProvider>
<TestComp />
</UpdatesProvider>
</StatusProvider>
);
});
afterEach(() => {
cleanup();
});
it("Initial state", async () => {
checkState("false", "[]", "");
});
it("getPackages no filter", async () => {
fireEvent.click(screen.getByTestId("no-filter"));
await waitFor(() =>
expect(screen.getByTestId("busy").innerHTML).toBe("false")
);
checkState("false", expectedData, "");
});
it("getPackages with filter", async () => {
fireEvent.click(screen.getByTestId("with-filter"));
await waitFor(() =>
expect(screen.getByTestId("busy").innerHTML).toBe("false")
);
checkState("false", expectedData, "");
});
});
describe("updatePackage", () => {
let result = null;
const checkState = (busy, message, data) => {
expect(screen.getByTestId("busy").innerHTML).toEqual(busy);
expect(screen.getByTestId("message").innerHTML).toEqual(message);
expect(result).toEqual(data);
};
beforeEach(() => {
const TestComp = () => {
const { busy, updatePackage } = useUpdatesContext();
const { message, setMessage } = useStatusContext();
const exec = async (data, token) => {
var r = null;
try {
r = await updatePackage(data, token);
} catch (e) {
setMessage(e.message);
}
return r;
};
return (
<>
<div data-testid="busy">{busy.toString()}</div>
<div data-testid="message">{message}</div>
<button
data-testid="no-data"
onClick={async () => {
result = await exec(null, TEST_AUTH_OBJECT);
}}
/>
<button
data-testid="with-bad-data"
onClick={async () => {
result = await exec({ package_name: "Test" }, TEST_AUTH_OBJECT);
}}
/>
<button
data-testid="with-good-data"
onClick={async () => {
result = await exec(
{
package_name: "Test",
version: "1.0",
},
TEST_AUTH_OBJECT
);
}}
/>
</>
);
};
render(
<StatusProvider>
<UpdatesProvider>
<TestComp />
</UpdatesProvider>
</StatusProvider>
);
});
afterEach(() => {
cleanup();
result = null;
});
it("Initial state", () => {
checkState("false", "", null);
});
it("no-data", async () => {
fireEvent.click(screen.getByTestId("no-data"));
await waitFor(() =>
expect(screen.getByTestId("busy").innerHTML).toBe("false")
);
checkState("false", "No update data", null);
});
it("with-bad-data", async () => {
fireEvent.click(screen.getByTestId("with-bad-data"));
await waitFor(() =>
expect(screen.getByTestId("busy").innerHTML).toBe("false")
);
checkState("false", "Version required", null);
});
it("with-good-data", async () => {
fireEvent.click(screen.getByTestId("with-good-data"));
await waitFor(() =>
expect(screen.getByTestId("busy").innerHTML).toBe("false")
);
checkState("false", "", { package_name: "Test", version: "1.0" });
});
});
describe("createCarUpdates", () => {
let result = null;
const checkState = (busy, message, data) => {
expect(screen.getByTestId("busy").innerHTML).toEqual(busy);
expect(screen.getByTestId("message").innerHTML).toEqual(message);
expect(result).toEqual(data);
};
beforeEach(() => {
const TestComp = () => {
const { busy, createCarUpdates } = useUpdatesContext();
const { message, setMessage } = useStatusContext();
const exec = async (data, token) => {
var r = null;
try {
r = await createCarUpdates(data, token);
} catch (e) {
setMessage(e.message);
}
return r;
};
return (
<>
<div data-testid="busy">{busy.toString()}</div>
<div data-testid="message">{message}</div>
<button
data-testid="no-data"
onClick={async () => {
result = await exec(null, TEST_AUTH_OBJECT);
}}
/>
<button
data-testid="with-bad-data"
onClick={async () => {
result = await exec(
{ package_id: 1, car_ids: [] },
TEST_AUTH_OBJECT
);
}}
/>
<button
data-testid="with-good-data"
onClick={async () => {
result = await exec(
{
package_id: 1,
car_ids: [1, 2, 3],
},
TEST_AUTH_OBJECT
);
}}
/>
</>
);
};
render(
<StatusProvider>
<UpdatesProvider>
<TestComp />
</UpdatesProvider>
</StatusProvider>
);
});
afterEach(() => {
cleanup();
result = null;
});
it("Initial state", () => {
checkState("false", "", null);
});
it("no-data", async () => {
fireEvent.click(screen.getByTestId("no-data"));
await waitFor(() =>
expect(screen.getByTestId("busy").innerHTML).toBe("false")
);
checkState("false", "No car update data", null);
});
it("with-bad-data", async () => {
fireEvent.click(screen.getByTestId("with-bad-data"));
await waitFor(() =>
expect(screen.getByTestId("busy").innerHTML).toBe("false")
);
checkState("false", "Car ids required", null);
});
it("with-good-data", async () => {
fireEvent.click(screen.getByTestId("with-good-data"));
await waitFor(() =>
expect(screen.getByTestId("busy").innerHTML).toBe("false")
);
checkState("false", "", {
id: 1,
package_id: 1,
car_ids: [1, 2, 3],
});
});
});
});

View File

@@ -0,0 +1,131 @@
import React, { useContext, useState } from "react";
import api from "../../services/updates";
const UpdatesContext = React.createContext();
export const UpdatesProvider = ({ children }) => {
const [busy, setBusy] = useState(false);
const [packages, setPackages] = useState([]);
const [carUpdates, setCarUpdates] = useState([]);
const [totalPackages, setTotalPackages] = useState(0);
const [totalCarUpdates, setTotalCarUpdates] = useState(0);
const getPackages = async (search, token) => {
let result;
try {
setBusy(true);
result = await api.getPackages(search, token);
if (result.error)
throw new Error(`Get packages error. ${result.message}`);
setPackages(result.data);
if (search && search.offset === 0 && result.total) {
setTotalPackages(result.total);
}
} finally {
setBusy(false);
}
return result;
};
const updatePackage = async (data, token) => {
let result = null;
try {
setBusy(true);
validateUpdatePackage(data);
result = await api.updatePackage(data, token);
if (result.error)
throw new Error(`Update package error. ${result.message}`);
} finally {
setBusy(false);
}
return result;
};
const createCarUpdates = async (data, token) => {
let result;
try {
setBusy(true);
validateCreateCarUpdates(data);
result = await api.createCarUpdates(data, token);
if (result.error)
throw new Error(`Create car updates error. ${result.message}`);
} finally {
setBusy(false);
}
return result;
};
const getCarUpdates = async (search, token) => {
let result;
try {
setBusy(true);
result = await api.getCarUpdates(search, token);
if (result.error)
throw new Error(`Get packages error. ${result.message}`);
setCarUpdates(result.data);
if (search && search.offset === 0 && result.total) {
setTotalCarUpdates(result.total);
}
} finally {
setBusy(false);
}
return result;
};
return (
<UpdatesContext.Provider
value={{
busy,
packages,
totalPackages,
carUpdates,
totalCarUpdates,
getPackages,
updatePackage,
createCarUpdates,
getCarUpdates,
}}
>
{children}
</UpdatesContext.Provider>
);
};
export const useUpdatesContext = () => useContext(UpdatesContext);
const validateUpdatePackage = (data) => {
if (data === null) {
throw new Error("No update data");
}
if (!data.package_name) {
throw new Error("Package name required");
}
if (!data.version) {
throw new Error("Version required");
}
};
const validateCreateCarUpdates = (data) => {
if (data === null) {
throw new Error("No car update data");
}
if (!data.package_id || data.package_id === 0) {
throw new Error("Package id required");
}
if (!data.car_ids || data.car_ids.length === 0) {
throw new Error("Car ids required");
}
};

View File

@@ -1,21 +1,29 @@
import React, { useContext, useEffect, useState } from "react";
import auth from "../../services/auth";
import getTimerWorker from "../../services/timer";
import { parsePayload } from "../../utils/jwt";
import { getGroups } from "../../utils/roles";
const UserContext = React.createContext();
export const UserProvider = ({ children }) => {
const [fetching, setFetching] = useState(false);
const [token, setToken] = useState(null);
const [groups, setGroups] = useState(null);
const [error, setError] = useState(null);
let timer;
useEffect(() => {
if (!localStorage) return;
const t = JSON.parse(localStorage.getItem("token"));
if (!t || !t.idToken || !t.idToken.jwtToken) return;
if (!t.idToken.payload || !t.idToken.payload.exp) return;
setToken(t);
try {
if (!localStorage) return;
const t = JSON.parse(localStorage.getItem("token"));
if (!t) return;
if (!t.idToken || !t.idToken.jwtToken) throw new Error("Invalid token");
setToken(t);
} catch (e) {
document.location = signOut();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -33,21 +41,20 @@ export const UserProvider = ({ children }) => {
return result;
};
const isError = (resp) => {
if (resp === null) return true;
if (resp && resp.error) return true;
return false;
};
const startSessionTimer = () => {
const duration = 1000 * token.idToken.payload.exp - new Date().getTime();
if (!token || !token.idToken || !token.idToken.jwtToken) {
throw new Error("No id token");
}
const payload = parsePayload(token.idToken.jwtToken);
if (!payload || !payload.exp) throw new Error("Bad id token payload");
const duration = 1000 * payload.exp - new Date().getTime();
if (!timer) {
timer = getTimerWorker();
timer.onMessage(async (e) => {
if (e.data === "timeout") {
const t = await refreshTokens();
if (!isError(t)) return;
signOut();
if (t && !t.error) return;
document.location = signOut();
}
});
}
@@ -61,17 +68,16 @@ export const UserProvider = ({ children }) => {
} = token;
const result = await auth.verify(idToken);
if (!result && !result.valid) {
if (!result || !result.valid) {
const t = await refreshTokens();
if (!isError(t)) return;
signOut();
return;
if (!t || t.error) throw new Error("Unable to refresh token");
}
setGroups(getGroups(idToken));
startSessionTimer();
} catch (e) {
signOut();
setError(`Verify error. ${e.message}`);
document.location = signOut();
}
};
@@ -100,6 +106,7 @@ export const UserProvider = ({ children }) => {
};
const signOut = () => {
setGroups(null);
setToken(null);
if (localStorage) {
localStorage.removeItem("token");
@@ -146,6 +153,7 @@ export const UserProvider = ({ children }) => {
value={{
fetching,
token,
groups,
error,
setError,
signIn,

View File

@@ -11,15 +11,7 @@ import {
import { UserProvider, useUserContext } from "../Contexts/UserContext";
import auth from "../../services/auth";
import getTimerWorker from "../../services/timer";
const TEST_TOKEN = {
idToken: {
jwtToken: "TEST",
payload: {
exp: new Date().getTime() / 1000,
},
},
};
import { TEST_AUTH_OBJECT, TEST_EXPECTED_GROUPS } from "../../utils/testing";
const INVALID_TOKEN_RESPONSE = {
error: "Bad Request Error",
@@ -36,10 +28,11 @@ const setupSignInEnv = (refreshResponse, valid) => {
auth.setVerifyResponse({ valid });
};
const checkBaseResults = (error, fetching, token) => {
const checkBaseResults = (error, fetching, token, groups) => {
expect(screen.getByTestId("error").innerHTML).toEqual(error);
expect(screen.getByTestId("fetching").innerHTML).toEqual(fetching);
expect(screen.getByTestId("token").innerHTML).toEqual(token);
expect(screen.getByTestId("groups").innerHTML).toEqual(groups);
};
const checkTokenResults = (timer, token) => {
@@ -57,13 +50,14 @@ describe("UseContext", () => {
describe("Signin", () => {
beforeEach(() => {
const TestComp = () => {
const { signIn, error, token, fetching } = useUserContext();
const { signIn, error, token, groups, fetching } = useUserContext();
return (
<>
<div data-testid="error">{error}</div>
<div data-testid="fetching">{fetching.toString()}</div>
<div data-testid="token">{JSON.stringify(token)}</div>
<div data-testid="groups">{groups}</div>
<button data-testid="signInNoCode" onClick={() => signIn("")} />
<button
data-testid="signInInvalidCode"
@@ -85,13 +79,13 @@ describe("UseContext", () => {
});
it("Initial state", () => {
checkBaseResults("", "false", "null");
checkBaseResults("", "false", "null", "");
});
it("No auth code", () => {
fireEvent.click(screen.getByTestId("signInNoCode"));
checkBaseResults("", "false", "null");
checkBaseResults("", "false", "null", "");
});
it("Invalid auth code", async () => {
@@ -103,14 +97,19 @@ describe("UseContext", () => {
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("Sign in error. Bad Request Message", "false", "null");
checkBaseResults(
"Sign in error. Bad Request Message",
"false",
"null",
""
);
});
it("Sign in form", async () => {
const TOKEN_STRING = JSON.stringify(TEST_TOKEN);
const TOKEN_STRING = JSON.stringify(TEST_AUTH_OBJECT);
const timer = getTimerWorker();
setupSignInEnv(TEST_TOKEN, true);
setupSignInEnv(TEST_AUTH_OBJECT, true);
fireEvent.click(screen.getByTestId("signIn"));
@@ -118,7 +117,7 @@ describe("UseContext", () => {
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("", "false", TOKEN_STRING);
checkBaseResults("", "false", TOKEN_STRING, TEST_EXPECTED_GROUPS);
checkTokenResults(timer, TOKEN_STRING);
});
});
@@ -126,12 +125,20 @@ describe("UseContext", () => {
describe("Signout", () => {
beforeEach(async () => {
const TestComp = () => {
const { signIn, signOut, error, token, fetching } = useUserContext();
const {
signIn,
signOut,
error,
token,
groups,
fetching,
} = useUserContext();
return (
<>
<div data-testid="error">{error}</div>
<div data-testid="fetching">{fetching.toString()}</div>
<div data-testid="token">{JSON.stringify(token)}</div>
<div data-testid="groups">{groups}</div>
<button data-testid="signIn" onClick={() => signIn("TEST_CODE")} />
<button data-testid="signOut" onClick={() => signOut()} />
</>
@@ -142,7 +149,7 @@ describe("UseContext", () => {
<TestComp />
</UserProvider>
);
auth.setSignInResponse(TEST_TOKEN);
auth.setSignInResponse(TEST_AUTH_OBJECT);
fireEvent.click(screen.getByTestId("signIn"));
await waitFor(() =>
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
@@ -154,10 +161,9 @@ describe("UseContext", () => {
cleanup();
});
it("Token cleared", () => {
it("Token cleared", async () => {
fireEvent.click(screen.getByTestId("signOut"));
checkBaseResults("", "false", "null");
checkBaseResults("", "false", "null", "");
if (!localStorage) return;
expect(localStorage.getItem("token")).toBeNull();
});
@@ -166,13 +172,14 @@ describe("UseContext", () => {
describe("Refresh", () => {
beforeEach(() => {
const TestComp = () => {
const { refresh, error, token, fetching } = useUserContext();
const { refresh, error, token, groups, fetching } = useUserContext();
return (
<>
<div data-testid="error">{error}</div>
<div data-testid="fetching">{fetching.toString()}</div>
<div data-testid="token">{JSON.stringify(token)}</div>
<div data-testid="groups">{groups}</div>
<button data-testid="refreshNoToken" onClick={() => refresh("")} />
<button
data-testid="refreshInvalidToken"
@@ -197,12 +204,12 @@ describe("UseContext", () => {
});
it("Initial state", () => {
checkBaseResults("", "false", "null");
checkBaseResults("", "false", "null", "");
});
it("No refresh token", () => {
fireEvent.click(screen.getByTestId("refreshNoToken"));
checkBaseResults("Refresh error. Token required", "false", "null");
checkBaseResults("Refresh error. Token required", "false", "null", "");
});
it("Invalid refresh token", async () => {
@@ -213,21 +220,26 @@ describe("UseContext", () => {
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("Refresh error. Bad Request Message", "false", "null");
checkBaseResults(
"Refresh error. Bad Request Message",
"false",
"null",
""
);
});
it("Valid refresh token", async () => {
const TOKEN_STRING = JSON.stringify(TEST_TOKEN);
const TOKEN_STRING = JSON.stringify(TEST_AUTH_OBJECT);
const timer = getTimerWorker();
setupRefreshEnv(TEST_TOKEN, true);
setupRefreshEnv(TEST_AUTH_OBJECT, true);
fireEvent.click(screen.getByTestId("refreshValidToken"));
await waitFor(() =>
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("", "false", TOKEN_STRING);
checkBaseResults("", "false", TOKEN_STRING, TEST_EXPECTED_GROUPS);
checkTokenResults(timer, TOKEN_STRING);
});
});

View File

@@ -15,11 +15,20 @@ const validateAdd = (vehicle) => {
if (vehicle.vin.length > 17) {
throw new Error("VIN cannot be larger than 17 characters");
}
if (!vehicle.model || vehicle.model.length === 0) {
throw new Error("model required");
}
if (!vehicle.year || vehicle.year < 2000 || vehicle.year > 9999) {
throw new Error("year required");
}
};
export const VehicleProvider = ({ children }) => {
const [busy, setBusy] = useState(false);
const [vehicles, setVehicles] = useState([]);
const [totalVehicles, setTotalVehicles] = useState(0);
const getVehicles = async (search, token) => {
try {
@@ -30,6 +39,9 @@ export const VehicleProvider = ({ children }) => {
throw new Error(`Get vehicles error. ${result.message}`);
} else {
setVehicles(result.data);
if (result.total) {
setTotalVehicles(result.total);
}
}
} finally {
setBusy(false);
@@ -53,6 +65,7 @@ export const VehicleProvider = ({ children }) => {
value={{
busy,
vehicles,
totalVehicles,
getVehicles,
addVehicle,
}}

View File

@@ -83,7 +83,9 @@ describe("VehicleContext", () => {
<button data-testid="addVehiclesNoVIN" onClick={() => add({})} />
<button
data-testid="addVehicles"
onClick={() => add({ vin: "XXXXXXXXXXX" })}
onClick={() =>
add({ vin: "XXXXXXXXXXX", model: "Ocean", year: 3000 })
}
/>
</>
);

View File

@@ -1,11 +1,16 @@
import React from "react";
import { getGroups } from "../../../utils/roles";
let token = null;
let groups = null;
let fetching = false;
let error = null;
let signInResp = {};
let authorizeURL = "https://cognito.com/authorize?redirect=https://example.com/callback";
let logoutURL = "https://cognito.com/logout?redirect=https://example.com/callback";
let authorizeURL =
"https://cognito.com/authorize?redirect=https://example.com/callback";
let logoutURL =
"https://cognito.com/logout?redirect=https://example.com/callback";
export const UserProvider = ({ children }) => {
return <div data-testid="mocked-userprovider">{children}</div>;
@@ -15,6 +20,7 @@ export const useUserContext = () => ({
token,
fetching,
error,
groups,
signIn: jest.fn(() => signInResp),
signOut: jest.fn(),
getAuthorizeURL: jest.fn(() => authorizeURL),
@@ -26,6 +32,11 @@ export const useUserContext = () => ({
export const setToken = (val) => {
token = val;
if (!val || !val.idToken || !val.idToken.jwtToken) {
groups = null;
} else {
groups = getGroups(val.idToken.jwtToken);
}
};
export const setFetching = (val) => {

View File

@@ -1,287 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`File Upload Form Should render 1`] = `
<div>
<div
data-testid="mocked-vehicleprovider"
>
<div
data-testid="mocked-fileuploadprovider"
>
<div
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"
>
<input
accept=""
autocomplete="off"
style="display: none;"
tabindex="-1"
type="file"
/>
<div
class="MuiDropzoneArea-textContainer"
>
<p
class="MuiTypography-root MuiDropzoneArea-text MuiTypography-h5"
>
Drag and drop a file here or click
</p>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiDropzoneArea-icon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"
/>
</svg>
</div>
</div>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained makeStyles-submit-4 MuiButton-containedPrimary MuiButton-fullWidth"
tabindex="0"
type="submit"
>
<span
class="MuiButton-label"
>
Submit
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
</form>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,35 @@
import React from "react";
import { Typography } from "@material-ui/core";
import useStyles from "../useStyles";
import { useUserContext } from "../Contexts/UserContext";
import { parsePayload } from "../../utils/jwt";
const DEFAULT_GREETING = "Welcome";
const getGreeting = (token) => {
if (!token || !token.idToken || !token.idToken.jwtToken)
return DEFAULT_GREETING;
const payload = parsePayload(token.idToken.jwtToken);
if (!payload || !payload.given_name) return DEFAULT_GREETING;
return `Welcome ${payload.given_name}!`;
};
const Home = () => {
const classes = useStyles();
const { token } = useUserContext();
const greeting = getGreeting(token);
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
{greeting}
</Typography>
</div>
);
};
export default Home;

View File

@@ -20,7 +20,7 @@ export default function MenuDrawer({ children }) {
const classes = useStyles();
const theme = useTheme();
const { signOut, token } = useUserContext();
const [open, setOpen] = React.useState(false);
const [open, setOpen] = React.useState(true);
const handleDrawerOpen = () => {
setOpen(true);
@@ -30,6 +30,10 @@ export default function MenuDrawer({ children }) {
setOpen(false);
};
const onSignOut = () => {
document.location = signOut();
};
return (
<div className={classes.root}>
<AppBar
@@ -59,7 +63,7 @@ export default function MenuDrawer({ children }) {
{token !== null && (
<Button
color="inherit"
onClick={signOut}
onClick={onSignOut}
className={classes.rightToolbar}
>
Sign Out
@@ -96,7 +100,7 @@ export default function MenuDrawer({ children }) {
})}
>
<div className={classes.drawerHeader} />
<Container component="main" maxWidth="md">
<Container component="main" maxWidth="lg">
{children}
</Container>
</main>

View File

@@ -1,16 +1,50 @@
import React from "react";
import { List } from "@material-ui/core";
import ListItemLink from "../ListItemLink";
import { useUserContext } from "../Contexts/UserContext";
import { Roles, hasRole } from "../../utils/roles";
const menuData = [
{
label: "Home",
to: "/home",
roles: [],
},
{
label: "View Updates",
to: "/updates",
roles: [Roles.CREATE, Roles.READ],
},
{
label: "Create Updates",
to: "/package-upload",
roles: [Roles.CREATE],
},
{
label: "View Vehicles",
to: "/vehicles",
roles: [Roles.CREATE, Roles.READ],
},
{
label: "Add Vehicles",
to: "/vehicle-add",
roles: [Roles.CREATE],
},
];
export default function SideMenu() {
const menuData = [
{ label: "Upload Update Package", to: "/home" },
{ label: "Add Vehicles", to: "/vehicle-add" },
];
const { groups } = useUserContext();
const menu = menuData.reduce((result, item) => {
if (hasRole(item.roles, groups)) {
result.push(item);
}
return result;
}, []);
return (
<List>
{menuData.map((item, index) => (
{menu.map((item, index) => (
<ListItemLink key={index} primary={item.label} to={item.to} />
))}
</List>

View File

@@ -0,0 +1,33 @@
jest.mock("../Contexts/UserContext");
import { render, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { UserProvider, setToken } from "../Contexts/UserContext";
import { TEST_AUTH_OBJECT } from "../../utils/testing";
import SideMenu from "./SideMenu";
const renderMenu = async () => {
const { container } = render(
<UserProvider>
<BrowserRouter>
<SideMenu />
</BrowserRouter>
</UserProvider>
);
await waitFor(() => {});
return container;
};
describe("SideMenu", () => {
it("Unauthenticated", async () => {
setToken(null);
const container = await renderMenu(null);
expect(container).toMatchSnapshot();
});
it("Authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
const container = await renderMenu(TEST_AUTH_OBJECT);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,159 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SideMenu Authenticated 1`] = `
<div>
<div
data-testid="mocked-userprovider"
>
<ul
class="MuiList-root MuiList-padding"
>
<li>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiListItem-root MuiListItem-gutters MuiListItem-button"
href="/home"
role="button"
tabindex="0"
>
<div
class="MuiListItemText-root"
>
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
Home
</span>
</div>
<span
class="MuiTouchRipple-root"
/>
</a>
</li>
<li>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiListItem-root MuiListItem-gutters MuiListItem-button"
href="/updates"
role="button"
tabindex="0"
>
<div
class="MuiListItemText-root"
>
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
View Updates
</span>
</div>
<span
class="MuiTouchRipple-root"
/>
</a>
</li>
<li>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiListItem-root MuiListItem-gutters MuiListItem-button"
href="/package-upload"
role="button"
tabindex="0"
>
<div
class="MuiListItemText-root"
>
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
Create Updates
</span>
</div>
<span
class="MuiTouchRipple-root"
/>
</a>
</li>
<li>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiListItem-root MuiListItem-gutters MuiListItem-button"
href="/vehicles"
role="button"
tabindex="0"
>
<div
class="MuiListItemText-root"
>
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
View Vehicles
</span>
</div>
<span
class="MuiTouchRipple-root"
/>
</a>
</li>
<li>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiListItem-root MuiListItem-gutters MuiListItem-button"
href="/vehicle-add"
role="button"
tabindex="0"
>
<div
class="MuiListItemText-root"
>
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
Add Vehicles
</span>
</div>
<span
class="MuiTouchRipple-root"
/>
</a>
</li>
</ul>
</div>
</div>
`;
exports[`SideMenu Unauthenticated 1`] = `
<div>
<div
data-testid="mocked-userprovider"
>
<ul
class="MuiList-root MuiList-padding"
>
<li>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiListItem-root MuiListItem-gutters MuiListItem-button"
href="/home"
role="button"
tabindex="0"
>
<div
class="MuiListItemText-root"
>
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
Home
</span>
</div>
<span
class="MuiTouchRipple-root"
/>
</a>
</li>
</ul>
</div>
</div>
`;

View File

@@ -1,5 +1,6 @@
import React from "react";
import { Redirect, Route } from "react-router-dom";
import { hasRole } from "../../utils/roles";
export const TYPES = {
PUBLIC: 0,
@@ -7,11 +8,19 @@ export const TYPES = {
PROTECTED: 2,
};
export const AuthRoute = ({ token, type, ...others }) => {
if (!token && type === TYPES.PROTECTED) {
export const AuthRoute = ({ token, type, roles, groups, ...others }) => {
if (type === TYPES.PROTECTED && !token) {
return <Redirect to="/" />;
} else if (token && type === TYPES.GUEST) {
} else if (type === TYPES.GUEST && token) {
return <Redirect to="/home" />;
} else if (
type === TYPES.PROTECTED &&
token &&
roles &&
!hasRole(roles, groups)
) {
return <Redirect to="/home" />;
}
return <Route render {...others} />;
};

View File

@@ -4,14 +4,21 @@ import { Switch } from "react-router-dom";
import { AuthRoute, TYPES } from "../Routes/AuthRoute";
import { MessageBar } from "../MessageBar";
import { useUserContext } from "../Contexts/UserContext";
import { Roles } from "../../utils/roles";
const SSOForm = React.lazy(() => import("../SSOForm"));
const FileUploadForm = React.lazy(() => import("../FileUploadForm"));
const VehicleAddForm = React.lazy(() => import("../VehicleAddForm"));
const Home = React.lazy(() => import("../Home"));
const FileUploadForm = React.lazy(() => import("../UpdatePackages/Create"));
const VehicleAddForm = React.lazy(() => import("../Cars/Add"));
const PageNotFound = React.lazy(() => import("../404"));
const UpdatePackagesForm = React.lazy(() => import("../UpdatePackages/List"));
const UpdatePackageEdit = React.lazy(() => import("../UpdatePackages/Edit"));
const CarUpdatesDeploy = React.lazy(() => import("../CarUpdates/Deploy"));
const CarUpdatesStatus = React.lazy(() => import("../CarUpdates/Status"));
const VehiclesList = React.lazy(() => import("../Cars/List"));
const SiteRoutes = () => {
const { token } = useUserContext();
const { token, groups } = useUserContext();
return (
<Suspense fallback={"Loading..."}>
<MessageBar />
@@ -25,15 +32,65 @@ const SiteRoutes = () => {
/>
<AuthRoute
path="/home"
render={() => <Home />}
type={TYPES.PROTECTED}
token={token}
/>
<AuthRoute
path="/package-upload"
render={() => <FileUploadForm />}
type={TYPES.PROTECTED}
token={token}
groups={groups}
roles={[Roles.CREATE]}
/>
<AuthRoute
path="/updates"
render={() => <UpdatePackagesForm />}
type={TYPES.PROTECTED}
token={token}
groups={groups}
roles={[Roles.CREATE]}
/>
<AuthRoute
path="/update/:id"
render={() => <UpdatePackageEdit />}
type={TYPES.PROTECTED}
token={token}
groups={groups}
roles={[Roles.CREATE]}
/>
<AuthRoute
path="/carupdate-deploy/:packageid"
render={() => <CarUpdatesDeploy />}
type={TYPES.PROTECTED}
token={token}
groups={groups}
roles={[Roles.CREATE]}
/>
<AuthRoute
path="/carupdate-status/:packageid"
render={() => <CarUpdatesStatus />}
type={TYPES.PROTECTED}
token={token}
groups={groups}
roles={[Roles.READ, Roles.CREATE]}
/>
<AuthRoute
path="/vehicles"
render={() => <VehiclesList />}
type={TYPES.PROTECTED}
token={token}
groups={groups}
roles={[Roles.READ, Roles.CREATE]}
/>
<AuthRoute
path="/vehicle-add"
render={() => <VehicleAddForm />}
type={TYPES.PROTECTED}
token={token}
groups={groups}
roles={[Roles.CREATE]}
/>
<PageNotFound />
</Switch>

View File

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

View File

@@ -0,0 +1,237 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`File Upload Form Should render 1`] = `
<div>
<div
data-testid="mocked-fileuploadprovider"
>
<div
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="MuiDropzoneArea-root"
tabindex="0"
>
<input
accept=""
autocomplete="off"
style="display: none;"
tabindex="-1"
type="file"
/>
<div
class="MuiDropzoneArea-textContainer"
>
<p
class="MuiTypography-root MuiDropzoneArea-text MuiTypography-h5"
>
Drag and drop a file here or click
</p>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiDropzoneArea-icon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"
/>
</svg>
</div>
</div>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained makeStyles-submit-4 MuiButton-containedPrimary MuiButton-fullWidth"
tabindex="0"
type="submit"
>
<span
class="MuiButton-label"
>
Submit
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
</form>
</div>
</div>
</div>
`;

View File

@@ -1,27 +1,14 @@
import React, { useEffect, useRef, useState } from "react";
import {
Button,
Chip,
FormControl,
Input,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
useTheme,
} from "@material-ui/core";
import React, { useRef } from "react";
import { Button, TextField, Typography } from "@material-ui/core";
import { DropzoneArea } from "material-ui-dropzone";
import { useUserContext } from "../Contexts/UserContext";
import { useStatusContext } from "../Contexts/StatusContext";
import { useVehicleContext, VehicleProvider } from "../Contexts/VehicleContext";
import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext";
import {
useFileUploadContext,
FileUploadProvider,
} from "../Contexts/FileUploadContext";
import ModalProgressBar from "../ModalProgressBar";
import useStyles from "../useStyles";
import menuItemStyle from "../menuItemStyle";
} from "../../Contexts/FileUploadContext";
import ModalProgressBar from "../../ModalProgressBar";
import useStyles from "../../useStyles";
const FileUploadZone = ({ classes, token }) => {
const { setFiles } = useFileUploadContext();
@@ -54,18 +41,12 @@ const FileUploadZone = ({ classes, token }) => {
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();
@@ -77,7 +58,6 @@ const MainForm = () => {
version: versionEl.current.value,
description: descEl.current.value,
releasenotes: releasenotesEl.current.value,
vehicles: selectedVehicles,
};
await upload(formData, authToken, files);
@@ -86,20 +66,6 @@ const MainForm = () => {
}
};
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">
@@ -160,41 +126,6 @@ const MainForm = () => {
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"
@@ -214,10 +145,8 @@ const MainForm = () => {
export default function FileUploadForm() {
return (
<VehicleProvider>
<FileUploadProvider>
<MainForm />
</FileUploadProvider>
</VehicleProvider>
<FileUploadProvider>
<MainForm />
</FileUploadProvider>
);
}

View File

@@ -0,0 +1,198 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router";
import { Button, TextField, Typography } from "@material-ui/core";
import {
UpdatesProvider,
useUpdatesContext,
} from "../../Contexts/UpdatesContext";
import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext";
import useStyles from "../../useStyles";
import { tsLocalDateTimeString } from "../../../utils/dates";
const MainForm = () => {
const { id } = useParams();
const { getPackages, updatePackage, packages, busy } = useUpdatesContext();
const {
token: {
idToken: { jwtToken: token },
},
} = useUserContext();
const { setMessage } = useStatusContext();
const [packageName, setPackageName] = useState("");
const [version, setVersion] = useState("");
const [link, setLink] = useState("");
const [description, setDescription] = useState("");
const [releaseNotesLink, setReleaseNotesLink] = useState("");
const [createDate, setCreateDate] = useState("");
const classes = useStyles();
const onSubmit = async (event) => {
try {
event.preventDefault();
const data = {
id: parseInt(id),
package_name: packageName,
version,
link,
desc: description,
release_notes: releaseNotesLink,
};
await updatePackage(data, token);
setMessage(`Updated ${packageName} ${version}`);
} catch (e) {
setMessage(e.message);
}
};
const getData = async () => {
try {
getPackages({ id: parseInt(id) }, token);
} catch (e) {
setMessage(e.message);
}
};
const handleChange = (event) => {
const field = event.target.id;
const value = event.target.value;
if (field === "packagename") {
setPackageName(value);
} else if (field === "version") {
setVersion(value);
} else if (field === "link") {
setLink(value);
} else if (field === "description") {
setDescription(value);
} else if (field === "releasenotes") {
setReleaseNotesLink(value);
}
};
useEffect(() => {
getData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
if (!packages || packages.length === 0) return;
var data = packages[0];
setPackageName(data.package_name);
setVersion(data.version);
setLink(data.link);
setDescription(data.desc || "");
setReleaseNotesLink(data.release_notes || "");
setCreateDate(tsLocalDateTimeString(data.timestamp));
}, [packages]);
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Update Package {id}
</Typography>
<form className={classes.form} noValidate action="{onSubmit}">
<TextField
label="Create Date"
variant="filled"
margin="normal"
inputProps={{
readOnly: true,
maxLength: "1024",
}}
value={createDate}
fullWidth
/>
<TextField
id="packagename"
name="packagename"
label="Package name"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "255",
}}
value={packageName}
onChange={handleChange}
required
fullWidth
/>
<TextField
id="version"
name="version"
label="Version"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "255",
}}
value={version}
onChange={handleChange}
required
fullWidth
/>
<TextField
id="link"
name="link"
label="Package link"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "1024",
}}
value={link}
onChange={handleChange}
required
fullWidth
/>
<TextField
id="description"
name="description"
label="Description"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "5120",
}}
value={description}
onChange={handleChange}
fullWidth
multiline
rows={4}
placeholder="Package description"
/>
<TextField
id="releasenotes"
name="releasenotes"
label="Release Notes URL"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "1024",
}}
value={releaseNotesLink}
onChange={handleChange}
fullWidth
placeholder="Release Notes URL"
/>
<Button
type="submit"
disabled={busy}
fullWidth
variant="contained"
color="primary"
className={classes.submit}
onClick={onSubmit}
>
{busy ? "Updating..." : "Submit"}
</Button>
</form>
</div>
);
};
const UpdatePackageEditForm = () => (
<UpdatesProvider>
<MainForm />
</UpdatesProvider>
);
export default UpdatePackageEditForm;

View File

@@ -0,0 +1,156 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableFooter,
TableHead,
TablePagination,
TableRow,
Tooltip,
Typography,
} from "@material-ui/core";
import EditIcon from "@material-ui/icons/Edit";
import SendIcon from "@material-ui/icons/Send";
import VisibilityIcon from "@material-ui/icons/Visibility";
import useStyles from "../../useStyles";
import {
UpdatesProvider,
useUpdatesContext,
} from "../../Contexts/UpdatesContext";
import { useUserContext } from "../../Contexts/UserContext";
import { tsLocalDateTimeString } from "../../../utils/dates";
import { Roles, hasRole } from "../../../utils/roles";
const UpdatePackagesList = () => {
const classes = useStyles();
const [pageSize, setPageSize] = useState(5);
const [pageIndex, setPageIndex] = useState(0);
const { getPackages, packages, totalPackages } = useUpdatesContext();
const {
token: {
idToken: { jwtToken: token },
},
groups,
} = useUserContext();
useEffect(() => {
getPackages({ limit: pageSize, offset: pageSize * pageIndex }, token);
// eslint-disable-next-line
}, [pageIndex, pageSize, token]);
const handleChangePageIndex = (event, newIndex) => {
setPageIndex(newIndex);
};
const handleChangePageSize = (event) => {
setPageSize(parseInt(event.target.value, 10));
setPageIndex(0);
};
const Actions = (row) => {
let actions = [];
if (hasRole([Roles.CREATE, Roles.READ], groups)) {
actions.push({
tip: `Status "${row.package_name} ${row.version}"`,
link: `/carupdate-status/${row.id}`,
icon: (
<VisibilityIcon
aria-label={`Status ${row.package_name} ${row.version}`}
/>
),
});
}
if (hasRole([Roles.CREATE], groups)) {
actions = actions.concat([
{
tip: `Edit "${row.package_name} ${row.version}"`,
link: `/update/${row.id}`,
icon: (
<EditIcon aria-label={`Edit ${row.package_name} ${row.version}`} />
),
},
{
tip: `Deploy "${row.package_name} ${row.version}"`,
link: `/carupdate-deploy/${row.id}`,
icon: (
<SendIcon
aria-label={`Deploy ${row.package_name} ${row.version}`}
/>
),
},
]);
}
if (actions.length === 0) return "No actions";
return actions.map((action) => (
<Tooltip key={action.link} title={action.tip}>
<Link to={action.link} style={{ margin: 5 }}>
{action.icon}
</Link>
</Tooltip>
));
};
return (
<div className={classes.paper} style={{ height: 700, width: "100%" }}>
<Typography component="h1" variant="h5">
Updates
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell align="center">ID</TableCell>
<TableCell align="center">Name</TableCell>
<TableCell align="center">Version</TableCell>
<TableCell align="center">Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{packages.map((row) => (
<TableRow key={row.id}>
<TableCell align="center">{row.id}</TableCell>
<TableCell align="center">{row.package_name}</TableCell>
<TableCell align="center">{row.version}</TableCell>
<TableCell align="center">
{tsLocalDateTimeString(row.timestamp)}
</TableCell>
<TableCell align="right">{Actions(row)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
colSpan={5}
count={totalPackages}
rowsPerPage={pageSize}
page={pageIndex}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePageIndex}
onChangeRowsPerPage={handleChangePageSize}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
</div>
);
};
const UpdatePackagesForm = () => (
<UpdatesProvider>
<UpdatePackagesList />
</UpdatesProvider>
);
export default UpdatePackagesForm;

View File

@@ -24,7 +24,7 @@ const useStyles = makeStyles((theme) => ({
textAlign: "center",
},
formControl: {
margin: theme.spacing(1),
margin: theme.spacing(3, 0, 2),
width: "100%",
minWidth: 120,
},