CEC-4525: add support for /tags endpoint and implement a new action for it (#361)

* add action for adding tags
This commit is contained in:
Tristan Timblin
2023-06-16 11:48:48 -07:00
committed by GitHub
parent 9ae3ef0e2e
commit 7c358a6052
10 changed files with 308 additions and 10 deletions

View File

@@ -9,6 +9,7 @@ jest.mock("../../services/vehiclesAPI");
jest.mock("../../services/superset");
jest.mock("../../services/suppliersAPI");
jest.mock("../../services/issueAPI");
jest.mock("../TransformModal");
import {
act, cleanup, render,

View File

@@ -12652,6 +12652,12 @@ exports[`App Route /vehicles authenticated 1`] = `
</tfoot>
</table>
</div>
<div
data-testid="transform-modal"
/>
<div
data-testid="transform-modal"
/>
</div>
</div>
</main>

View File

@@ -30,8 +30,15 @@ const MainForm = () => {
type: "boolean",
value: false
},
})
const [showUpdateConfigModal, setShowUpdateConfigModal] = useState(false);
});
const [tagsToAdd, setTagsToAdd] = useState({
tags: {
label: "Tags",
type: "list.string",
value: [],
},
});
const [activeModal, setActiveModal] = useState(null);
const { setTitle, setSitePath, setMessage } = useStatusContext();
const {
token: {
@@ -80,12 +87,23 @@ const MainForm = () => {
selectedVins.forEach((vin, i) => taskRunner.push(request(vin, i)))
}
const handleAddTags = async (fn) => {
await fn(selectedVins, tagsToAdd.tags.value, token)
.then(() => setMessage(`Added ${tagsToAdd.tags.value.length} tags to ${selectedVins.length} vehicles.`))
.catch((error) => setMessage(error.message));
};
const actions = [
{
name: "Update Configs",
disabled: selectedVins.length === 0,
trigger: () => setShowUpdateConfigModal(true),
trigger: () => setActiveModal("updateConfig"),
},
{
name: "Add Tags",
disabled: selectedVins.length === 0,
trigger: () => setActiveModal("addTags"),
}
];
const handleOnlineHMI = (event) => {
@@ -150,17 +168,26 @@ const MainForm = () => {
onSelectAll={handleSelectAll}
/>
<VehicleConsumer>
{(context) => (
{(context) => (<>
<TransformModal
open={showUpdateConfigModal}
close={() => setShowUpdateConfigModal(false)}
open={activeModal === "updateConfig"}
close={() => setActiveModal(null)}
title="Update Configs"
body={`You are updating the config for the following VINs: ${selectedVins.join(", ")}.`}
data={config}
setData={setConfig}
submit={() => handleUploadConfig(context.uploadConfig)}
/>
)}
<TransformModal
open={activeModal === "addTags"}
close={() => setActiveModal(null)}
title="Add Tags"
body={`You are adding tags for the following VINs: ${selectedVins.join(", ")}.`}
data={tagsToAdd}
setData={setTagsToAdd}
submit={() => handleAddTags(context.addTags)}
/>
</>)}
</VehicleConsumer>
</div>
);

View File

@@ -61,6 +61,22 @@ export const VehicleProvider = ({ children }) => {
}
};
const addTags = async (vins, tags, token) => {
try {
setBusy(true);
vins.forEach(vin => validateVIN(vin));
const validateTags = tags.every(tag => typeof tag === "string");
if (!validateTags)
throw new Error("Invalid Tag");
const result = await api.addTags(vins, tags, token)
if (result.error)
throw new Error(`Add tags error. ${result.message}`);
} finally {
setBusy(false)
}
}
const getConnections = async (vins, token) => {
try {
setBusy(true);
@@ -301,6 +317,7 @@ export const VehicleProvider = ({ children }) => {
getFleets,
getVersionLog,
uploadConfig,
addTags,
}}
>
{children}

View File

@@ -0,0 +1,58 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DropDownButton Render 1`] = `
<div>
<div>
<div
class="MuiFormControl-root MuiFormControl-marginNormal MuiFormControl-fullWidth MuiTextField-root css-17vbkzs-MuiFormControl-root-MuiTextField-root"
data-testid="text-input-list"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-sizeSmall MuiInputLabel-outlined MuiFormLabel-colorPrimary Mui-required MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-sizeSmall MuiInputLabel-outlined css-1pysi21-MuiFormLabel-root-MuiInputLabel-root"
data-shrink="false"
for="mui-0"
id="mui-0-label"
>
The input label (use commas to add multiple)
<span
aria-hidden="true"
class="MuiFormLabel-asterisk MuiInputLabel-asterisk css-wgai2y-MuiFormLabel-asterisk"
>
*
</span>
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-colorPrimary MuiInputBase-fullWidth MuiInputBase-formControl MuiInputBase-sizeSmall css-md26zr-MuiInputBase-root-MuiOutlinedInput-root"
>
<input
aria-invalid="false"
class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputSizeSmall css-1n4twyu-MuiInputBase-input-MuiOutlinedInput-input"
id="mui-0"
name="text"
required=""
type="text"
value=""
/>
<fieldset
aria-hidden="true"
class="MuiOutlinedInput-notchedOutline css-1d3z3hw-MuiOutlinedInput-notchedOutline"
>
<legend
class="css-yjsfm1"
>
<span>
The input label (use commas to add multiple)
*
</span>
</legend>
</fieldset>
</div>
</div>
<div
class="MuiBox-root css-6km64s"
/>
</div>
</div>
`;

View File

@@ -0,0 +1,97 @@
import { useState, useEffect } from "react";
import { Cancel } from "@mui/icons-material";
import { TextField } from "@mui/material";
import { Box } from "@mui/system";
import { useStatusContext } from "../../Contexts/StatusContext";
const TextInput = ({ text, handleDelete }) => {
return (
<Box sx={{
display: "flex",
alignItems: "center",
gap: "2px",
p: "2px 4px",
backgroundColor: "#ccc",
borderRadius: 1,
}}>
{text}
<Cancel
sx={{
color: "#666",
fontSize: "16px",
cursor: "pointer",
}}
onClick={() => handleDelete(text)}
/>
</Box>
);
}
const TextInputList = ({
onChange = () => {},
validate = () => {},
label
}) => {
const [textList, setTextList] = useState([]);
const [input, setInput] = useState("");
const { setMessage } = useStatusContext();
const handleDelete = (textToDelete) => {
setTextList(textList => textList.filter(text => text !== textToDelete));
}
const handleOnChange = (event) => {
const char = event.nativeEvent.data;
if (char === ",") {
try {
if (validate) validate(input);
setTextList(textList => [...textList, input]);
setInput("");
} catch {
setMessage(`"${input}" is not valid.`);
}
} else {
setInput(event.target.value);
}
}
useEffect(() => {
onChange(input.length ? [...textList, input] : [...textList]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textList, input]);
return (
<div>
<TextField
data-testid="text-input-list"
name="text"
label={`${label} (use commas to add multiple)`}
value={input}
variant="outlined"
margin="normal"
required
size="small"
fullWidth
onChange={handleOnChange}
/>
<Box sx={{
display: "flex",
flexWrap: "wrap",
gap: 1,
pr: 1,
}}>
{textList.map((text) => (
<TextInput
text={text}
handleDelete={handleDelete}
key={text}
/>
))}
</Box>
</div>
);
};
export default TextInputList;

View File

@@ -0,0 +1,57 @@
jest.mock("../../Contexts/StatusContext");
import React from "react";
import { render, waitFor } from "@testing-library/react";
import userEvent from '@testing-library/user-event';
import TextInputList from ".";
import addSnapshotSerializer from "../../../utils/snapshot";
describe("DropDownButton", () => {
beforeAll(() => {
addSnapshotSerializer(expect);
});
it("Render", async () => {
const { container } = render(
<TextInputList
label={"The input label"}
/>
);
await waitFor(() => {
/* render */
});
expect(container).toMatchSnapshot();
});
it("properly adds tag after comma", async () => {
const { getByText, getByTestId } = render(
<TextInputList
label={"The input label"}
payload={[]}
/>
);
const [inputEl] = getByTestId("text-input-list").getElementsByTagName("input");
userEvent.type(inputEl, "tag1");
userEvent.type(inputEl, ",");
expect(getByText("tag1").nodeName).toBe("DIV");
});
it("properly passes payload to callback", async () => {
const mockCallback = jest.fn();
const { getByTestId } = render(
<TextInputList
label={"The input label"}
onChange={mockCallback}
/>
);
const [inputEl] = getByTestId("text-input-list").getElementsByTagName("input");
userEvent.type(inputEl, "tag1");
userEvent.type(inputEl, ",");
userEvent.type(inputEl, "tag2");
expect(mockCallback).toHaveBeenCalledWith(["tag1", "tag2"]);
});
});

View File

@@ -0,0 +1,5 @@
const TransformModalMock = jest.fn().mockImplementation(() => {
return <div data-testid="transform-modal" />
});
export default TransformModalMock;

View File

@@ -10,6 +10,7 @@ import {
FormGroup,
FormControlLabel,
} from '@material-ui/core';
import TextInputList from "../Controls/TextInputList";
const TransformModal = ({
open,
@@ -25,16 +26,24 @@ const TransformModal = ({
submit();
};
const handleChange = (key) => {
const handleChange = (key, value) => {
setData((data) => {
const {[key]: toChange, ...rest} = data;
switch (data[key].type) {
case "boolean":
toChange.value = !toChange.value;
break;
case "list.string":
toChange.value = value;
break;
default:
}
return {
[key]: toChange,
...rest
};
});
}
};
return (
<Dialog
@@ -64,6 +73,14 @@ const TransformModal = ({
}
/>
)
case "list.string":
return (
<TextInputList
key={key}
label={value.label}
onChange={(list) => handleChange(key, list)}
/>
)
default:
return <></>;
}
@@ -72,6 +89,7 @@ const TransformModal = ({
</DialogContent>
<DialogActions>
<Button
label="Test"
onClick={close}
>
Cancel

View File

@@ -17,6 +17,18 @@ const vehiclesAPI = {
.then(fetchRespHandler)
.catch(errorHandler),
addTags: async (vins, tags, token) =>
fetch(`${API_ENDPOINT}/tags`, {
method: "PUT",
headers: Object.assign(
{ "Content-Type": "application/json" },
getAuthHeaderOptions(token),
),
body: JSON.stringify({ vins, tags }),
})
.then(fetchRespHandler)
.catch(errorHandler),
deleteVehicle: async (vin, token) =>
fetch(`${API_ENDPOINT}/vehicle/${vin}`, {
method: "DELETE",