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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -99,37 +99,37 @@ describe("App", () => {
it("Route /package-upload authenticated", async () => { it("Route /package-upload authenticated", async () => {
setToken(TEST_AUTH_OBJECT); 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 () => { it("Route /vehicle-add authenticated", async () => {
setToken(TEST_AUTH_OBJECT); setToken(TEST_AUTH_OBJECT);
await check("/vehicle-add", "h1", "Add Vehicle"); await check("/vehicle-add", "h6", "Add Vehicle");
}); });
it("Route /updates authenticated", async () => { it("Route /updates authenticated", async () => {
setToken(TEST_AUTH_OBJECT); setToken(TEST_AUTH_OBJECT);
await check("/updates", "h1", "Update Packages"); await check("/updates", "h6", "Deploy Packages");
}); });
it("Route /update authenticated", async () => { it("Route /update authenticated", async () => {
setToken(TEST_AUTH_OBJECT); 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 () => { it("Route /carupdate-status authenticated", async () => {
setToken(TEST_AUTH_OBJECT); setToken(TEST_AUTH_OBJECT);
await check("/carupdate-status/1", "h1", ""); await check("/carupdate-status/1", "h6", "");
}); });
it("Route /vehicles authenticated", async () => { it("Route /vehicles authenticated", async () => {
setToken(TEST_AUTH_OBJECT); setToken(TEST_AUTH_OBJECT);
await check("/vehicles", "h1", "Vehicles"); await check("/vehicles", "h6", "Vehicles");
}); });
it("Route /vehicle-status authenticated", async () => { it("Route /vehicle-status authenticated", async () => {
setToken(TEST_AUTH_OBJECT); 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 () => { it("Route /page-not-found unauthenticated", async () => {
@@ -143,6 +143,6 @@ describe("App", () => {
it("Route /carupdate-deploy authenticated", async () => { it("Route /carupdate-deploy authenticated", async () => {
setToken(TEST_AUTH_OBJECT); 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 React, { useEffect, useState } from "react";
import { useParams, Redirect } from "react-router"; import { useParams, Redirect } from "react-router";
import { Button, TextField, Typography } from "@material-ui/core"; import { Button, Typography } from "@material-ui/core";
import { import {
UpdatesProvider, UpdatesProvider,
useUpdatesContext, useUpdatesContext,
@@ -20,12 +19,10 @@ const MainForm = () => {
idToken: { jwtToken: token }, idToken: { jwtToken: token },
}, },
} = useUserContext(); } = useUserContext();
const { setMessage } = useStatusContext(); const { setMessage, setTitle } = useStatusContext();
const [packageName, setPackageName] = useState(""); const [packageName, setPackageName] = useState("");
const [version, setVersion] = useState(""); const [version, setVersion] = useState("");
const [link, setLink] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [releaseNotesLink, setReleaseNotesLink] = useState("");
const [createDate, setCreateDate] = useState(""); const [createDate, setCreateDate] = useState("");
const [selectedVehicles, setSelectedVehicles] = useState([]); const [selectedVehicles, setSelectedVehicles] = useState([]);
const [redirect, setRedirect] = useState(""); const [redirect, setRedirect] = useState("");
@@ -60,15 +57,18 @@ const MainForm = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]); }, [token]);
useEffect(() => {
setTitle(`Deploy ${packageName} ${version}`);
// eslint-disable-next-line
}, [packageName, version]);
useEffect(() => { useEffect(() => {
if (!packages || packages.length === 0) return; if (!packages || packages.length === 0) return;
var data = packages[0]; var data = packages[0];
setPackageName(data.package_name); setPackageName(data.package_name);
setVersion(data.version); setVersion(data.version);
setLink(data.link);
setDescription(data.desc || ""); setDescription(data.desc || "");
setReleaseNotesLink(data.release_notes || "");
setCreateDate(tsLocalDateTimeString(data.timestamp)); setCreateDate(tsLocalDateTimeString(data.timestamp));
}, [packages]); }, [packages]);
@@ -78,64 +78,11 @@ const MainForm = () => {
return ( return (
<div className={classes.paper}> <div className={classes.paper}>
<Typography component="h1" variant="h5">
Deploy {`${packageName} ${version} [${packageid}]`}
</Typography>
<form className={classes.form} noValidate action="{onSubmit}"> <form className={classes.form} noValidate action="{onSubmit}">
<TextField <Typography variant="body2">
label="Create Date" Created {createDate}. {description}
variant="filled" </Typography>
margin="normal" <hr style={{ marginBottom: 30, marginTop: 30 }} />
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"
/>
<CarSelection onSelection={setSelectedVehicles} /> <CarSelection onSelection={setSelectedVehicles} />
<Button <Button
type="submit" type="submit"

View File

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

View File

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

View File

@@ -6,10 +6,9 @@ import {
TableCell, TableCell,
TableContainer, TableContainer,
TableFooter, TableFooter,
TableHead,
TablePagination, TablePagination,
TableRow, TableRow,
Typography, Toolbar,
} from "@material-ui/core"; } from "@material-ui/core";
import { import {
@@ -20,25 +19,77 @@ import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext"; import { useStatusContext } from "../../Contexts/StatusContext";
import useStyles from "../../useStyles"; import useStyles from "../../useStyles";
import { LocalDateTimeString } from "../../../utils/dates"; 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 MainForm = () => {
const classes = useStyles(); const classes = useStyles();
const [pageSize, setPageSize] = useState(25); const [pageSize, setPageSize] = useState(25);
const [pageIndex, setPageIndex] = useState(0); 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 { getVehicles, vehicles, totalVehicles } = useVehicleContext();
const { setMessage } = useStatusContext(); const { setMessage, setTitle } = useStatusContext();
const { const {
token: { token: {
idToken: { jwtToken: token }, idToken: { jwtToken: token },
}, },
} = useUserContext(); } = 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(() => { useEffect(() => {
try { try {
getVehicles( getVehicles(
{ {
limit: pageSize, limit: pageSize,
offset: pageSize * pageIndex, offset: pageSize * pageIndex,
order: `${orderBy} ${order}`,
search,
}, },
token token
); );
@@ -46,7 +97,7 @@ const MainForm = () => {
setMessage(e.message); setMessage(e.message);
} }
// eslint-disable-next-line // eslint-disable-next-line
}, [pageIndex, pageSize, token]); }, [pageIndex, pageSize, token, orderBy, order, search]);
const handleChangePageIndex = (event, newIndex) => { const handleChangePageIndex = (event, newIndex) => {
setPageIndex(newIndex); setPageIndex(newIndex);
@@ -57,30 +108,33 @@ const MainForm = () => {
setPageIndex(0); setPageIndex(0);
}; };
const handleSearch = (search) => {
setSearch(search);
};
return ( return (
<div className={classes.paper} style={{ height: 700, width: "100%" }}> <div className={classes.paper} style={{ height: 700, width: "100%" }}>
<Typography component="h1" variant="h5"> <Toolbar className={classes.tableToolbar}>
Vehicles <SearchField classes={classes} onSearch={handleSearch} />
</Typography> </Toolbar>
<TableContainer> <TableContainer>
<Table> <Table>
<TableHead> <TableHeaderSortable
<TableRow> classes={classes}
<TableCell align="center">VIN</TableCell> orderBy={orderBy}
<TableCell align="center">Model</TableCell> order={order}
<TableCell align="center">Year</TableCell> columnData={tableColumns}
<TableCell align="center">Created</TableCell> onSortRequest={sortHandler}
<TableCell align="center">Updated</TableCell> />
</TableRow>
</TableHead>
<TableBody> <TableBody>
{vehicles.map((row) => ( {vehicles.map((row) => (
<TableRow key={row.vin}> <TableRow key={row.vin}>
<TableCell align="center"> <TableCell align="center" sortDirection={true}>
<Link to={`/vehicle-status/${row.vin}`}>{row.vin}</Link> <Link to={`/vehicle-status/${row.vin}`}>{row.vin}</Link>
</TableCell> </TableCell>
<TableCell align="center">{row.model}</TableCell> <TableCell align="center">{row.model}</TableCell>
<TableCell align="center">{row.year}</TableCell> <TableCell align="center">{row.year}</TableCell>
<TableCell align="center">{row.trim || ""}</TableCell>
<TableCell align="center"> <TableCell align="center">
{LocalDateTimeString(row.created)} {LocalDateTimeString(row.created)}
</TableCell> </TableCell>
@@ -93,7 +147,7 @@ const MainForm = () => {
<TableFooter> <TableFooter>
<TableRow> <TableRow>
<TablePagination <TablePagination
rowsPerPageOptions={[5, 10, 25]} rowsPerPageOptions={[5, 10, 25, 100]}
colSpan={6} colSpan={6}
count={totalVehicles} count={totalVehicles}
rowsPerPage={pageSize} 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 { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext"; import { useStatusContext } from "../../Contexts/StatusContext";
const SendCommand = ({ vin }) => { const SendCommand = ({ vins }) => {
const classes = useStyles(); const classes = useStyles();
const { sendCommand, busy } = useVehicleContext(); const { sendCommand, busy } = useVehicleContext();
const { const {
@@ -17,25 +17,59 @@ const SendCommand = ({ vin }) => {
idToken: { jwtToken: token }, idToken: { jwtToken: token },
}, },
} = useUserContext(); } = useUserContext();
const NoParameters = {
value: "",
label: "None",
};
const { setMessage } = useStatusContext(); const { setMessage } = useStatusContext();
const [command, setCommand] = useState(""); const [command, setCommand] = useState("");
const [parameters, setParameters] = useState([NoParameters]);
const [parameter, setParameter] = useState("");
const changeCommandHandler = (e) => {
selectCommand(e.target.value);
};
const changeHandler = (e) => { const selectCommand = (cmd) => {
setCommand(e.target.value); const params = getParameters(cmd);
setCommand(cmd);
setParameters(params);
setParameter(params[0].value);
};
const changeParametersHandler = (e) => {
setParameter(e.target.value);
}; };
const clickHandler = async (e) => { const clickHandler = async (e) => {
try { try {
await sendCommand(vin, command, token); await sendCommand(vins, command, parameter, token);
setMessage(`Sent command to ${vin}`); if (vins.length === 1) {
setMessage(`Sent command to ${vins[0]}`);
} else {
setMessage(`Sent command to ${vins.length} cars`);
}
} catch (e) { } catch (e) {
setMessage(e.message); 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(() => { useEffect(() => {
if (!commands || commands.length === 0) return; if (!commands || commands.length === 0) return;
setCommand(commands[0].value); selectCommand(commands[0].value);
// eslint-disable-next-line
}, []); }, []);
return ( return (
@@ -52,7 +86,7 @@ const SendCommand = ({ vin }) => {
name: "send-command", name: "send-command",
id: "send-command", id: "send-command",
}} }}
onChange={changeHandler} onChange={changeCommandHandler}
> >
{commands.map((item) => ( {commands.map((item) => (
<option key={item.value} value={item.value}> <option key={item.value} value={item.value}>
@@ -61,12 +95,37 @@ const SendCommand = ({ vin }) => {
))} ))}
</Select> </Select>
</FormControl> </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 <IconButton
color="primary" color="primary"
aria-label="send command" aria-label="send command"
component="span" component="span"
onClick={clickHandler} onClick={clickHandler}
disabled={busy} disabled={busy || vins.length === 0}
> >
<SendIcon fontSize="large" /> <SendIcon fontSize="large" />
</IconButton> </IconButton>
@@ -75,7 +134,7 @@ const SendCommand = ({ vin }) => {
}; };
SendCommand.propTypes = { SendCommand.propTypes = {
vin: PropTypes.string.isRequired, vins: PropTypes.array.isRequired,
}; };
export default SendCommand; 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, TableCell,
TableContainer, TableContainer,
TableFooter, TableFooter,
TableHead,
TablePagination, TablePagination,
TableRow, TableRow,
Typography,
} from "@material-ui/core"; } from "@material-ui/core";
import { import {
@@ -23,22 +21,51 @@ import { useUserContext } from "../../Contexts/UserContext";
import { useStatusContext } from "../../Contexts/StatusContext"; import { useStatusContext } from "../../Contexts/StatusContext";
import useStyles from "../../useStyles"; import useStyles from "../../useStyles";
import { LocalDateTimeString } from "../../../utils/dates"; import { LocalDateTimeString } from "../../../utils/dates";
import TableHeaderSortable from "../../Table/HeaderSortable";
import SendCommand from "../SendCommand"; 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 MainForm = () => {
const { vin } = useParams(); const { vin } = useParams();
const classes = useStyles(); const classes = useStyles();
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const [orderBy, setOrderBy] = useState("id");
const [order, setOrder] = useState("desc");
const { getCarUpdates, carUpdates, totalCarUpdates } = useUpdatesContext(); const { getCarUpdates, carUpdates, totalCarUpdates } = useUpdatesContext();
const { setMessage } = useStatusContext(); const { setMessage, setTitle } = useStatusContext();
const { const {
token: { token: {
idToken: { jwtToken: token }, idToken: { jwtToken: token },
}, },
} = useUserContext(); } = useUserContext();
useEffect(() => {
setTitle(`Vehicle ${vin} Details`);
// eslint-disable-next-line
}, [vin]);
useEffect(() => { useEffect(() => {
try { try {
getCarUpdates( getCarUpdates(
@@ -46,6 +73,7 @@ const MainForm = () => {
vin, vin,
limit: pageSize, limit: pageSize,
offset: pageSize * pageIndex, offset: pageSize * pageIndex,
order: `${orderBy} ${order}`,
}, },
token token
); );
@@ -53,7 +81,7 @@ const MainForm = () => {
setMessage(e.message); setMessage(e.message);
} }
// eslint-disable-next-line // eslint-disable-next-line
}, [pageIndex, pageSize, token]); }, [pageIndex, pageSize, token, orderBy, order]);
const handleChangePageIndex = (event, newIndex) => { const handleChangePageIndex = (event, newIndex) => {
setPageIndex(newIndex); setPageIndex(newIndex);
@@ -64,22 +92,30 @@ const MainForm = () => {
setPageIndex(0); setPageIndex(0);
}; };
const handleSort = (event, property) => {
if (property === orderBy) {
if (order === "asc") {
setOrder("desc");
} else {
setOrder("asc");
}
} else {
setOrderBy(property);
setOrder("asc");
}
};
return ( return (
<div className={classes.paper} style={{ height: 700, width: "100%" }}> <div className={classes.paper} style={{ height: 700, width: "100%" }}>
<Typography component="h1" variant="h5">
{vin} Updates
</Typography>
<TableContainer> <TableContainer>
<Table> <Table>
<TableHead> <TableHeaderSortable
<TableRow> classes={classes}
<TableCell align="center">ID</TableCell> orderBy={orderBy}
<TableCell align="center">Update</TableCell> order={order}
<TableCell align="center">Status</TableCell> columnData={tableColumns}
<TableCell align="center">Created</TableCell> onSortRequest={handleSort}
<TableCell align="center">Updated</TableCell> />
</TableRow>
</TableHead>
<TableBody> <TableBody>
{carUpdates.map((row) => ( {carUpdates.map((row) => (
<TableRow key={row.id}> <TableRow key={row.id}>
@@ -98,7 +134,7 @@ const MainForm = () => {
<TableFooter> <TableFooter>
<TableRow> <TableRow>
<TablePagination <TablePagination
rowsPerPageOptions={[5, 10, 25]} rowsPerPageOptions={[5, 10, 25, 100]}
colSpan={5} colSpan={5}
count={totalCarUpdates} count={totalCarUpdates}
rowsPerPage={pageSize} rowsPerPage={pageSize}
@@ -116,10 +152,7 @@ const MainForm = () => {
</TableContainer> </TableContainer>
<Grid container className={classes.root} spacing={2}> <Grid container className={classes.root} spacing={2}>
<Grid item lg={6} md={12}> <Grid item lg={6} md={12}>
<SendCommand vin={vin} /> <SendCommand vins={[vin]} />
</Grid>
<Grid item lg={6} md={12} style={{ textAlign: "right" }}>
<LogFilter vin={vin} />
</Grid> </Grid>
</Grid> </Grid>
</div> </div>

View File

@@ -4,12 +4,15 @@ const StatusContext = React.createContext();
export const StatusProvider = ({ children }) => { export const StatusProvider = ({ children }) => {
const [message, setMessage] = useState(null); const [message, setMessage] = useState(null);
const [title, setTitle] = useState("");
return ( return (
<StatusContext.Provider <StatusContext.Provider
value={{ value={{
message, message,
setMessage, setMessage,
title,
setTitle,
}} }}
> >
{children} {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 { try {
setBusy(true); setBusy(true);
const result = await api.sendCommand(vin, command, token); const result = await api.sendCommand(vins, command, parameters, token);
if (result.error) if (result.error)
throw new Error(`Send command error. ${result.message}`); throw new Error(`Send command error. ${result.message}`);
return result; 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 ( return (
<VehicleContext.Provider <VehicleContext.Provider
value={{ value={{
@@ -121,7 +109,6 @@ export const VehicleProvider = ({ children }) => {
getModels, getModels,
getYears, getYears,
sendCommand, sendCommand,
sendLogFilter,
}} }}
> >
{children} {children}

View File

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

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

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ exports[`SideMenu Authenticated 1`] = `
<span <span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock" class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
> >
View Packages Deploy Packages
</span> </span>
</div> </div>
<span <span
@@ -118,6 +118,28 @@ exports[`SideMenu Authenticated 1`] = `
/> />
</a> </a>
</li> </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> </ul>
</div> </div>
</div> </div>

View File

@@ -17,6 +17,7 @@ const CarUpdatesDeploy = React.lazy(() => import("../CarUpdates/Deploy"));
const CarUpdatesStatus = React.lazy(() => import("../CarUpdates/Status")); const CarUpdatesStatus = React.lazy(() => import("../CarUpdates/Status"));
const CarUpdates = React.lazy(() => import("../Cars/Status")); const CarUpdates = React.lazy(() => import("../Cars/Status"));
const VehiclesList = React.lazy(() => import("../Cars/List")); const VehiclesList = React.lazy(() => import("../Cars/List"));
const SendCommandBulk = React.lazy(() => import("../Cars/SendCommandBulk"));
const SiteRoutes = () => { const SiteRoutes = () => {
const { token, groups } = useUserContext(); const { token, groups } = useUserContext();
@@ -101,6 +102,14 @@ const SiteRoutes = () => {
groups={groups} groups={groups}
roles={[Roles.READ, Roles.CREATE]} roles={[Roles.READ, Roles.CREATE]}
/> />
<AuthRoute
path="/vehicles-command"
render={() => <SendCommandBulk />}
type={TYPES.PROTECTED}
token={token}
groups={groups}
roles={[Roles.CREATE]}
/>
<PageNotFound /> <PageNotFound />
</Switch> </Switch>
</Suspense> </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 <div
class="makeStyles-paper-3" class="makeStyles-paper-3"
> >
<h1
class="MuiTypography-root MuiTypography-h5"
>
Create Update Package
</h1>
<form <form
action="{onSubmit}" action="{onSubmit}"
class="makeStyles-form-5" class="makeStyles-form-5"
@@ -51,10 +46,10 @@ exports[`File Upload Form Should render 1`] = `
/> />
<fieldset <fieldset
aria-hidden="true" aria-hidden="true"
class="PrivateNotchedOutline-root-26 MuiOutlinedInput-notchedOutline" class="PrivateNotchedOutline-root-31 MuiOutlinedInput-notchedOutline"
> >
<legend <legend
class="PrivateNotchedOutline-legendLabelled-28" class="PrivateNotchedOutline-legendLabelled-33"
> >
<span> <span>
Package name Package name
@@ -97,10 +92,10 @@ exports[`File Upload Form Should render 1`] = `
/> />
<fieldset <fieldset
aria-hidden="true" aria-hidden="true"
class="PrivateNotchedOutline-root-26 MuiOutlinedInput-notchedOutline" class="PrivateNotchedOutline-root-31 MuiOutlinedInput-notchedOutline"
> >
<legend <legend
class="PrivateNotchedOutline-legendLabelled-28" class="PrivateNotchedOutline-legendLabelled-33"
> >
<span> <span>
Version Version
@@ -143,10 +138,10 @@ exports[`File Upload Form Should render 1`] = `
/> />
<fieldset <fieldset
aria-hidden="true" aria-hidden="true"
class="PrivateNotchedOutline-root-26 MuiOutlinedInput-notchedOutline" class="PrivateNotchedOutline-root-31 MuiOutlinedInput-notchedOutline"
> >
<legend <legend
class="PrivateNotchedOutline-legendLabelled-28" class="PrivateNotchedOutline-legendLabelled-33"
> >
<span> <span>
Description Description
@@ -190,10 +185,10 @@ exports[`File Upload Form Should render 1`] = `
/> />
<fieldset <fieldset
aria-hidden="true" aria-hidden="true"
class="PrivateNotchedOutline-root-26 MuiOutlinedInput-notchedOutline" class="PrivateNotchedOutline-root-31 MuiOutlinedInput-notchedOutline"
> >
<legend <legend
class="PrivateNotchedOutline-legendLabelled-28" class="PrivateNotchedOutline-legendLabelled-33"
> >
<span> <span>
Release Notes URL Release Notes URL

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ const useStyles = makeStyles((theme) => ({
padding: theme.spacing(2, 4, 3), padding: theme.spacing(2, 4, 3),
}, },
paper: { paper: {
marginTop: theme.spacing(8), marginTop: theme.spacing(1),
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
@@ -105,7 +105,7 @@ const useStyles = makeStyles((theme) => ({
padding: theme.spacing(0, 1), padding: theme.spacing(0, 1),
// necessary for content to be below app bar // necessary for content to be below app bar
...theme.mixins.toolbar, ...theme.mixins.toolbar,
justifyContent: "flex-end", justifyContent: "flex-start",
}, },
content: { content: {
flexGrow: 1, flexGrow: 1,
@@ -134,6 +134,34 @@ const useStyles = makeStyles((theme) => ({
textDecorationLine: "underline", textDecorationLine: "underline",
color: "Blue", 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; export default useStyles;

View File

@@ -25,14 +25,11 @@ const vehiclesAPI = {
data: [2021, 2022], data: [2021, 2022],
}; };
}, },
sendCommand: async (vin, command, token) => { sendCommand: async (vin, command, parameters, token) => {
return { return {
vin, command, vin, command, parameters
} }
}, },
sendLog: async (vin, filter, token) => {
return Object.assign(filter, {vin});
},
}; };
export default vehiclesAPI; export default vehiclesAPI;

View File

@@ -1,41 +1,68 @@
const LockUnlockParams = [
{
value: "LOCK",
label: "Lock",
},
{
value: "UNLOCK",
label: "Unlock",
},
];
const OpenCloseParams = [
{
value: "OPEN",
label: "Open",
},
{
value: "CLOSE",
label: "Close",
},
];
const Commands = [{ const Commands = [{
value: "LFRD", value: "LOG",
label: "Lock front right door", label: "Log level",
parameters: [
{
value: "INFO",
label: "Info",
},
{
value: "DEBUG",
label: "Debug",
},
{
value: "TRACE",
label: "Trace",
},
],
},{ },{
value: "UFRD", value: "FRONT-RIGHT",
label: "Unlock front right door", label: "Front right door",
parameters: LockUnlockParams,
},{ },{
value: "LFLD", value: "FRONT-LEFT",
label: "Lock front left door", label: "Front left door",
parameters: LockUnlockParams,
},{ },{
value: "UFLD", value: "REAR-RIGHT",
label: "Unlock front left door", label: "Rear right door",
parameters: LockUnlockParams,
},{ },{
value: "LRRD", value: "REAR-LEFT",
label: "Lock rear right door", label: "Rear left door",
parameters: LockUnlockParams,
},{ },{
value: "URRD", value: "TRUNK",
label: "Unlock rear right door", label: "Trunk",
parameters: LockUnlockParams,
},{ },{
value: "LRLD", value: "WINDOWS",
label: "Lock rear left door", label: "Windows",
parameters: OpenCloseParams,
},{ },{
value: "URLD", value: "FLASH-HEADLIGHTS",
label: "Unlock rear left door",
},{
value: "LTRK",
label: "Lock trunk",
},{
value: "UTRK",
label: "Unlock trunk",
},{
value: "OWIN",
label: "Open Windows",
},{
value: "CWIN",
label: "Close Windows",
},{
value: "FLASH",
label: "Flash headlights", label: "Flash headlights",
}]; }];

View File

@@ -31,22 +31,14 @@ const vehiclesAPI = {
}) })
.then(fetchRespHandler), .then(fetchRespHandler),
sendCommand: async (vin, command, token) => fetch(`${API_ENDPOINT}/vehiclecommand`, { sendCommand: async (vins, command, parameters, token) => fetch(`${API_ENDPOINT}/vehiclecommand`, {
method: "POST", method: "POST",
headers: Object.assign({ "Content-Type": "application/json" }, getAuthHeaderOptions(token)), headers: Object.assign({ "Content-Type": "application/json" }, getAuthHeaderOptions(token)),
body: JSON.stringify({ body: JSON.stringify({
vin, command, vins, command, parameters
}), }),
}) })
.then(fetchRespHandler), .then(fetchRespHandler),
sendLog: async (vin, filter, token) => fetch(`${API_ENDPOINT}/vehiclelog`, {
method: "POST",
headers: Object.assign({ "Content-Type": "application/json" }, getAuthHeaderOptions(token)),
body: JSON.stringify(Object.assign(filter, {vin})),
})
.then(fetchRespHandler),
}; };
export default vehiclesAPI; export default vehiclesAPI;