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:
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export { useTimeoutState } from "./useTimeoutState";
|
||||||
export { useUpdateManifest } from "./useUpdateManifest";
|
export { useUpdateManifest } from "./useUpdateManifest";
|
||||||
|
|||||||
21
src/hooks/useTimeoutState.js
Normal file
21
src/hooks/useTimeoutState.js
Normal 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
13
src/utils/truncateCSV.js
Normal 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`;
|
||||||
|
}
|
||||||
18
src/utils/truncateCSV.test.js
Normal file
18
src/utils/truncateCSV.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user