CEC-244 Remote car commands, search, sortable tables (#42)

* Add sortable table header

* Send bulk commands page
Update table page sizes
All tables are sortable

* Update site layout
Add search to update packages

* Reenable Datadog

* remove dev stuff
This commit is contained in:
John Wu
2021-05-26 15:46:46 -07:00
committed by GitHub
parent 64995ef7a6
commit 931e1521e8
29 changed files with 1886 additions and 1541 deletions

View File

@@ -99,37 +99,37 @@ describe("App", () => {
it("Route /package-upload authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/package-upload", "h1", "Create Update Package");
await check("/package-upload", "h6", "Create Update Package");
});
it("Route /vehicle-add authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/vehicle-add", "h1", "Add Vehicle");
await check("/vehicle-add", "h6", "Add Vehicle");
});
it("Route /updates authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/updates", "h1", "Update Packages");
await check("/updates", "h6", "Deploy Packages");
});
it("Route /update authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/update/1", "h1", "Edit Update Package 1");
await check("/update/1", "h6", "Edit Update Package 1");
});
it("Route /carupdate-status authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/carupdate-status/1", "h1", "");
await check("/carupdate-status/1", "h6", "");
});
it("Route /vehicles authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/vehicles", "h1", "Vehicles");
await check("/vehicles", "h6", "Vehicles");
});
it("Route /vehicle-status authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/vehicle-status/FISKER123", "h1", "FISKER123 Updates");
await check("/vehicle-status/FISKER123", "h6", "Vehicle FISKER123 Details");
});
it("Route /page-not-found unauthenticated", async () => {
@@ -143,6 +143,6 @@ describe("App", () => {
it("Route /carupdate-deploy authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/carupdate-deploy/1", "h1", "Deploy [1]");
await check("/carupdate-deploy/1", "h6", "Deploy ");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState } from "react";
import { useParams, Redirect } from "react-router";
import { Button, TextField, Typography } from "@material-ui/core";
import { Button, Typography } from "@material-ui/core";
import {
UpdatesProvider,
useUpdatesContext,
@@ -20,12 +19,10 @@ const MainForm = () => {
idToken: { jwtToken: token },
},
} = useUserContext();
const { setMessage } = useStatusContext();
const { setMessage, setTitle } = 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 [redirect, setRedirect] = useState("");
@@ -60,15 +57,18 @@ const MainForm = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
setTitle(`Deploy ${packageName} ${version}`);
// eslint-disable-next-line
}, [packageName, version]);
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]);
@@ -78,64 +78,11 @@ const MainForm = () => {
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Deploy {`${packageName} ${version} [${packageid}]`}
</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"
/>
<Typography variant="body2">
Created {createDate}. {description}
</Typography>
<hr style={{ marginBottom: 30, marginTop: 30 }} />
<CarSelection onSelection={setSelectedVehicles} />
<Button
type="submit"

View File

@@ -10,7 +10,6 @@ import {
TableHead,
TablePagination,
TableRow,
Typography,
} from "@material-ui/core";
import {
@@ -38,7 +37,7 @@ const MainForm = () => {
startMonitor,
stopMonitor,
} = useUpdatesContext();
const { setMessage } = useStatusContext();
const { setMessage, setTitle } = useStatusContext();
const {
token: {
idToken: { jwtToken: token },
@@ -54,6 +53,13 @@ const MainForm = () => {
// eslint-disable-next-line
}, [token]);
useEffect(() => {
if (!packages || packages.length === 0) return;
setTitle(`Package ${packages[0].package_name} ${packages[0].version}`);
// eslint-disable-next-line
}, [packages]);
useEffect(() => {
try {
stopMonitor();
@@ -104,11 +110,6 @@ const MainForm = () => {
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>
@@ -150,7 +151,7 @@ const MainForm = () => {
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
rowsPerPageOptions={[5, 10, 25, 100]}
colSpan={5}
count={totalCarUpdates}
rowsPerPage={pageSize}

View File

@@ -1,4 +1,4 @@
import React, { useRef } from "react";
import React, { useEffect, useRef } from "react";
import useStyles from "../../useStyles";
import {
@@ -7,11 +7,11 @@ import {
} from "../../Contexts/VehicleContext";
import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext";
import { Button, TextField, Typography } from "@material-ui/core";
import { Button, TextField } from "@material-ui/core";
const MainForm = () => {
const { addVehicle, busy } = useVehicleContext();
const { setMessage } = useStatusContext();
const { setMessage, setTitle } = useStatusContext();
const {
token: {
idToken: { jwtToken: token },
@@ -22,6 +22,10 @@ const MainForm = () => {
const modelEl = useRef(null);
const yearEl = useRef(null);
useEffect(() => {
setTitle("Add Vehicle");
// eslint-disable-next-line
}, []);
const onSubmit = async (event) => {
try {
event.preventDefault();
@@ -43,9 +47,6 @@ const MainForm = () => {
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Add Vehicle
</Typography>
<form className={classes.form} noValidate action="{onSubmit}">
<TextField
id="vin"

View File

@@ -6,10 +6,9 @@ import {
TableCell,
TableContainer,
TableFooter,
TableHead,
TablePagination,
TableRow,
Typography,
Toolbar,
} from "@material-ui/core";
import {
@@ -20,25 +19,77 @@ import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext";
import useStyles from "../../useStyles";
import { LocalDateTimeString } from "../../../utils/dates";
import TableHeaderSortable from "../../Table/HeaderSortable";
import SearchField from "../../Controls/SearchField";
const tableColumns = [
{
id: "vin",
label: "VIN",
},
{
id: "model",
label: "Model",
},
{
id: "year",
label: "Year",
},
{
id: "trim",
label: "Trim",
},
{
id: "created_at",
label: "Created",
},
{
id: "updated_at",
label: "Updated",
},
];
const MainForm = () => {
const classes = useStyles();
const [pageSize, setPageSize] = useState(25);
const [pageIndex, setPageIndex] = useState(0);
const [orderBy, setOrderBy] = useState("vin");
const [order, setOrder] = useState("asc");
const [search, setSearch] = useState("");
const { getVehicles, vehicles, totalVehicles } = useVehicleContext();
const { setMessage } = useStatusContext();
const { setMessage, setTitle } = useStatusContext();
const {
token: {
idToken: { jwtToken: token },
},
} = useUserContext();
const sortHandler = (event, property) => {
if (property === orderBy) {
if (order === "asc") {
setOrder("desc");
} else {
setOrder("asc");
}
} else {
setOrderBy(property);
setOrder("asc");
}
};
useEffect(() => {
setTitle("Vehicles");
// eslint-disable-next-line
}, []);
useEffect(() => {
try {
getVehicles(
{
limit: pageSize,
offset: pageSize * pageIndex,
order: `${orderBy} ${order}`,
search,
},
token
);
@@ -46,7 +97,7 @@ const MainForm = () => {
setMessage(e.message);
}
// eslint-disable-next-line
}, [pageIndex, pageSize, token]);
}, [pageIndex, pageSize, token, orderBy, order, search]);
const handleChangePageIndex = (event, newIndex) => {
setPageIndex(newIndex);
@@ -57,30 +108,33 @@ const MainForm = () => {
setPageIndex(0);
};
const handleSearch = (search) => {
setSearch(search);
};
return (
<div className={classes.paper} style={{ height: 700, width: "100%" }}>
<Typography component="h1" variant="h5">
Vehicles
</Typography>
<Toolbar className={classes.tableToolbar}>
<SearchField classes={classes} onSearch={handleSearch} />
</Toolbar>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<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>
<TableHeaderSortable
classes={classes}
orderBy={orderBy}
order={order}
columnData={tableColumns}
onSortRequest={sortHandler}
/>
<TableBody>
{vehicles.map((row) => (
<TableRow key={row.vin}>
<TableCell align="center">
<TableCell align="center" sortDirection={true}>
<Link to={`/vehicle-status/${row.vin}`}>{row.vin}</Link>
</TableCell>
<TableCell align="center">{row.model}</TableCell>
<TableCell align="center">{row.year}</TableCell>
<TableCell align="center">{row.trim || ""}</TableCell>
<TableCell align="center">
{LocalDateTimeString(row.created)}
</TableCell>
@@ -93,7 +147,7 @@ const MainForm = () => {
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
rowsPerPageOptions={[5, 10, 25, 100]}
colSpan={6}
count={totalVehicles}
rowsPerPage={pageSize}

View File

@@ -1,123 +0,0 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import {
FormControlLabel,
Grid,
IconButton,
TextField,
Switch,
} from "@material-ui/core";
import SendIcon from "@material-ui/icons/Send";
import { useVehicleContext } from "../../Contexts/VehicleContext";
import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext";
const LogFilter = ({ vin }) => {
const { sendLogFilter, busy } = useVehicleContext();
const {
token: {
idToken: { jwtToken: token },
},
} = useUserContext();
const { setMessage } = useStatusContext();
const [filter, setFilter] = useState("");
const [enableLog, setEnableLog] = useState(true);
const [freq, setFreq] = useState(0);
const changeFilterHandler = (e) => {
setFilter(e.target.value);
};
const changeStartHandler = (e) => {
setEnableLog(e.target.checked);
};
const changeFreqHandler = (e) => {
setFreq(e.target.value);
};
const clickHandler = async (e) => {
try {
const data = {
enable: enableLog,
frequency: freq,
filter,
};
await sendLogFilter(vin, data, token);
setMessage(`Sent log command to ${vin}`);
} catch (e) {
setMessage(e.message);
}
};
return (
<Grid
container
direction="row"
justify="flex-start"
alignItems="center"
spacing={2}
>
<Grid item>
<FormControlLabel
control={
<Switch
checked={enableLog}
onChange={changeStartHandler}
name="logStart"
color="primary"
/>
}
labelPlacement="top"
label="Logging"
/>
</Grid>
<Grid item>
<TextField
id="log-filter"
name="log-filter"
label="Filter"
variant="outlined"
margin="normal"
inputProps={{
maxLength: "17",
}}
disabled={!enableLog}
value={filter}
onChange={changeFilterHandler}
/>
</Grid>
<Grid item>
<TextField
id="log-frequency"
label="Frequency"
type="number"
disabled={!enableLog}
InputLabelProps={{
shrink: true,
}}
value={freq}
onChange={changeFreqHandler}
/>
</Grid>
<Grid item>
<IconButton
color="primary"
aria-label="send log command"
component="span"
onClick={clickHandler}
disabled={busy}
>
<SendIcon fontSize="large" />
</IconButton>
</Grid>
</Grid>
);
};
LogFilter.propTypes = {
vin: PropTypes.string.isRequired,
};
export default LogFilter;

View File

@@ -9,7 +9,7 @@ import useStyles from "../../useStyles";
import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext";
const SendCommand = ({ vin }) => {
const SendCommand = ({ vins }) => {
const classes = useStyles();
const { sendCommand, busy } = useVehicleContext();
const {
@@ -17,25 +17,59 @@ const SendCommand = ({ vin }) => {
idToken: { jwtToken: token },
},
} = useUserContext();
const NoParameters = {
value: "",
label: "None",
};
const { setMessage } = useStatusContext();
const [command, setCommand] = useState("");
const [parameters, setParameters] = useState([NoParameters]);
const [parameter, setParameter] = useState("");
const changeCommandHandler = (e) => {
selectCommand(e.target.value);
};
const changeHandler = (e) => {
setCommand(e.target.value);
const selectCommand = (cmd) => {
const params = getParameters(cmd);
setCommand(cmd);
setParameters(params);
setParameter(params[0].value);
};
const changeParametersHandler = (e) => {
setParameter(e.target.value);
};
const clickHandler = async (e) => {
try {
await sendCommand(vin, command, token);
setMessage(`Sent command to ${vin}`);
await sendCommand(vins, command, parameter, token);
if (vins.length === 1) {
setMessage(`Sent command to ${vins[0]}`);
} else {
setMessage(`Sent command to ${vins.length} cars`);
}
} catch (e) {
setMessage(e.message);
}
};
const getParameters = (command) => {
for (let i = 0, len = commands.length; i < len; i += 1) {
const item = commands[i];
if (item.value === command) {
if (!item.parameters) {
break;
}
return item.parameters;
}
}
return [NoParameters];
};
useEffect(() => {
if (!commands || commands.length === 0) return;
setCommand(commands[0].value);
selectCommand(commands[0].value);
// eslint-disable-next-line
}, []);
return (
@@ -52,7 +86,7 @@ const SendCommand = ({ vin }) => {
name: "send-command",
id: "send-command",
}}
onChange={changeHandler}
onChange={changeCommandHandler}
>
{commands.map((item) => (
<option key={item.value} value={item.value}>
@@ -61,12 +95,37 @@ const SendCommand = ({ vin }) => {
))}
</Select>
</FormControl>
<FormControl className={classes.formControlInline} variant="outlined">
<InputLabel
htmlFor="send-parameter"
style={{ backgroundColor: "White" }}
>
Parameter
</InputLabel>
<Select
native
value={parameter}
variant="outlined"
inputProps={{
name: "send-parameter",
id: "send-parameter",
}}
onChange={changeParametersHandler}
disabled={parameters.length === 0}
>
{parameters.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</FormControl>
<IconButton
color="primary"
aria-label="send command"
component="span"
onClick={clickHandler}
disabled={busy}
disabled={busy || vins.length === 0}
>
<SendIcon fontSize="large" />
</IconButton>
@@ -75,7 +134,7 @@ const SendCommand = ({ vin }) => {
};
SendCommand.propTypes = {
vin: PropTypes.string.isRequired,
vins: PropTypes.array.isRequired,
};
export default SendCommand;

View File

@@ -0,0 +1,203 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
Checkbox,
Table,
TableBody,
TableCell,
TableContainer,
TableFooter,
TablePagination,
TableRow,
} 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";
import TableHeaderSortable from "../../Table/HeaderSortable";
import SendCommand from "../SendCommand";
const tableColumns = [
{
id: "vin",
label: "VIN",
},
{
id: "model",
label: "Model",
},
{
id: "year",
label: "Year",
},
{
id: "trim",
label: "Trim",
},
{
id: "created_at",
label: "Created",
},
{
id: "updated_at",
label: "Updated",
},
];
const MainForm = () => {
const classes = useStyles();
const [pageSize, setPageSize] = useState(25);
const [pageIndex, setPageIndex] = useState(0);
const [orderBy, setOrderBy] = useState("vin");
const [order, setOrder] = useState("asc");
const [selected, setSelected] = useState([]);
const { getVehicles, vehicles, totalVehicles } = useVehicleContext();
const { setMessage, setTitle } = useStatusContext();
const {
token: {
idToken: { jwtToken: token },
},
} = useUserContext();
const sortHandler = (event, property) => {
if (property === orderBy) {
if (order === "asc") {
setOrder("desc");
} else {
setOrder("asc");
}
} else {
setOrderBy(property);
setOrder("asc");
}
};
useEffect(() => {
setTitle("Send Command");
// eslint-disable-next-line
}, []);
useEffect(() => {
try {
getVehicles(
{
limit: pageSize,
offset: pageSize * pageIndex,
order: `${orderBy} ${order}`,
},
token
);
} catch (e) {
setMessage(e.message);
}
// eslint-disable-next-line
}, [pageIndex, pageSize, token, orderBy, order]);
const handleChangePageIndex = (event, newIndex) => {
setPageIndex(newIndex);
};
const handleChangePageSize = (event) => {
setPageSize(parseInt(event.target.value, 10));
setPageIndex(0);
};
const handleSelectAll = (event) => {
const newSelected = [];
if (event.target.checked) {
vehicles.forEach((car) => {
newSelected.push(car.vin);
});
}
setSelected(newSelected);
};
const handleSelect = (event, key) => {
let newSelected;
if (event.target.checked) {
newSelected = [...selected];
newSelected.push(key);
} else {
newSelected = selected.filter((vin) => vin !== key);
}
setSelected(newSelected);
};
return (
<div className={classes.paper} style={{ height: 700, width: "100%" }}>
<TableContainer>
<Table>
<TableHeaderSortable
classes={classes}
orderBy={orderBy}
order={order}
columnData={tableColumns}
onSortRequest={sortHandler}
multiSelect={true}
onSelectAll={handleSelectAll}
selectCount={selected.length}
rowCount={totalVehicles}
/>
<TableBody>
{vehicles.map((row) => {
const isSelected = selected.indexOf(row.vin) !== -1;
return (
<TableRow key={row.vin}>
<TableCell padding="checkbox">
<Checkbox
checked={isSelected}
onChange={(event) => handleSelect(event, row.vin)}
/>
</TableCell>
<TableCell align="center" sortDirection={true}>
<Link to={`/vehicle-status/${row.vin}`}>{row.vin}</Link>
</TableCell>
<TableCell align="center">{row.model}</TableCell>
<TableCell align="center">{row.year}</TableCell>
<TableCell align="center">{row.trim || ""}</TableCell>
<TableCell align="center">
{LocalDateTimeString(row.created)}
</TableCell>
<TableCell align="center">
{LocalDateTimeString(row.updated)}
</TableCell>
</TableRow>
);
})}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={[5, 10, 25, 100]}
colSpan={7}
count={totalVehicles}
rowsPerPage={pageSize}
page={pageIndex}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePageIndex}
onChangeRowsPerPage={handleChangePageSize}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
<SendCommand vins={selected} />
</div>
);
};
const VehiclesList = () => (
<VehicleProvider>
<MainForm />
</VehicleProvider>
);
export default VehiclesList;

View File

@@ -7,10 +7,8 @@ import {
TableCell,
TableContainer,
TableFooter,
TableHead,
TablePagination,
TableRow,
Typography,
} from "@material-ui/core";
import {
@@ -23,22 +21,51 @@ import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext";
import useStyles from "../../useStyles";
import { LocalDateTimeString } from "../../../utils/dates";
import TableHeaderSortable from "../../Table/HeaderSortable";
import SendCommand from "../SendCommand";
import LogFilter from "../LogFilter";
const tableColumns = [
{
id: "id",
label: "ID",
},
{
id: "update_package_id",
label: "Name",
},
{
id: "status",
label: "Status",
},
{
id: "created_at",
label: "Created",
},
{
id: "updated_at",
label: "Updated",
},
];
const MainForm = () => {
const { vin } = useParams();
const classes = useStyles();
const [pageSize, setPageSize] = useState(10);
const [pageIndex, setPageIndex] = useState(0);
const [orderBy, setOrderBy] = useState("id");
const [order, setOrder] = useState("desc");
const { getCarUpdates, carUpdates, totalCarUpdates } = useUpdatesContext();
const { setMessage } = useStatusContext();
const { setMessage, setTitle } = useStatusContext();
const {
token: {
idToken: { jwtToken: token },
},
} = useUserContext();
useEffect(() => {
setTitle(`Vehicle ${vin} Details`);
// eslint-disable-next-line
}, [vin]);
useEffect(() => {
try {
getCarUpdates(
@@ -46,6 +73,7 @@ const MainForm = () => {
vin,
limit: pageSize,
offset: pageSize * pageIndex,
order: `${orderBy} ${order}`,
},
token
);
@@ -53,7 +81,7 @@ const MainForm = () => {
setMessage(e.message);
}
// eslint-disable-next-line
}, [pageIndex, pageSize, token]);
}, [pageIndex, pageSize, token, orderBy, order]);
const handleChangePageIndex = (event, newIndex) => {
setPageIndex(newIndex);
@@ -64,22 +92,30 @@ const MainForm = () => {
setPageIndex(0);
};
const handleSort = (event, property) => {
if (property === orderBy) {
if (order === "asc") {
setOrder("desc");
} else {
setOrder("asc");
}
} else {
setOrderBy(property);
setOrder("asc");
}
};
return (
<div className={classes.paper} style={{ height: 700, width: "100%" }}>
<Typography component="h1" variant="h5">
{vin} Updates
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell align="center">ID</TableCell>
<TableCell align="center">Update</TableCell>
<TableCell align="center">Status</TableCell>
<TableCell align="center">Created</TableCell>
<TableCell align="center">Updated</TableCell>
</TableRow>
</TableHead>
<TableHeaderSortable
classes={classes}
orderBy={orderBy}
order={order}
columnData={tableColumns}
onSortRequest={handleSort}
/>
<TableBody>
{carUpdates.map((row) => (
<TableRow key={row.id}>
@@ -98,7 +134,7 @@ const MainForm = () => {
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
rowsPerPageOptions={[5, 10, 25, 100]}
colSpan={5}
count={totalCarUpdates}
rowsPerPage={pageSize}
@@ -116,10 +152,7 @@ const MainForm = () => {
</TableContainer>
<Grid container className={classes.root} spacing={2}>
<Grid item lg={6} md={12}>
<SendCommand vin={vin} />
</Grid>
<Grid item lg={6} md={12} style={{ textAlign: "right" }}>
<LogFilter vin={vin} />
<SendCommand vins={[vin]} />
</Grid>
</Grid>
</div>

View File

@@ -4,12 +4,15 @@ const StatusContext = React.createContext();
export const StatusProvider = ({ children }) => {
const [message, setMessage] = useState(null);
const [title, setTitle] = useState("");
return (
<StatusContext.Provider
value={{
message,
setMessage,
title,
setTitle,
}}
>
{children}

View File

@@ -84,10 +84,10 @@ export const VehicleProvider = ({ children }) => {
}
};
const sendCommand = async (vin, command, token) => {
const sendCommand = async (vins, command, parameters, token) => {
try {
setBusy(true);
const result = await api.sendCommand(vin, command, token);
const result = await api.sendCommand(vins, command, parameters, token);
if (result.error)
throw new Error(`Send command error. ${result.message}`);
return result;
@@ -96,18 +96,6 @@ export const VehicleProvider = ({ children }) => {
}
};
const sendLogFilter = async (vin, filter, token) => {
try {
setBusy(true);
const result = await api.sendLog(vin, filter, token);
if (result.error)
throw new Error(`Send log filter error. ${result.message}`);
return result;
} finally {
setBusy(false);
}
};
return (
<VehicleContext.Provider
value={{
@@ -121,7 +109,6 @@ export const VehicleProvider = ({ children }) => {
getModels,
getYears,
sendCommand,
sendLogFilter,
}}
>
{children}

View File

@@ -25,13 +25,10 @@ export const useVehicleContext = () => ({
getYears: jest.fn(() => {
years = [2023, 2024];
}),
sendCommand: jest.fn((vin, command, token) => ({
sendCommand: jest.fn((vin, command, parameters, token) => ({
vin,
command,
})),
sendLogFilter: jest.fn((vin, filter, token) => ({
vin,
filter,
parameters,
})),
});

View File

@@ -0,0 +1,49 @@
import React, { useState } from "react";
import {
FormControl,
IconButton,
Input,
InputAdornment,
InputLabel,
} from "@material-ui/core";
import SearchIcon from "@material-ui/icons/Search";
import clsx from "clsx";
const SearchField = (props) => {
const { classes, onSearch } = props;
const [searchTerm, setSearchTerm] = useState("");
const handleChange = (e) => {
setSearchTerm(e.target.value);
};
const handleSearch = (e) => {
if (!onSearch) return;
onSearch(searchTerm);
};
const handleEnterPress = (e) => {
if (e.keyCode !== 13) return;
e.preventDefault();
handleSearch(e);
};
return (
<FormControl className={clsx(classes.margin, classes.textField)}>
<InputLabel htmlFor="search">Search</InputLabel>
<Input
id="search"
type="text"
value={searchTerm}
onChange={handleChange}
onKeyDown={handleEnterPress}
endAdornment={
<InputAdornment position="end">
<IconButton aria-label="search" onClick={handleSearch}>
<SearchIcon />
</IconButton>
</InputAdornment>
}
/>
</FormControl>
);
};
export default SearchField;

View File

@@ -1,8 +1,9 @@
import React from "react";
import React, { useEffect } from "react";
import { Typography } from "@material-ui/core";
import useStyles from "../useStyles";
import { useUserContext } from "../Contexts/UserContext";
import { useStatusContext } from "../Contexts/StatusContext";
import { parsePayload } from "../../utils/jwt";
const DEFAULT_GREETING = "Welcome";
@@ -22,6 +23,12 @@ const Home = () => {
const classes = useStyles();
const { token } = useUserContext();
const greeting = getGreeting(token);
const { setTitle } = useStatusContext();
useEffect(() => {
setTitle("");
// eslint-disable-next-line
}, []);
return (
<div className={classes.paper}>

View File

@@ -1,34 +1,22 @@
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 { useStatusContext } from "../Contexts/StatusContext";
import { Button, Container } from "@material-ui/core";
import logo from "../../assets/fisker-badge.svg";
export default function MenuDrawer({ children }) {
const classes = useStyles();
const theme = useTheme();
const { title } = useStatusContext();
const { signOut, token } = useUserContext();
const [open, setOpen] = React.useState(true);
const handleDrawerOpen = () => {
setOpen(true);
};
const handleDrawerClose = () => {
setOpen(false);
};
const onSignOut = () => {
document.location = signOut();
@@ -39,26 +27,12 @@ export default function MenuDrawer({ children }) {
<AppBar
position="fixed"
className={clsx(classes.appBar, {
[classes.appBarShift]: open && token !== null,
[classes.appBarShift]: 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
{title}
</Typography>
{token !== null && (
<Button
@@ -76,19 +50,17 @@ export default function MenuDrawer({ children }) {
className={classes.drawer}
variant="persistent"
anchor="left"
open={open}
open={true}
classes={{
paper: classes.drawerPaper,
}}
>
<div className={classes.drawerHeader}>
<IconButton onClick={handleDrawerClose}>
{theme.direction === "ltr" ? (
<ChevronLeftIcon />
) : (
<ChevronRightIcon />
)}
</IconButton>
<img
src={logo}
alt="Fisker Admin Portal"
className={classes.logo}
/>
</div>
<Divider />
<SideMenu />
@@ -96,7 +68,7 @@ export default function MenuDrawer({ children }) {
)}
<main
className={clsx(classes.content, {
[classes.contentShift]: open && token !== null,
[classes.contentShift]: token !== null,
})}
>
<div className={classes.drawerHeader} />

View File

@@ -11,7 +11,7 @@ const menuData = [
roles: [],
},
{
label: "View Packages",
label: "Deploy Packages",
to: "/updates",
roles: [Roles.CREATE, Roles.READ],
},
@@ -30,6 +30,11 @@ const menuData = [
to: "/vehicle-add",
roles: [Roles.CREATE],
},
{
label: "Send Command",
to: "/vehicles-command",
roles: [Roles.CREATE],
},
];
export default function SideMenu() {

View File

@@ -44,7 +44,7 @@ exports[`SideMenu Authenticated 1`] = `
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
View Packages
Deploy Packages
</span>
</div>
<span
@@ -118,6 +118,28 @@ exports[`SideMenu Authenticated 1`] = `
/>
</a>
</li>
<li>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiListItem-root MuiListItem-gutters MuiListItem-button"
href="/vehicles-command"
role="button"
tabindex="0"
>
<div
class="MuiListItemText-root"
>
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
Send Command
</span>
</div>
<span
class="MuiTouchRipple-root"
/>
</a>
</li>
</ul>
</div>
</div>

View File

@@ -17,6 +17,7 @@ const CarUpdatesDeploy = React.lazy(() => import("../CarUpdates/Deploy"));
const CarUpdatesStatus = React.lazy(() => import("../CarUpdates/Status"));
const CarUpdates = React.lazy(() => import("../Cars/Status"));
const VehiclesList = React.lazy(() => import("../Cars/List"));
const SendCommandBulk = React.lazy(() => import("../Cars/SendCommandBulk"));
const SiteRoutes = () => {
const { token, groups } = useUserContext();
@@ -101,6 +102,14 @@ const SiteRoutes = () => {
groups={groups}
roles={[Roles.READ, Roles.CREATE]}
/>
<AuthRoute
path="/vehicles-command"
render={() => <SendCommandBulk />}
type={TYPES.PROTECTED}
token={token}
groups={groups}
roles={[Roles.CREATE]}
/>
<PageNotFound />
</Switch>
</Suspense>

View File

@@ -0,0 +1,101 @@
import React from "react";
import PropTypes from "prop-types";
import {
Checkbox,
TableCell,
TableHead,
TableRow,
TableSortLabel,
} from "@material-ui/core";
const HeaderSortable = (props) => {
const {
classes,
order,
orderBy,
onSortRequest,
columnData,
multiSelect,
onSelectAll,
selectCount,
rowCount,
} = props;
const sortHandler = (property) => (event) => {
if (!onSortRequest) return;
onSortRequest(event, property);
};
const selectAllHandler = (event) => {
if (!onSelectAll) return;
onSelectAll(event);
};
const ColumnLabel = (column) => {
if (column.id) {
return (
<TableSortLabel
active={orderBy === column.id}
direction={orderBy === column.id ? order : "asc"}
onClick={sortHandler(column.id)}
>
{column.label}
{orderBy === column.id ? (
<span className={classes.hiddenSortSpan}>
{order === "desc" ? "sorted descending" : "sorted ascending"}
</span>
) : null}
</TableSortLabel>
);
}
return column.label;
};
if (multiSelect) {
const errors = [];
if (onSelectAll === undefined) errors.push("onSelectAll required");
if (selectCount === undefined) errors.push("selectCount required");
if (rowCount === undefined) errors.push("rowCount required");
if (errors.length > 0) {
throw new Error(errors.join(". "));
}
}
return (
<TableHead>
<TableRow>
{multiSelect && (
<TableCell padding="checkbox">
<Checkbox
indeterminate={selectCount > 0 && selectCount < rowCount}
checked={rowCount > 0 && selectCount === rowCount}
onChange={selectAllHandler}
inputProps={{ "aria-label": "select all desserts" }}
/>
</TableCell>
)}
{columnData.map((column) => (
<TableCell
key={column.id}
align={column.numeric ? "right" : "center"}
padding={column.disablePadding ? "none" : "default"}
sortDirection={orderBy === column.id ? order : false}
>
{ColumnLabel(column)}
</TableCell>
))}
</TableRow>
</TableHead>
);
};
HeaderSortable.propTypes = {
classes: PropTypes.object.isRequired,
onSortRequest: PropTypes.func.isRequired,
order: PropTypes.oneOf(["asc", "desc"]).isRequired,
orderBy: PropTypes.string.isRequired,
columnData: PropTypes.array.isRequired,
multiSelect: PropTypes.bool,
selectCount: PropTypes.number,
totalRows: PropTypes.number,
onSelectAll: PropTypes.func,
};
export default HeaderSortable;

View File

@@ -8,11 +8,6 @@ exports[`File Upload Form Should render 1`] = `
<div
class="makeStyles-paper-3"
>
<h1
class="MuiTypography-root MuiTypography-h5"
>
Create Update Package
</h1>
<form
action="{onSubmit}"
class="makeStyles-form-5"
@@ -51,10 +46,10 @@ exports[`File Upload Form Should render 1`] = `
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-26 MuiOutlinedInput-notchedOutline"
class="PrivateNotchedOutline-root-31 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-28"
class="PrivateNotchedOutline-legendLabelled-33"
>
<span>
Package name
@@ -97,10 +92,10 @@ exports[`File Upload Form Should render 1`] = `
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-26 MuiOutlinedInput-notchedOutline"
class="PrivateNotchedOutline-root-31 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-28"
class="PrivateNotchedOutline-legendLabelled-33"
>
<span>
Version
@@ -143,10 +138,10 @@ exports[`File Upload Form Should render 1`] = `
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-26 MuiOutlinedInput-notchedOutline"
class="PrivateNotchedOutline-root-31 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-28"
class="PrivateNotchedOutline-legendLabelled-33"
>
<span>
Description
@@ -190,10 +185,10 @@ exports[`File Upload Form Should render 1`] = `
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-26 MuiOutlinedInput-notchedOutline"
class="PrivateNotchedOutline-root-31 MuiOutlinedInput-notchedOutline"
>
<legend
class="PrivateNotchedOutline-legendLabelled-28"
class="PrivateNotchedOutline-legendLabelled-33"
>
<span>
Release Notes URL

View File

@@ -1,5 +1,5 @@
import React, { useRef, useState } from "react";
import { Button, TextField, Typography } from "@material-ui/core";
import React, { useEffect, useRef, useState } from "react";
import { Button, TextField } from "@material-ui/core";
import { DropzoneArea } from "material-ui-dropzone";
import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext";
@@ -42,13 +42,19 @@ const FileUploadZone = ({ classes, token }) => {
const MainForm = () => {
const { uploading, upload, files, cancel } = useFileUploadContext();
const { token } = useUserContext();
const { setMessage } = useStatusContext();
const { setMessage, setTitle } = useStatusContext();
const [redirect, setRedirect] = useState(null);
const classes = useStyles();
const packagenameEl = useRef(null);
const versionEl = useRef(null);
const descEl = useRef(null);
const releasenotesEl = useRef(null);
useEffect(() => {
setTitle("Create Update Package");
// eslint-disable-next-line
}, []);
const onSubmit = async (event) => {
try {
event.preventDefault();
@@ -79,9 +85,6 @@ const MainForm = () => {
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Create Update Package
</Typography>
<form className={classes.form} noValidate action="{onSubmit}">
<TextField
id="packagename"

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router";
import { Button, TextField, Typography } from "@material-ui/core";
import { Button, TextField } from "@material-ui/core";
import {
UpdatesProvider,
@@ -19,7 +19,7 @@ const MainForm = () => {
idToken: { jwtToken: token },
},
} = useUserContext();
const { setMessage } = useStatusContext();
const { setMessage, setTitle } = useStatusContext();
const [packageName, setPackageName] = useState("");
const [version, setVersion] = useState("");
const [link, setLink] = useState("");
@@ -68,10 +68,16 @@ const MainForm = () => {
}
};
useEffect(() => {
setTitle(`Edit Update Package ${id}`);
// eslint-disable-next-line
}, []);
useEffect(() => {
getData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
if (!packages || packages.length === 0) return;
var data = packages[0];
@@ -86,9 +92,6 @@ const MainForm = () => {
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Edit Update Package {id}
</Typography>
<form className={classes.form} noValidate action="{onSubmit}">
<TextField
label="Create Date"

View File

@@ -6,13 +6,11 @@ import {
TableCell,
TableContainer,
TableFooter,
TableHead,
TablePagination,
TableRow,
Toolbar,
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";
@@ -21,13 +19,42 @@ import {
useUpdatesContext,
} from "../../Contexts/UpdatesContext";
import { useUserContext } from "../../Contexts/UserContext";
import { tsLocalDateTimeString } from "../../../utils/dates";
import { useStatusContext } from "../../Contexts/StatusContext";
import { LocalDateTimeString } from "../../../utils/dates";
import { Roles, hasRole } from "../../../utils/roles";
import TableHeaderSortable from "../../Table/HeaderSortable";
import SearchField from "../../Controls/SearchField";
const tableColumns = [
{
id: "id",
label: "ID",
},
{
id: "package_name",
label: "Name",
},
{
id: "version",
label: "Version",
},
{
id: "created_at",
label: "Created",
},
{
id: "",
label: "Actions",
},
];
const UpdatePackagesList = () => {
const classes = useStyles();
const [pageSize, setPageSize] = useState(25);
const [pageIndex, setPageIndex] = useState(0);
const [orderBy, setOrderBy] = useState("id");
const [order, setOrder] = useState("desc");
const [search, setSearch] = useState("");
const { getPackages, packages, totalPackages } = useUpdatesContext();
const {
token: {
@@ -35,11 +62,25 @@ const UpdatePackagesList = () => {
},
groups,
} = useUserContext();
const { setTitle } = useStatusContext();
useEffect(() => {
getPackages({ limit: pageSize, offset: pageSize * pageIndex }, token);
setTitle("Deploy Packages");
// eslint-disable-next-line
}, [pageIndex, pageSize, token]);
}, []);
useEffect(() => {
getPackages(
{
limit: pageSize,
offset: pageSize * pageIndex,
order: `${orderBy} ${order}`,
search,
},
token
);
// eslint-disable-next-line
}, [pageIndex, pageSize, token, orderBy, order, search]);
const handleChangePageIndex = (event, newIndex) => {
setPageIndex(newIndex);
@@ -50,6 +91,23 @@ const UpdatePackagesList = () => {
setPageIndex(0);
};
const handleSort = (event, property) => {
if (property === orderBy) {
if (order === "asc") {
setOrder("desc");
} else {
setOrder("asc");
}
} else {
setOrderBy(property);
setOrder("asc");
}
};
const handleSearch = (search) => {
setSearch(search);
};
const Actions = (row) => {
let actions = [];
if (hasRole([Roles.CREATE, Roles.READ], groups)) {
@@ -65,13 +123,6 @@ const UpdatePackagesList = () => {
}
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}`,
@@ -97,20 +148,18 @@ const UpdatePackagesList = () => {
return (
<div className={classes.paper} style={{ height: 700, width: "100%" }}>
<Typography component="h1" variant="h5">
Update Packages
</Typography>
<Toolbar className={classes.tableToolbar}>
<SearchField classes={classes} onSearch={handleSearch} />
</Toolbar>
<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>
<TableHeaderSortable
classes={classes}
orderBy={orderBy}
order={order}
columnData={tableColumns}
onSortRequest={handleSort}
/>
<TableBody>
{packages.map((row) => (
<TableRow key={row.id}>
@@ -118,16 +167,16 @@ const UpdatePackagesList = () => {
<TableCell align="center">{row.package_name}</TableCell>
<TableCell align="center">{row.version}</TableCell>
<TableCell align="center">
{tsLocalDateTimeString(row.timestamp)}
{LocalDateTimeString(row.created)}
</TableCell>
<TableCell align="right">{Actions(row)}</TableCell>
<TableCell align="center">{Actions(row)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
rowsPerPageOptions={[5, 10, 25, 100]}
colSpan={5}
count={totalPackages}
rowsPerPage={pageSize}

View File

@@ -17,7 +17,7 @@ const useStyles = makeStyles((theme) => ({
padding: theme.spacing(2, 4, 3),
},
paper: {
marginTop: theme.spacing(8),
marginTop: theme.spacing(1),
display: "flex",
flexDirection: "column",
alignItems: "center",
@@ -105,7 +105,7 @@ const useStyles = makeStyles((theme) => ({
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
justifyContent: "flex-end",
justifyContent: "flex-start",
},
content: {
flexGrow: 1,
@@ -134,6 +134,34 @@ const useStyles = makeStyles((theme) => ({
textDecorationLine: "underline",
color: "Blue",
},
hiddenSortSpan: {
border: 0,
clip: "rect(0 0 0 0)",
height: 1,
margin: -1,
overflow: "hidden",
padding: 0,
position: "absolute",
top: 20,
width: 1,
},
margin: {
margin: theme.spacing(1),
},
textField: {
width: "25ch",
},
tableToolbar: {
textAlign: "left",
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(1),
width: "100%",
},
logo: {
height: 60,
width: 60,
margin: "auto",
},
}));
export default useStyles;