CEC-5150: search on fleets in bulk action (#454)

* CEC-5150: search on fleets in bulk action

* add deps

* add deps

* sonar

* sonar

* break out fetch
This commit is contained in:
Tristan Timblin
2023-10-02 13:11:48 -07:00
committed by GitHub
parent 8d867a7a1e
commit f4d45abfca
8 changed files with 127 additions and 64 deletions

View File

@@ -1,23 +1,20 @@
import { useEffect, useState, forwardRef, useImperativeHandle } from "react"; import { useState, forwardRef, useImperativeHandle } from "react";
import { import {
FormControl, FormControl,
InputLabel,
MenuItem,
Select,
} from '@material-ui/core'; } from '@material-ui/core';
import SearchSelect from "../../SearchSelect/SearchSelect";
import { useStatusContext } from "../../Contexts/StatusContext"; import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext"; import { useUserContext } from "../../Contexts/UserContext";
import fleetsAPI from "../../../services/fleetsAPI"; import fleetsAPI from "../../../services/fleetsAPI";
export default forwardRef(({ export default forwardRef(({
vins, ids,
vinCSV, idCSV,
}, ref) => { }, ref) => {
const { setMessage } = useStatusContext(); const { setMessage } = useStatusContext();
const { token: { idToken: { jwtToken: token } } } = useUserContext(); const { token: { idToken: { jwtToken: token } } } = useUserContext();
const [fleet, setFleet] = useState(""); const [fleet, setFleet] = useState(null);
const [options, setOptions] = useState([]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
async submit() { async submit() {
@@ -27,7 +24,7 @@ export default forwardRef(({
} }
return fleetsAPI return fleetsAPI
.addFleetVehicles(fleet, { vins }, token) .addFleetVehicles(fleet, { vins: ids }, token)
.then((response) => { .then((response) => {
if (response.error) { if (response.error) {
setMessage(`${response.error}: ${response.message}`); setMessage(`${response.error}: ${response.message}`);
@@ -46,54 +43,32 @@ export default forwardRef(({
}, },
})); }));
useEffect(() => { async function searchFleets(search) {
const controller = new AbortController(); return fleetsAPI
let isMounted = true;
fleetsAPI
.getFleets({ .getFleets({
search: "", search,
limit: 10, limit: 10,
offset: 0, offset: 0,
order: `id desc`, order: `id desc`,
}, token, controller) }, token)
.then(({ data }) => { .then(response => response.data.map(fleet => fleet.name))
if (isMounted) { .catch(() => []);
setOptions(data.map((fleet) => fleet.name));
}
});
return () => {
controller?.abort();
isMounted = false;
}
}, [token]);
const handleChange = (event) => {
setFleet(event.target.value);
} }
return ( return (
<div> <div>
<p> <p>
You are adding the following VINs to a fleet: {vinCSV}. You are adding the following VINs to a fleet: {idCSV}.
</p> </p>
{options && ( <FormControl variant="filled" fullWidth={true}>
<FormControl variant="filled" fullWidth={true}> <SearchSelect
<InputLabel id="fleet-selection"> label="Fleet"
Fleet value={fleet}
</InputLabel> setValue={setFleet}
<Select getData={searchFleets}
labelId="fleet-selection" research={true}
value={fleet} />
label="Fleet" </FormControl>
onChange={handleChange}
>
{options.map((option) => (
<MenuItem key={option} value={option}>{option}</MenuItem>
))}
</Select>
</FormControl>
)}
</div> </div>
); );
}); });

View File

@@ -3,6 +3,7 @@ import { hasRole, Permissions } from "../../utils/roles";
import DropDownButton from "../Controls/DropDownButton"; import DropDownButton from "../Controls/DropDownButton";
import { useUserContext } from "../Contexts/UserContext"; import { useUserContext } from "../Contexts/UserContext";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import truncateCSV from "../../utils/truncateCSV";
// Code-splitting individual actions // Code-splitting individual actions
// https://react.dev/reference/react/lazy // https://react.dev/reference/react/lazy
@@ -71,7 +72,7 @@ export default function BulkActions({
const payload = { const payload = {
ids, ids,
idCSV: (ids && ids.length > 0) ? ids.join(", ") : "N/A", idCSV: (ids && ids.length > 0) ? truncateCSV(ids, 10) : "N/A",
ref: activeRef ref: activeRef
}; };

View File

@@ -242,7 +242,7 @@ const MainForm = () => {
label="SUMS Version" label="SUMS Version"
value={sumsVersion} value={sumsVersion}
setValue={setSumsVersion} setValue={setSumsVersion}
getData={async () => getSums()} getData={getSums}
/> />
<TextField <TextField
id="model" id="model"

View File

@@ -1,36 +1,59 @@
import * as React from 'react'; import * as React from 'react';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Autocomplete from '@mui/material/Autocomplete'; import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete';
import CircularProgress from '@mui/material/CircularProgress';
export default function SearchSelect({ export default function SearchSelect({
label = "", label = "",
value = "", value = "",
setValue = () => { }, setValue = () => { },
getData = () => [], getData = () => new Promise(),
research = false, research = false,
}) { }) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [searchCount, setSearchCount] = React.useState(0); const [loading, setLoading] = React.useState(false);
const [inputValue, setInputValue] = React.useState(""); const [inputValue, setInputValue] = React.useState("");
const [options, setOptions] = React.useState([{ label: value }]); const [options, setOptions] = React.useState([]);
const [searchComplete, setSearchComplete] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
function canSearch() { if (!loading || searchComplete) {
if (research || searchCount === 0) { return undefined;
return true;
}
return false;
} }
async function fetchData() { const debounce = setTimeout(async () => {
setOptions(await getData(value)); const data = await getData(inputValue);
setOptions(data);
if (!research) setSearchComplete(true);
}, research ? 500 : 0); // reduce queries while typing
return () => {
clearTimeout(debounce);
};
}, [research, loading, inputValue, getData, setOptions, searchComplete, setSearchComplete]);
React.useEffect(() => {
if (!research || searchComplete) {
return undefined;
} }
if (!open && canSearch()) { setOptions([]);
fetchData(); }, [research, open, inputValue, setOptions, searchComplete]);
setSearchCount((searchCount) => searchCount + 1);
React.useEffect(() => {
if (searchComplete) {
return undefined;
} }
}, [open, value, research, searchCount, getData]);
setLoading(open && options.length === 0);
const timeout = setTimeout(() => {
setLoading(false);
}, 2000); // don't show loading forever
return () => {
clearTimeout(timeout);
}
}, [searchComplete, open, options, setLoading]);
return ( return (
<Autocomplete <Autocomplete
@@ -43,6 +66,8 @@ export default function SearchSelect({
onOpen={() => setOpen(true)} onOpen={() => setOpen(true)}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
options={options} options={options}
loading={loading}
filterOptions={research ? (x) => x : createFilterOptions()}
renderInput={(params) => renderInput={(params) =>
<TextField <TextField
{...params} {...params}
@@ -50,6 +75,15 @@ export default function SearchSelect({
name={label} name={label}
variant="outlined" variant="outlined"
margin="normal" margin="normal"
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/> />
} }
/> />

View File

@@ -1 +1,2 @@
export { useTimeoutState } from "./useTimeoutState";
export { useUpdateManifest } from "./useUpdateManifest"; export { useUpdateManifest } from "./useUpdateManifest";

View File

@@ -0,0 +1,21 @@
import { useCallback, useState } from "react";
export const useTimeoutState = (defaultState) => {
const [state, _setState] = useState(defaultState);
const [currentTimeoutId, setCurrentTimeoutId] = useState();
const setState = useCallback(
(action, opts) => {
if (currentTimeoutId != null) {
clearTimeout(currentTimeoutId);
}
_setState(action);
const id = setTimeout(() => _setState(defaultState), opts?.timeout);
setCurrentTimeoutId(id);
},
[currentTimeoutId, defaultState]
);
return [state, setState];
};

13
src/utils/truncateCSV.js Normal file
View File

@@ -0,0 +1,13 @@
export default function truncateCSV(strings = [], max = Infinity) {
const count = strings.length;
if (max === 0) {
return `${count}`;
}
if (count <= max) {
return strings.join(", ");
}
return `${strings.slice(0, max).join(", ")}, +${count - max} more`;
}

View File

@@ -0,0 +1,18 @@
import truncateCSV from "./truncateCSV";
const tests = [
[["ocean", "pear", "ronin", "alaska"], 4, "ocean, pear, ronin, alaska"],
[["ocean", "pear", "ronin", "alaska"], 3, "ocean, pear, ronin, +1 more"],
[["ocean", "pear", "ronin", "alaska"], 2, "ocean, pear, +2 more"],
[["ocean", "pear", "ronin", "alaska"], 0, "4"],
[["ocean", "pear", "ronin", "alaska"], 6, "ocean, pear, ronin, alaska"],
[["ocean", "pear", "ronin", "alaska"], Infinity, "ocean, pear, ronin, alaska"],
];
describe("truncateCSV", () => {
it("properly concatenates", () => {
tests.forEach(([strings, max, expected]) => {
expect(truncateCSV(strings, max)).toBe(expected);
});
});
});