diff --git a/.env.cec-euprd b/.env.cec-euprd index 74cb850..5eac964 100644 --- a/.env.cec-euprd +++ b/.env.cec-euprd @@ -15,6 +15,8 @@ REACT_APP_ROLE_GENERATE_CERTIFICATE=9af2d8c0-c26d-4d6d-bbd1-ac53cbd37ebc REACT_APP_ROLE_MANUFACTURE=3412e11a-a2d1-4355-be3e-ef9aa5065b69 REACT_APP_ROLE_SUPPLIER_APPROVER=a6c9805e-80b2-42b2-bfbb-9df52e5504d8 REACT_APP_ROLE_MANIFEST_MIGRATION=42798c8a-9fa7-4fb4-82c0-9582cabe364f +REACT_APP_ROLE_CAR_DIAGNOSTIC=2914e67f-fb85-4b78-b79d-656f4f37faa1 +REACT_APP_ROLE_UPDATE_DEPLOY=e4af2c4c-6c5e-4784-9097-7c18e776d7b6 REACT_APP_ECCKEY_ENV= REACT_APP_HOME_MAP_DEFAULT_LOCATION={"lat":49.8327,"lng":9.8816,"zoom":4.5} -REACT_APP_ENABLE_DEBUGMASK=1 \ No newline at end of file +REACT_APP_ENABLE_DEBUGMASK=1 diff --git a/.env.cec-prd b/.env.cec-prd index 074549f..101dd7f 100644 --- a/.env.cec-prd +++ b/.env.cec-prd @@ -15,6 +15,8 @@ REACT_APP_ROLE_GENERATE_CERTIFICATE=9af2d8c0-c26d-4d6d-bbd1-ac53cbd37ebc REACT_APP_ROLE_MANUFACTURE=3412e11a-a2d1-4355-be3e-ef9aa5065b69 REACT_APP_ROLE_SUPPLIER_APPROVER=a6c9805e-80b2-42b2-bfbb-9df52e5504d8 REACT_APP_ROLE_MANIFEST_MIGRATION=42798c8a-9fa7-4fb4-82c0-9582cabe364f +REACT_APP_ROLE_CAR_DIAGNOSTIC=2914e67f-fb85-4b78-b79d-656f4f37faa1 +REACT_APP_ROLE_UPDATE_DEPLOY=e4af2c4c-6c5e-4784-9097-7c18e776d7b6 REACT_APP_ECCKEY_ENV= REACT_APP_HOME_MAP_DEFAULT_LOCATION={"lat":37.0902,"lng":-95.7129,"zoom":4.5} -REACT_APP_ENABLE_DEBUGMASK=1 \ No newline at end of file +REACT_APP_ENABLE_DEBUGMASK=1 diff --git a/.env.dev b/.env.dev index 7fdf5cb..92dd8fc 100644 --- a/.env.dev +++ b/.env.dev @@ -15,6 +15,8 @@ REACT_APP_ROLE_GENERATE_CERTIFICATE=746f34b0-9ba0-4b5d-8d84-0256a9c8e390 REACT_APP_ROLE_MANUFACTURE=3412e11a-a2d1-4355-be3e-ef9aa5065b69 REACT_APP_ROLE_SUPPLIER_APPROVER=a6c9805e-80b2-42b2-bfbb-9df52e5504d8 REACT_APP_ROLE_MANIFEST_MIGRATION=42798c8a-9fa7-4fb4-82c0-9582cabe364f +REACT_APP_ROLE_CAR_DIAGNOSTIC=2914e67f-fb85-4b78-b79d-656f4f37faa1 +REACT_APP_ROLE_UPDATE_DEPLOY=3590ec3f-1c05-428b-81a4-40b00baf83de REACT_APP_ECCKEY_ENV=stage,prod REACT_APP_HOME_MAP_DEFAULT_LOCATION={"lat":37.0902,"lng":-95.7129,"zoom":4.5} -REACT_APP_ENABLE_DEBUGMASK=1 \ No newline at end of file +REACT_APP_ENABLE_DEBUGMASK=1 diff --git a/.env.local b/.env.local index 2311225..19ba31b 100644 --- a/.env.local +++ b/.env.local @@ -15,6 +15,8 @@ REACT_APP_ROLE_GENERATE_CERTIFICATE=746f34b0-9ba0-4b5d-8d84-0256a9c8e390 REACT_APP_ROLE_MANUFACTURE=3412e11a-a2d1-4355-be3e-ef9aa5065b69 REACT_APP_ROLE_SUPPLIER_APPROVER=a6c9805e-80b2-42b2-bfbb-9df52e5504d8 REACT_APP_ROLE_MANIFEST_MIGRATION=42798c8a-9fa7-4fb4-82c0-9582cabe364f +REACT_APP_ROLE_UPDATE_DEPLOY=3590ec3f-1c05-428b-81a4-40b00baf83de +REACT_APP_ROLE_CAR_DIAGNOSTIC=2914e67f-fb85-4b78-b79d-656f4f37faa1 REACT_APP_ECCKEY_ENV=dev,stage,prod REACT_APP_HOME_MAP_DEFAULT_LOCATION={"lat":37.0902,"lng":-95.7129,"zoom":4.5} -REACT_APP_ENABLE_DEBUGMASK=1 \ No newline at end of file +REACT_APP_ENABLE_DEBUGMASK=1 diff --git a/.env.prd b/.env.prd index 878fe6b..d84d254 100644 --- a/.env.prd +++ b/.env.prd @@ -15,6 +15,8 @@ REACT_APP_ROLE_GENERATE_CERTIFICATE=746f34b0-9ba0-4b5d-8d84-0256a9c8e390 REACT_APP_ROLE_MANUFACTURE=3412e11a-a2d1-4355-be3e-ef9aa5065b69 REACT_APP_ROLE_SUPPLIER_APPROVER=a6c9805e-80b2-42b2-bfbb-9df52e5504d8 REACT_APP_ROLE_MANIFEST_MIGRATION=42798c8a-9fa7-4fb4-82c0-9582cabe364f +REACT_APP_ROLE_CAR_DIAGNOSTIC=2914e67f-fb85-4b78-b79d-656f4f37faa1 +REACT_APP_ROLE_UPDATE_DEPLOY=e4af2c4c-6c5e-4784-9097-7c18e776d7b6 REACT_APP_ECCKEY_ENV=stage REACT_APP_HOME_MAP_DEFAULT_LOCATION={"lat":37.0902,"lng":-95.7129,"zoom":4.5} -REACT_APP_ENABLE_DEBUGMASK=1 \ No newline at end of file +REACT_APP_ENABLE_DEBUGMASK=1 diff --git a/.env.stg b/.env.stg index df9db72..e6c8a39 100644 --- a/.env.stg +++ b/.env.stg @@ -15,6 +15,8 @@ REACT_APP_ROLE_GENERATE_CERTIFICATE=746f34b0-9ba0-4b5d-8d84-0256a9c8e390 REACT_APP_ROLE_MANUFACTURE=3412e11a-a2d1-4355-be3e-ef9aa5065b69 REACT_APP_ROLE_SUPPLIER_APPROVER=a6c9805e-80b2-42b2-bfbb-9df52e5504d8 REACT_APP_ROLE_MANIFEST_MIGRATION=42798c8a-9fa7-4fb4-82c0-9582cabe364f +REACT_APP_ROLE_CAR_DIAGNOSTIC=2914e67f-fb85-4b78-b79d-656f4f37faa1 +REACT_APP_ROLE_UPDATE_DEPLOY=3590ec3f-1c05-428b-81a4-40b00baf83de REACT_APP_ECCKEY_ENV=prod REACT_APP_HOME_MAP_DEFAULT_LOCATION={"lat":37.0902,"lng":-95.7129,"zoom":4.5} -REACT_APP_ENABLE_DEBUGMASK=1 \ No newline at end of file +REACT_APP_ENABLE_DEBUGMASK=1 diff --git a/.env.template b/.env.template index 517bef2..6f453b7 100644 --- a/.env.template +++ b/.env.template @@ -15,5 +15,7 @@ REACT_APP_ROLE_GENERATE_CERTIFICATE=746f34b0-9ba0-4b5d-8d84-0256a9c8e390 REACT_APP_ROLE_MANUFACTURE=3412e11a-a2d1-4355-be3e-ef9aa5065b69 REACT_APP_ROLE_SUPPLIER_APPROVER=a6c9805e-80b2-42b2-bfbb-9df52e5504d8 REACT_APP_ROLE_MANIFEST_MIGRATION=42798c8a-9fa7-4fb4-82c0-9582cabe364f +REACT_APP_ROLE_CAR_DIAGNOSTIC=2914e67f-fb85-4b78-b79d-656f4f37faa1 +REACT_APP_ROLE_UPDATE_DEPLOY=3590ec3f-1c05-428b-81a4-40b00baf83de REACT_APP_ECCKEY_ENV=dev,stage,prod REACT_APP_HOME_MAP_DEFAULT_LOCATION={"lat":37.0902,"lng":-95.7129,"zoom":4.5} diff --git a/package-lock.json b/package-lock.json index b27b5b3..7d007ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "react-router-dom": "^5.3.0", "react-router-hash-link": "^2.4.3", "react-scripts": "5.0.0", + "semver-compare": "^1.0.0", "usehooks-ts": "^2.7.1", "web-vitals": "^2.1.4", "webpack": "^5.74.0" @@ -15130,6 +15131,11 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==" + }, "node_modules/send": { "version": "0.17.2", "resolved": "https://registry.npmjs.org/send/-/send-0.17.2.tgz", @@ -28020,6 +28026,11 @@ "lru-cache": "^6.0.0" } }, + "semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==" + }, "send": { "version": "0.17.2", "resolved": "https://registry.npmjs.org/send/-/send-0.17.2.tgz", diff --git a/package.json b/package.json index 5d58712..685bd6c 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "react-router-dom": "^5.3.0", "react-router-hash-link": "^2.4.3", "react-scripts": "5.0.0", + "semver-compare": "^1.0.0", "usehooks-ts": "^2.7.1", "web-vitals": "^2.1.4", "webpack": "^5.74.0" diff --git a/src/components/App/__snapshots__/App.test.js.snap b/src/components/App/__snapshots__/App.test.js.snap index 5a5eea9..e4a3185 100644 --- a/src/components/App/__snapshots__/App.test.js.snap +++ b/src/components/App/__snapshots__/App.test.js.snap @@ -6261,9 +6261,9 @@ exports[`App Route /packages authenticated 1`] = ` class="MuiButtonBase-root MuiToggleButton-root Mui-selected MuiToggleButton-sizeMedium MuiToggleButton-standard MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal css-ueukts-MuiButtonBase-root-MuiToggleButton-root" tabindex="0" type="button" - value="true" + value="software" > - Active + Software @@ -6273,18 +6273,67 @@ exports[`App Route /packages authenticated 1`] = ` class="MuiButtonBase-root MuiToggleButton-root MuiToggleButton-sizeMedium MuiToggleButton-standard MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal css-ueukts-MuiButtonBase-root-MuiToggleButton-root" tabindex="0" type="button" - value="false" + value="archived" > Archived +
+ > +
+ + +
+
+ + @@ -6577,7 +6700,7 @@ exports[`App Route /packages authenticated 1`] = ` >
+ + + + + + + + + + Type + + + - Type + Update + + + + + + + @@ -6494,6 +6632,9 @@ exports[`App Route /packages authenticated 1`] = ` + @@ -6548,24 +6689,6 @@ exports[`App Route /packages authenticated 1`] = ` /> - - -
+
@@ -11427,6 +11551,13 @@ exports[`App Route /vehicle-status authenticated 1`] = ` : false

+

+ + DLT Logging Enabled + + : + false +

- - - - -
+ />
diff --git a/src/components/BulkActions/index.jsx b/src/components/BulkActions/index.jsx new file mode 100644 index 0000000..81f5f3c --- /dev/null +++ b/src/components/BulkActions/index.jsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import TransformModal from "../TransformModal"; +import DropDownButton from "../Controls/DropDownButton"; +import { useUserContext } from "../Contexts/UserContext"; +import { useStatusContext } from "../Contexts/StatusContext"; +import useAddTags from "./useAddTags"; +import useUpdateConfig from "./useUpdateConfig"; + +const transformArrayToCSV = (arr) => arr.join(", "); + +export default function BulkActions({ + vins = [], +}) { + const [vinCSV, setVinCSV] = useState(transformArrayToCSV(vins)); + const [active, setActive] = useState(null); + const actions = [ + { + name: "Update Configs", + disabled: vins.length === 0, + trigger: () => setActive("updateConfig"), + }, + { + name: "Add Tags", + disabled: vins.length === 0, + trigger: () => setActive("addTags"), + }, + ]; + + const updateConfig = useUpdateConfig(); + const addTags = useAddTags(); + + const { setMessage } = useStatusContext(); + const { + token: { + idToken: { jwtToken: token }, + }, + } = useUserContext(); + + const handleUpdateConfig = () => { + updateConfig.submit(vins, token) + .then(() => { + setMessage(`${vins.length} vehicles updated.`); + }) + .catch((error) => { + setMessage(error.message); + }); + } + + const handleAddTags = () => { + addTags.submit(vins, token) + .then(() => setMessage(`Added ${addTags.data.tags.value.length} tags to ${vins.length} vehicles.`)) + .catch((error) => setMessage(error.message)); + } + + const handleClose = () => setActive(null); + + useEffect(() => { + setVinCSV(transformArrayToCSV(vins)); + }, [vins]); + + return ( + <> + + + + + ); +} diff --git a/src/components/BulkActions/useAddTags.js b/src/components/BulkActions/useAddTags.js new file mode 100644 index 0000000..7696595 --- /dev/null +++ b/src/components/BulkActions/useAddTags.js @@ -0,0 +1,22 @@ +import { useState } from "react"; +import vehiclesAPI from "../../services/vehiclesAPI"; + +export default function useAddTags() { + const [tags, setTags] = useState({ + tags: { + label: "Tags", + type: "list.string", + value: [], + }, + }); + + const submit = async (vins, token) => { + return vehiclesAPI.addTags(vins, tags.tags.value, token); + } + + return { + data: tags, + setData: setTags, + submit, + }; +} diff --git a/src/components/BulkActions/useUpdateConfig.js b/src/components/BulkActions/useUpdateConfig.js new file mode 100644 index 0000000..c648bd1 --- /dev/null +++ b/src/components/BulkActions/useUpdateConfig.js @@ -0,0 +1,40 @@ +import { useState } from "react"; +import TaskRunner from "../../utils/taskRunner"; +import vehiclesAPI from "../../services/vehiclesAPI"; + +export default function useUpdateConfig() { + const [config, setConfig] = useState({ + force: { + label: "Force Push", + type: "boolean", + value: false, + }, + }); + + const submit = async (vins, token) => { + return new Promise((resolve, reject) => { + const taskRunner = new TaskRunner(5); + + const task = (vin, isLast) => { + return async () => vehiclesAPI.updateConfig(vin, config.force.value, token) + .then((response) => { + if (isLast) { + if (response.error) { + reject(response); + } + resolve(response) + } + }) + .catch((error) => reject(error)); + } + + vins.forEach((vin, index) => taskRunner.push(task(vin, index === vins.length - 1))); + }); + } + + return { + data: config, + setData: setConfig, + submit, + }; +} \ No newline at end of file diff --git a/src/components/Cars/Status/Details/__snapshots__/index.test.jsx.snap b/src/components/Cars/Status/Details/__snapshots__/index.test.jsx.snap index 7cadd4e..d245fc7 100644 --- a/src/components/Cars/Status/Details/__snapshots__/index.test.jsx.snap +++ b/src/components/Cars/Status/Details/__snapshots__/index.test.jsx.snap @@ -147,6 +147,13 @@ exports[`VehicleDetailsTab Render 1`] = ` : false

+

+ + DLT Logging Enabled + + : + false +

- - - - -
+ />
diff --git a/src/components/Cars/Status/Details/index.jsx b/src/components/Cars/Status/Details/index.jsx index cb8a973..2ef5534 100644 --- a/src/components/Cars/Status/Details/index.jsx +++ b/src/components/Cars/Status/Details/index.jsx @@ -115,38 +115,41 @@ const MainForm = ({ vin }) => { Info Source: {vehicle.info_source}

- Tags: {vehicle.tags ? vehicle.tags.join(", ") : "none" } + Tags: {vehicle.tags ? vehicle.tags.join(", ") : "none"}

- {vehicle.log_level != null && ( + {vehicle.log_level != null && (

Log Level: {vehicle.log_level}

)} - {vehicle.canbus && ( - <> -

- CANBus Enabled: {vehicle.canbus.enabled.toString()} -

-

- Max Memory Buffer Size: {vehicle.canbus.max_mem_buffer_size ?? "Default"} -

-

- Data Logger Enabled: {vehicle.canbus.data_logger_enabled.toString()} -

-

- Max Disk Buffer Size: {vehicle.canbus.max_disk_buffer_size ?? "Default"} -

-

- Filters: {vehicle.canbus.filters ? vehicle.canbus.filters.length : 0} -

-

- DTC Enabled: { (vehicle.canbus.dtc_enabled || false).toString() } -

- - )} -
+ {vehicle.canbus && ( + <> +

+ CANBus Enabled: {vehicle.canbus.enabled.toString()} +

+

+ Max Memory Buffer Size: {vehicle.canbus.max_mem_buffer_size ?? "Default"} +

+

+ Data Logger Enabled: {vehicle.canbus.data_logger_enabled.toString()} +

+

+ Max Disk Buffer Size: {vehicle.canbus.max_disk_buffer_size ?? "Default"} +

+

+ Filters: {vehicle.canbus.filters ? vehicle.canbus.filters.length : 0} +

+

+ DTC Enabled: {(vehicle.canbus.dtc_enabled || false).toString()} +

+

+ DLT Logging Enabled: {(vehicle.dlt_enabled || false).toString()} +

+ + )} + {showDebugMask && (

@@ -156,19 +159,19 @@ const MainForm = ({ vin }) => { )} + groups={groups} + providers={providers} + rolesPerProvider={Permissions.FiskerUpdateDeploy} + > - } + label="Force Config Update" + control={ + + } + /> setShowUploadConfigModal(true)} > diff --git a/src/components/Cars/Status/Details/index.test.jsx b/src/components/Cars/Status/Details/index.test.jsx index b571949..94f6310 100644 --- a/src/components/Cars/Status/Details/index.test.jsx +++ b/src/components/Cars/Status/Details/index.test.jsx @@ -2,24 +2,25 @@ jest.mock("../../../Contexts/VehicleContext"); jest.mock("../../../Contexts/StatusContext"); jest.mock("../../../Contexts/UserContext"); -import { render, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { BrowserRouter } from "react-router-dom"; import routeData from "react-router"; import { VehicleProvider } from "../../../Contexts/VehicleContext"; import { StatusProvider } from "../../../Contexts/StatusContext"; import { UserProvider, setToken } from "../../../Contexts/UserContext"; -import { TEST_AUTH_OBJECT_FISKER }from "../../../../utils/testing"; +import { TEST_AUTH_OBJECT_FISKER } from "../../../../utils/testing"; import MainForm from "./index"; import addSnapshotSerializer from "../../../../utils/snapshot"; +import * as Roles from "../../../../utils/roles"; const renderVehicleDetailsTab = async () => { const { container } = render( - + - + @@ -46,4 +47,23 @@ describe("VehicleDetailsTab", () => { const container = await renderVehicleDetailsTab(); expect(container).toMatchSnapshot(); }); + + it("renders update config control when required permission is present.", () => { + const hasRole = jest.spyOn(Roles, 'hasRole'); + hasRole.mockReturnValue(true); + render( + + + + + + + + + + ); + + expect(screen.getByLabelText("Force Config Update")).toBeTruthy(); + hasRole.mockRestore(); + }) }); diff --git a/src/components/Cars/Status/RemoteDiagnosticCommands.jsx b/src/components/Cars/Status/RemoteDiagnosticCommands.jsx new file mode 100644 index 0000000..cadf990 --- /dev/null +++ b/src/components/Cars/Status/RemoteDiagnosticCommands.jsx @@ -0,0 +1,30 @@ +import useStyles from "../../useStyles"; +import clsx from "clsx"; +import Typography from "@material-ui/core/Typography"; +import SendDiagnosticCommand from "../../Controls/SendDiagnosticCommand"; +import { useParams } from "react-router"; + +import { useUserContext } from "../../Contexts/UserContext"; + +import {VehicleProvider} from "../../Contexts/VehicleContext"; + +const RemoteDiagnosticCommandsTab = (props) => { + const { vin } = useParams(); + const classes = useStyles(); + const { + token: { + idToken: { jwtToken: token }, + }, + } = useUserContext(); + + return ( +

+ Vehicle Diagnostic Commands + + + +
+ ) +} + +export default RemoteDiagnosticCommandsTab diff --git a/src/components/Cars/Status/__snapshots__/DetailsTab.test.jsx.snap b/src/components/Cars/Status/__snapshots__/DetailsTab.test.jsx.snap index fedbdd8..900b45a 100644 --- a/src/components/Cars/Status/__snapshots__/DetailsTab.test.jsx.snap +++ b/src/components/Cars/Status/__snapshots__/DetailsTab.test.jsx.snap @@ -155,6 +155,13 @@ exports[`DetailsTab Render 1`] = ` : false

+

+ + DLT Logging Enabled + + : + false +

- - - - -
+ />
diff --git a/src/components/Cars/Status/__snapshots__/DigitalTwinTab.test.jsx.snap b/src/components/Cars/Status/__snapshots__/DigitalTwinTab.test.jsx.snap index f12a979..c7e54ae 100644 --- a/src/components/Cars/Status/__snapshots__/DigitalTwinTab.test.jsx.snap +++ b/src/components/Cars/Status/__snapshots__/DigitalTwinTab.test.jsx.snap @@ -237,6 +237,17 @@ exports[`DigitalTwinTab Render 1`] = ` 77.7 km/h

+
+

+ + Parked + + : + Yes +

+
- - - - -
+ />
diff --git a/src/components/Cars/Status/index.jsx b/src/components/Cars/Status/index.jsx index fe7bd66..de180d4 100644 --- a/src/components/Cars/Status/index.jsx +++ b/src/components/Cars/Status/index.jsx @@ -18,6 +18,7 @@ import DigitalTwinTab from "./DigitalTwinTab"; import ECUsTab from "./ECUsTab"; import FleetsTab from "./FleetsTab"; import RemoteCommandsTab from "./RemoteCommandsTab"; +import RemoteDiagnosticCommandsTab from "./RemoteDiagnosticCommands"; import TRexLogsTab from "./TRexLogsTab"; const tabHashes = ["details", "updates", "filters"]; @@ -63,6 +64,11 @@ const TabViews = [ component: RemoteCommandsTab, rolesPerProvider: Permissions.FiskerMagnaCreate, }, + { + label: "Remote Diagnostic Commands", + component: RemoteDiagnosticCommandsTab, + rolesPerProvider: Permissions.CarDiagnostic, + }, { label: "Fleets", component: FleetsTab, diff --git a/src/components/Cars/Update/__snapshots__/index.test.jsx.snap b/src/components/Cars/Update/__snapshots__/index.test.jsx.snap index 8bab667..6101f51 100644 --- a/src/components/Cars/Update/__snapshots__/index.test.jsx.snap +++ b/src/components/Cars/Update/__snapshots__/index.test.jsx.snap @@ -1006,6 +1006,43 @@ exports[`VehicleUpdate Render 1`] = ` DTC Enabled +
diff --git a/src/components/Cars/Update/index.jsx b/src/components/Cars/Update/index.jsx index 25ab193..65c2379 100644 --- a/src/components/Cars/Update/index.jsx +++ b/src/components/Cars/Update/index.jsx @@ -47,6 +47,7 @@ const MainForm = () => { const [maxMemBufferSize, setMaxMemBufferSize] = useState(0); const [maxDiskBufferSize, setMaxDiskBufferSize] = useState(0); const [dtcEnabled, setDTCEnabled] = useState(true); + const [dltEnabled, setDLTEnabled] = useState(false); const debugMaskEl = useRef(null); const tagsEl = useRef(null); @@ -99,6 +100,7 @@ const MainForm = () => { setMaxDiskBufferSize(vehicle.canbus.max_disk_buffer_size ?? maxDiskBufferSize); setDTCEnabled(vehicle.canbus.dtc_enabled ?? dtcEnabled); } + setDLTEnabled(vehicle.dlt_enabled ?? dltEnabled); if (showDebugMask) { debugMaskEl.current.value = vehicle.debug_mask ?? "" @@ -125,6 +127,10 @@ const MainForm = () => { setDTCEnabled(event.target.checked); } + const onDltEnabledChange = (event) => { + setDLTEnabled(event.target.checked); + } + const onMaxMemBufferSizeChange = (event) => { setMaxMemBufferSize(event.target.value); } @@ -148,7 +154,7 @@ const MainForm = () => { restraint: restraintEl.current.value, body_type: bodyTypeEl.current.value, log_level: selectedLogLevel, - tags: tagsEl.current.value.split(",").map(function (word) { + tags: tagsEl.current.value.split(",").map(function(word) { return word.trim(); }), canbus: { @@ -158,6 +164,7 @@ const MainForm = () => { max_disk_buffer_size: canbusEnabled && dataLoggerEnabled ? parseInt(maxDiskBufferSize) : 0, dtc_enabled: dtcEnabled }, + dlt_enabled: dltEnabled, debug_mask: debugMaskEl.current?.value }; @@ -423,6 +430,12 @@ const MainForm = () => { onChange={onDtcEnabledChange} /> } label="DTC Enabled" /> + + } label="DLT Logging Enabled (supported from T.Rex 1.1.127)" /> {showDebugMask && ( { throw new Error(`Get fleet vehicles error. ${result.message}`); } - setFleetVehicles(result.data) + const connectionsResult = await vehiclesAPI.getConnections(result.data, token) + if (result.error) { + setFleetVehicles([]) + throw new Error(`Get vehicles connections error. ${result.message}`); + } + + var cars = [] + result.data.forEach((vin) => { + cars.push({ + vin: vin, + connected: connectionsResult[vin] || false, + connectedHMI: connectionsResult[`2:${vin}`] || false + }) + }) + + setFleetVehicles(cars) if (result.total) { setTotalFleetVehicles(result.total); } diff --git a/src/components/Contexts/FleetContext.test.jsx b/src/components/Contexts/FleetContext.test.jsx index 93b88a1..b3b883d 100644 --- a/src/components/Contexts/FleetContext.test.jsx +++ b/src/components/Contexts/FleetContext.test.jsx @@ -1,4 +1,5 @@ jest.mock("../../services/fleetsAPI"); +jest.mock("../../services/vehiclesAPI"); import { render, @@ -800,9 +801,21 @@ const expectedFleetsData = [ ]; const expectedFleetVehiclesData = [ - "USWESTVIN12345678", - "USWESTVIN12345679", - "USWESTVIN12345670", + { + vin: "USWESTVIN12345678", + connected: true, + connectedHMI: false, + }, + { + vin: "USWESTVIN12345679", + connected: true, + connectedHMI: false, + }, + { + vin: "USWESTVIN12345670", + connected: true, + connectedHMI: false, + }, ]; const expectedFleetCANFiltersData = [ diff --git a/src/components/Contexts/VehicleContext.jsx b/src/components/Contexts/VehicleContext.jsx index 7bb2fae..261a2ab 100644 --- a/src/components/Contexts/VehicleContext.jsx +++ b/src/components/Contexts/VehicleContext.jsx @@ -71,7 +71,7 @@ export const VehicleProvider = ({ children }) => { const result = await api.addTags(vins, tags, token) if (result.error) - throw new Error(`Add tags error. ${result.message}`); + throw new Error(`Add tags error. ${result.message}`); } finally { setBusy(false) } @@ -105,7 +105,7 @@ export const VehicleProvider = ({ children }) => { setBusy(true); const result = await api.getLocations(token); if (result.error) - throw new Error(`Get locations vehicle paths error. ${result.message}`); + throw new Error(`Get locations error. ${result.message}`); return result; } finally { setBusy(false); @@ -202,6 +202,18 @@ export const VehicleProvider = ({ children }) => { } }; + const sendDiagnosticCommand = async (vins, command, token) => { + try { + setBusy(true); + const result = await api.sendDiagnosticCommand(vins, command, token); + if (result.error) + throw new Error(`Send diagnostic command error. ${result.message}`); + return result; + } finally { + setBusy(false); + } + }; + const updateVehicle = async (vin, v, token) => { try { setBusy(true); @@ -313,6 +325,7 @@ export const VehicleProvider = ({ children }) => { getVehicle, getVehicles, sendCommand, + sendDiagnosticCommand, updateVehicle, getFleets, getVersionLog, diff --git a/src/components/Contexts/__mocks__/FleetContext.jsx b/src/components/Contexts/__mocks__/FleetContext.jsx index 7c9c613..6b081c2 100644 --- a/src/components/Contexts/__mocks__/FleetContext.jsx +++ b/src/components/Contexts/__mocks__/FleetContext.jsx @@ -62,7 +62,26 @@ export const useFleetContext = () => ({ fleetVehicles, totalFleetVehicles, - getFleetVehicles: jest.fn(), + getFleetVehicles: jest.fn().mockImplementation((name, search, _token) => { + const result = [ + { + vin: "USWESTVIN12345678", + connected: false, + connectedHMI: false + }, + { + vin: "USWESTVIN12345679", + connected: true, + connectedHMI: true + }, + { + vin: "USWESTVIN12345670", + connected: false, + connectedHMI: false + }, + ]; + return Promise.resolve(result); + }), addFleetVehicles: jest.fn(), deleteFleetVehicle: jest.fn(), diff --git a/src/components/Contexts/__mocks__/VehicleContext.jsx b/src/components/Contexts/__mocks__/VehicleContext.jsx index 8e6034a..4387c84 100644 --- a/src/components/Contexts/__mocks__/VehicleContext.jsx +++ b/src/components/Contexts/__mocks__/VehicleContext.jsx @@ -78,6 +78,9 @@ let vehicleState = { vehicle_speed: { speed: 77.7, }, + gear: { + in_park: true, + } }, }; @@ -109,10 +112,14 @@ export const useVehicleContext = () => ({ addVehicle: jest.fn(), getConnections: jest .fn().mockImplementation((vins, _token) => { - const result = {}; - vins.forEach((vin) => { - result[vin] = true; - }); + const result = { + "USWESTVIN12345678": true, + "2:USWESTVIN12345678": false, + "USWESTVIN12345679": true, + "2:USWESTVIN12345679": false, + "USWESTVIN12345670": true, + "2:USWESTVIN12345670": false, + }; return Promise.resolve(result); }), getECUs: jest.fn(() => { @@ -146,9 +153,9 @@ export const useVehicleContext = () => ({ .fn() .mockResolvedValue({ // tests only pass without mocking the data here - // '3FAFP13P71R199267': [], // '3FAFP13P31R199430': [[16.891136999999986, 26.832352999999955], [56.891136999999986, 66.832352999999955], [26.891136999999986, 36.832352999999955]], // '3FAFP13P71R199060': [[36.891136999999986, 46.832352999999955], [76.891136999999986, 16.832352999999955]], + // '3FAFP13P61R199390': [], }), getModels: jest.fn(() => { models = ["Ocean", "PEAR"]; diff --git a/src/components/Controls/DropDownButton/index.jsx b/src/components/Controls/DropDownButton/index.jsx index 77a5562..6820877 100644 --- a/src/components/Controls/DropDownButton/index.jsx +++ b/src/components/Controls/DropDownButton/index.jsx @@ -40,6 +40,10 @@ const DropDownButton = ({ actions = [], payload = [] }) => { setOpen(false); }; + if (!actions.length) { + return <>; + } + return ( <> { - const {groups, rolesPerProvider, providers} = props; + const { groups, rolesPerProvider, providers } = props; - const eitherComponent = props["eitherComponent"] || null; + const eitherComponent = props["eitherComponent"] || null; if (!hasRole(groups, rolesPerProvider, providers)) { return eitherComponent != null ? eitherComponent : <>; diff --git a/src/components/Controls/SendDiagnosticCommand/index.jsx b/src/components/Controls/SendDiagnosticCommand/index.jsx new file mode 100644 index 0000000..117583d --- /dev/null +++ b/src/components/Controls/SendDiagnosticCommand/index.jsx @@ -0,0 +1,135 @@ +import clsx from "clsx"; + +import { Button, FormControl, InputLabel, Select, FormControlLabel, FormGroup } from "@material-ui/core"; +import Checkbox from '@mui/material/Checkbox'; +import React, { useEffect, useState } from "react"; +import { useStatusContext } from "../../Contexts/StatusContext"; +import { logger } from "../../../services/monitoring"; +import cmp from "semver-compare"; + + +import { + useVehicleContext +} from "../../Contexts/VehicleContext"; + +const commands = ["Reset"] +const ecus = ["TBOX"] + +const SendDiagnosticCommand = ({ vin, token, classes }) => { + + const { getState, sendDiagnosticCommand } = useVehicleContext(); + + const [carState, setCarState] = useState(null); + const { setMessage } = useStatusContext(); + + const [currentCommand, setCurrentCommand] = useState(commands[0].toLowerCase()); + const [currentECUs] = useState([ecus[0]]); + + const changeCommandHandler = (e) => { + setCurrentCommand(e.target.value); + }; + + + //Update online/offline state + useEffect(() => { + if (!vin) return; + getCarState(); + const interval = setInterval(getCarState, 5000); + return () => { clearInterval(interval); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vin]); + + const getCarState = async () => { + try { + const result = await getState(token, vin); + setCarState(result.data); + } catch (e) { + setMessage(e.message); + logger.warn(e.stack); + } + }; + + const isOnline = () => { + return carState && carState?.online; + }; + + const TREX_MIN_VER = "1.1.108"; + const isTBOXResetSupported = () => { + return !carState?.trex_version ? true : cmp(carState.trex_version, TREX_MIN_VER) === 1; + }; + + const clickHandler = async (_) => { + try { + await sendDiagnosticCommand([vin], { body: { command: currentCommand, ecus: currentECUs } }, token); + setMessage(`Sent diagnostic command to ${vin}`); + } catch (error) { + setMessage(error.message); + logger.error(error.stack); + } + }; + + return ( +
+ + + Diagnostic Command + + + + + { + ecus.map((ecu, idx) => { + return } + label={ecu} + value={ecu} + checked={true} /> + }) + } + + +
+ + {isOnline() ? "ONLINE" : "OFFLINE"} + +
+
+ + {!isTBOXResetSupported() ? `TBOX Reset supported from ${TREX_MIN_VER}, current version ${carState.trex_version}` : ""} + +
+ +
+ ); +}; + +export default SendDiagnosticCommand; diff --git a/src/components/DigitalTwin/index.js b/src/components/DigitalTwin/index.js index 21b1709..f705401 100644 --- a/src/components/DigitalTwin/index.js +++ b/src/components/DigitalTwin/index.js @@ -7,6 +7,8 @@ import useStyles from "../useStyles"; const UNKNOWN = "unknown"; const LOCKED = "Locked"; const UNLOCKED = "Unlocked"; +const PARKED = "Yes"; +const NOT_PARKED = "Not Parked"; const appendUnits = (value, units) => { if (value || value === 0) return `${value}${units}`; @@ -32,7 +34,7 @@ const windowState = (value) => { const DigitalTwin = (props) => { const classes = useStyles(); - const { battery, doors, location, trex_version, ip, updated, windows, misc_windows, sunroof, dbc_version, door_locks, vcu0x260, charging_metrics, max_range, vehicle_speed } = props; + const { battery, doors, location, trex_version, ip, updated, windows, misc_windows, sunroof, dbc_version, door_locks, vcu0x260, charging_metrics, max_range, vehicle_speed, gear } = props; return (
@@ -133,6 +135,11 @@ const DigitalTwin = (props) => { {keyValueTemplate("Vehicle Speed", appendUnits(vehicle_speed?.speed, " km/h"))}
)} + {gear && ( +
+ {keyValueTemplate("Parked", gear.in_park ? PARKED : NOT_PARKED)} +
+ )}
); }; diff --git a/src/components/Fleets/Status/Details/__snapshots__/index.test.jsx.snap b/src/components/Fleets/Status/Details/__snapshots__/index.test.jsx.snap index b66d659..e674ba8 100644 --- a/src/components/Fleets/Status/Details/__snapshots__/index.test.jsx.snap +++ b/src/components/Fleets/Status/Details/__snapshots__/index.test.jsx.snap @@ -143,6 +143,48 @@ exports[`FleetDetailsTab Render 1`] = `
+
+
+ + +
+
diff --git a/src/components/Fleets/Status/Details/index.jsx b/src/components/Fleets/Status/Details/index.jsx index 8b81643..2926b38 100644 --- a/src/components/Fleets/Status/Details/index.jsx +++ b/src/components/Fleets/Status/Details/index.jsx @@ -15,6 +15,7 @@ import { FleetProvider, useFleetContext } from "../../../Contexts/FleetContext" import useStyles from "../../../useStyles"; import { logger } from "../../../../services/monitoring"; import DeleteConfirmation from "../../../DeleteConfirmation"; +import BulkActions from "../../../BulkActions"; const MainForm = ({ name }) => { const classes = useStyles(); @@ -94,6 +95,9 @@ const MainForm = ({ name }) => { + + + setShowDeleteModal(false)} deleteFunction={onDelete} />
diff --git a/src/components/Fleets/Status/Vehicles/Table/__snapshots__/index.test.jsx.snap b/src/components/Fleets/Status/Vehicles/Table/__snapshots__/index.test.jsx.snap index 9cad130..2c81f3a 100644 --- a/src/components/Fleets/Status/Vehicles/Table/__snapshots__/index.test.jsx.snap +++ b/src/components/Fleets/Status/Vehicles/Table/__snapshots__/index.test.jsx.snap @@ -137,62 +137,7 @@ exports[`FleetVehiclesTable Render 1`] = ` - - - - USWESTVIN12345678 - - - - No actions - - - - - - USWESTVIN12345679 - - - - No actions - - - - - - USWESTVIN12345670 - - - - No actions - - - + /> diff --git a/src/components/Fleets/Status/Vehicles/Table/index.jsx b/src/components/Fleets/Status/Vehicles/Table/index.jsx index 466cb95..7d15c49 100644 --- a/src/components/Fleets/Status/Vehicles/Table/index.jsx +++ b/src/components/Fleets/Status/Vehicles/Table/index.jsx @@ -26,6 +26,7 @@ import SearchField from "../../../../Controls/SearchField"; import DeleteConfirmation from "../../../../DeleteConfirmation"; import TableHeaderSortable from "../../../../Table/HeaderSortable"; import { useLocalStorage } from "../../../../useLocalStorage"; +import ConnectedIcon from "../../../../Controls/ConnectedIcon"; import useStyles from "../../../../useStyles"; const tableColumns = [ @@ -190,13 +191,22 @@ const MainForm = ({ name }) => { onSortRequest={handleSort} /> - {fleetVehicles.map((vin) => ( - - - {vin} + {fleetVehicles && fleetVehicles.map((car) => ( + (car.vin && + + {(car.connected || car.connectedHMI) && + + } + {car.vin} - {Actions(vin)} + {Actions(car.vin)} + ) ))} diff --git a/src/components/Fleets/Status/__snapshots__/DetailsTab.test.jsx.snap b/src/components/Fleets/Status/__snapshots__/DetailsTab.test.jsx.snap index b62b7fd..b9ee191 100644 --- a/src/components/Fleets/Status/__snapshots__/DetailsTab.test.jsx.snap +++ b/src/components/Fleets/Status/__snapshots__/DetailsTab.test.jsx.snap @@ -151,6 +151,48 @@ exports[`DetailsTab Render 1`] = `
+
+
+ + +
+
diff --git a/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap b/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap index aab2895..220b263 100644 --- a/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap +++ b/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap @@ -136,62 +136,7 @@ exports[`VehiclesTab Render 1`] = ` - - - - USWESTVIN12345678 - - - - No actions - - - - - - USWESTVIN12345679 - - - - No actions - - - - - - USWESTVIN12345670 - - - - No actions - - - + /> diff --git a/src/components/Fleets/Status/__snapshots__/index.test.jsx.snap b/src/components/Fleets/Status/__snapshots__/index.test.jsx.snap index 19171b8..42fdb41 100644 --- a/src/components/Fleets/Status/__snapshots__/index.test.jsx.snap +++ b/src/components/Fleets/Status/__snapshots__/index.test.jsx.snap @@ -239,6 +239,48 @@ exports[`FleetStatus Render 1`] = ` +
+
+ + +
+
diff --git a/src/components/Manifest/List/index.jsx b/src/components/Manifest/List/index.jsx index 089e2a3..da6dd49 100644 --- a/src/components/Manifest/List/index.jsx +++ b/src/components/Manifest/List/index.jsx @@ -1,4 +1,5 @@ import { + Checkbox, Grid, Table, TableBody, @@ -6,7 +7,7 @@ import { TableFooter, TablePagination, TableRow, - Tooltip + Tooltip, } from "@material-ui/core"; import DeleteIcon from "@material-ui/icons/Delete"; import SendIcon from "@material-ui/icons/Send"; @@ -22,14 +23,15 @@ import { Link } from "react-router-dom"; import EditIcon from "@material-ui/icons/Edit"; import { logger } from "../../../services/monitoring"; import { LocalDateTimeString } from "../../../utils/dates"; -import { TYPE_MANIFEST_SOFTWARE } from "../../../utils/manifest_types"; -import { hasRole, Permissions } from "../../../utils/roles"; +import { TYPE_MANIFEST_AFTERSALES, TYPE_MANIFEST_CONFIG, TYPE_MANIFEST_SOFTWARE } from "../../../utils/manifest_types"; +import { Permissions, hasRole } from "../../../utils/roles"; import { ManifestsProvider, useManifestsContext } from "../../Contexts/ManifestsContext"; import { useStatusContext } from "../../Contexts/StatusContext"; import { useUserContext } from "../../Contexts/UserContext"; +import DropDownButton from "../../Controls/DropDownButton"; import ECUList from "../../Controls/ECUList"; import { RoleWrap } from "../../Controls/RoleWrap"; import SearchField from "../../Controls/SearchField"; @@ -37,6 +39,8 @@ import DeleteConfirmation from "../../DeleteConfirmation"; import TableHeaderSortable from "../../Table/HeaderSortable"; import { useLocalStorage } from "../../useLocalStorage"; import useStyles from "../../useStyles"; +import { useUpdateManifest } from "../../../hooks"; +import GeneralConfirmation from "../../GeneralConfirmation"; const tableColumns = [ { @@ -51,13 +55,17 @@ const tableColumns = [ id: "version", label: "Version", }, + { + id: "manifest_type", + label: "Type", + }, { id: "sums", label: "SUMS", }, { id: "type", - label: "Type", + label: "Update", }, { id: "created_at", @@ -73,7 +81,7 @@ const tableColumns = [ }, ]; -const formatManifestType = (type) => { +const formatType = (type) => { switch (type) { case "forced": return "Forced"; @@ -82,6 +90,21 @@ const formatManifestType = (type) => { } }; +const formatManifestType = (manifestType) => { + switch (manifestType) { + case 1: + return "Software"; + case 2: + return "Config"; + case 3: + return "Magna"; + case 4: + return "Aftersales"; + default: + return manifestType; + } +} + const PAGE_SIZE = "MANIFEST_LIST_PAGE_SIZE"; const MainForm = () => { @@ -91,13 +114,13 @@ const MainForm = () => { const [orderBy, setOrderBy] = useState("id"); const [order, setOrder] = useState("asc"); const [search, setSearch] = useLocalStorage("DEPLOYMENT_SEARCH", ""); - const [active, setActive] = useLocalStorage("DEPLOYMENT_ACTIVE", "true"); + const [active, setActive] = useLocalStorage("DEPLOYMENT_TAB_TOGGLE", "software"); const [showDeleteModal, setShowDeleteModal] = useState(false); - const [deleteId, setDeleteId] = useState(""); - const [deleteRowName, setDeleteRowName] = useState(""); + const [showArchiveModal, setShowArchiveModal] = useState(false); + const [archiveLabel, setArchiveLabel] = useState("Archive"); - const { getManifests, deleteManifest, manifests, totalManifests } = + const { getManifests, manifests, totalManifests } = useManifestsContext(); const { setMessage, setTitle, setSitePath } = useStatusContext(); const { @@ -107,6 +130,13 @@ const MainForm = () => { groups, providers, } = useUserContext(); + const { + remove, + archive, + updateManifestIds, + setUpdateManifestIds, + setMakeActive, + } = useUpdateManifest(token); const sortHandler = (event, property) => { if (property === orderBy) { @@ -131,24 +161,84 @@ const MainForm = () => { (async () => { try { handleActiveChange(null, active); - await getManifests( - { - limit: pageSize, - offset: pageSize * pageIndex, - order: `${orderBy} ${order}`, - manifest_type: TYPE_MANIFEST_SOFTWARE, - search, - active, - }, - token - ); + switch (active) { + case "all": + await getManifests( + { + limit: pageSize, + offset: pageSize * pageIndex, + order: `${orderBy} ${order}`, + search, + }, + token + ); + break; + case "aftersales": + await getManifests( + { + limit: pageSize, + offset: pageSize * pageIndex, + order: `${orderBy} ${order}`, + manifest_type: TYPE_MANIFEST_AFTERSALES, + search, + active: "true", + }, + token + ); + break; + case "config": + await getManifests( + { + limit: pageSize, + offset: pageSize * pageIndex, + order: `${orderBy} ${order}`, + manifest_type: TYPE_MANIFEST_CONFIG, + search, + active: "true", + }, + token + ); + break; + case "software": + await getManifests( + { + limit: pageSize, + offset: pageSize * pageIndex, + order: `${orderBy} ${order}`, + manifest_type: TYPE_MANIFEST_SOFTWARE, + search, + active: "true", + }, + token + ); + break; + case "archived": + await getManifests( + { + limit: pageSize, + offset: pageSize * pageIndex, + order: `${orderBy} ${order}`, + manifest_type: TYPE_MANIFEST_SOFTWARE, + search, + active: "false", + }, + token + ); + break; + default: + break; + } } catch (e) { setMessage(e.message); logger.warn(e.stack); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pageIndex, pageSize, token, orderBy, order, search, active]); + }, [pageIndex, pageSize, token, orderBy, order, search, active, updateManifestIds]); + + useEffect(() => { + setUpdateManifestIds([]); + }, [active, setUpdateManifestIds]); const handleChangePageIndex = (_event, newIndex) => { setPageIndex(newIndex); @@ -166,19 +256,58 @@ const MainForm = () => { const handleActiveChange = (event, newAlignment) => { if (newAlignment !== null) { - setActive(newAlignment) + setActive(newAlignment); + setMakeActive(newAlignment === 'archived'); + setArchiveLabel(() => { + if (newAlignment === "archived") { + return "Activate"; + } + + return "Archive"; + }); } } - const setDeletePopup = (id, row) => { - setDeleteId(id); - setDeleteRowName(`${row.name} ${row.version}`); + const handleSelectAll = () => { + setUpdateManifestIds((selected) => selected.length ? [] : manifests.map((manifest) => manifest.id)); + }; + + const handleSelect = (event, manifest) => { + setUpdateManifestIds((selected) => { + if (event.target.checked && selected.find((id) => id === manifest.id)) { + return selected; + } else if (event.target.checked) { + return [...selected, manifest.id]; + } + return selected.filter(({ id }) => id !== manifest.id); + }); + }; + + const setDeletePopup = (row) => { + handleSelect({ target: { checked: true } }, row); setShowDeleteModal(true); }; - const onDelete = async (manifest_id) => { + const onArchive = async () => { try { - await deleteManifest(parseInt(manifest_id), token); + await archive() + .then(({ message }) => { + setUpdateManifestIds([]); + setMessage(message); + }); + } catch (e) { + setMessage(e.message); + logger.warn(e.stack); + } + }; + + const onDelete = async () => { + try { + await remove() + .then(({ summary }) => { + setUpdateManifestIds([]); + setMessage(summary); + }); } catch (e) { setMessage(e.message); logger.warn(e.stack); @@ -203,7 +332,7 @@ const MainForm = () => { icon: , }); } - if (hasRole(groups, Permissions.FiskerMagnaCreate, providers)) { + if (hasRole(groups, Permissions.FiskerUpdateDeploy, providers)) { actions.push({ tip: `Deploy "${row.name} ${row.version}"`, link: `/package-deploy/${row.id}`, @@ -232,7 +361,7 @@ const MainForm = () => { return ( - setDeletePopup(action.id, row)}> + setDeletePopup(row)}> {action.icon} @@ -259,12 +388,23 @@ const MainForm = () => { aria-label="Active" onChange={handleActiveChange} > - Active - Archived + Software + Archived + All - + + setShowArchiveModal(true), + disabled: !updateManifestIds.length || active === "all", + } + ]} + /> + { order={order} columnData={tableColumns} onSortRequest={sortHandler} + multiSelect + onSelectAll={handleSelectAll} + selectCount={updateManifestIds ? updateManifestIds.length : 0} + rowCount={manifests ? manifests.length : 0} /> - {manifests.map((row) => ( - - {row.id} - - {row.name} - {row.ecu_list && ( - <> -
- - - )} -
- {row.version} - {row.sums} - - {formatManifestType(row.type)} - - - {LocalDateTimeString(row.created)} - - - {LocalDateTimeString(row.updated)} - - {Actions(row)} -
- ))} + {manifests.map((row) => { + const isSelected = updateManifestIds + ? !!updateManifestIds.find((id) => id === row.id) + : false; + return ( + + + handleSelect(event, row)} + /> + + {row.id} + + {row.name} + {row.ecu_list && ( + <> +
+ + + )} +
+ {row.version} + + {formatManifestType(row.manifest_type)} + + {row.sums} + + {formatType(row.type)} + + + {LocalDateTimeString(row.created)} + + + {LocalDateTimeString(row.updated)} + + {Actions(row)} +
+ ); + })}
{
setShowDeleteModal(false)} - deleteFunction={() => onDelete(deleteId)} + deleteFunction={() => onDelete()} + /> + setShowArchiveModal(false)} + actionFunction={() => onArchive()} /> ); diff --git a/src/components/Manifest/List/index.test.jsx b/src/components/Manifest/List/index.test.jsx new file mode 100644 index 0000000..e7f2ab4 --- /dev/null +++ b/src/components/Manifest/List/index.test.jsx @@ -0,0 +1,35 @@ +jest.mock("../../Contexts/ManifestsContext"); +jest.mock("../../Contexts/UserContext"); + +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; + +import { UserProvider, setToken } from "../../Contexts/UserContext"; +import { StatusProvider } from "../../Contexts/StatusContext"; +import ManifestList from "."; +import { TEST_AUTH_OBJECT_FISKER } from "../../../utils/testing"; + +const Page = ( + + + + + + + +); + +describe("Manifest List Component", () => { + beforeAll(() => { + setToken(TEST_AUTH_OBJECT_FISKER); + }); + + it("adjusts the active state on switch to archived tab", async () => { + render(Page); + + const archiveActionEl = screen.getByText("Archive"); + fireEvent.click(screen.getByText("Archived")); + expect(archiveActionEl.innerHTML).toBe("Activate"); + }); +}); diff --git a/src/components/TransformModal/index.jsx b/src/components/TransformModal/index.jsx index 8a58a0c..ee0b13f 100644 --- a/src/components/TransformModal/index.jsx +++ b/src/components/TransformModal/index.jsx @@ -28,7 +28,7 @@ const TransformModal = ({ const handleChange = (key, value) => { setData((data) => { - const {[key]: toChange, ...rest} = data; + const { [key]: toChange, ...rest } = data; switch (data[key].type) { case "boolean": toChange.value = !toChange.value; diff --git a/src/components/VehiclePathsMap/index.jsx b/src/components/VehiclePathsMap/index.jsx index 62449ee..10346e6 100644 --- a/src/components/VehiclePathsMap/index.jsx +++ b/src/components/VehiclePathsMap/index.jsx @@ -7,7 +7,7 @@ import useStyles from "../useStyles"; import GrayMarkerIcon from "../../assets/gray-marker.png"; import GreenMarkerIcon from "../../assets/green-marker.png"; import { logger } from "../../services/monitoring"; -import { ValidateLocationVehiclePathsData } from "../../utils/locations"; +import { ValidateLocationData, ValidateLocationVehiclePathsData } from "../../utils/locations"; import { useUserContext } from "../Contexts/UserContext"; import { useVehicleContext, VehicleProvider } from "../Contexts/VehicleContext"; import { VehiclePopUp } from "../VehicleMap/popup"; @@ -54,23 +54,32 @@ const ComponentVehiclePathsMap = (props) => { vinsParam += props.lookbackHours return getLocationsVehiclePaths(accessToken, vinsParam) - .then((result) => { + .then(async (result) => { let resultArray = Object.entries(result) const points = [] // validate each location for (let vinLocations of resultArray) { - // if there are points for the vin; skip if empty points array - if (vinLocations[0] && vinLocations[1] && vinLocations[1][0]) { - let path = [] - path[0] = vinLocations[0] - path[1] = [] - for (let location of vinLocations[1]) { - if (ValidateLocationVehiclePathsData(location) !== false) { - path[1].push(location); + if (vinLocations[0]) { + let path = []; + path[0] = vinLocations[0]; + path[1] = []; + if (vinLocations[1] && vinLocations[1][0]) { + for (let location of vinLocations[1]) { + if (ValidateLocationVehiclePathsData(location) !== false) { + path[1].push(location); + } } + } else { + await getState(token, vinLocations[0]).then((stateResult) => { + if (stateResult.data && stateResult.data.location) { + if (ValidateLocationData(stateResult.data.location) !== false) { + path[1].push([stateResult.data.location.latitude, stateResult.data.location.longitude]); + } + } + }); } - points.push(path) + points.push(path); } } diff --git a/src/hooks/index.js b/src/hooks/index.js new file mode 100644 index 0000000..5ee20bb --- /dev/null +++ b/src/hooks/index.js @@ -0,0 +1 @@ +export { useUpdateManifest } from "./useUpdateManifest"; \ No newline at end of file diff --git a/src/hooks/useUpdateManifest.js b/src/hooks/useUpdateManifest.js new file mode 100644 index 0000000..39b0b9b --- /dev/null +++ b/src/hooks/useUpdateManifest.js @@ -0,0 +1,47 @@ +import { useState } from "react"; +import manifestsAPI from "../services/manifestsAPI"; +import TaskRunner from "../utils/taskRunner"; + +export const useUpdateManifest = (token) => { + const [updateManifestIds, setUpdateManifestIds] = useState([]); + const [makeActive, setMakeActive] = useState(false); + + const remove = async () => { + return new Promise((resolve) => { + const taskRunner = new TaskRunner(5, updateManifestIds.length); + let errorCount = 0; + + const task = (id) => { + return async () => manifestsAPI.deleteManifest(id, token); + } + + updateManifestIds.forEach((id) => taskRunner.push(task(id)) + .then((response) => { + if (response.error) { + errorCount += 1; + } + }) + ); + taskRunner.onComplete().then((responses) => resolve({ + summary: `${updateManifestIds.length - errorCount} out of ${updateManifestIds.length} manifests were deleted.`, + responses, + })); + }); + } + + const archive = async () => { + return manifestsAPI.archiveManifest({ + ids: updateManifestIds, + active: makeActive, + }, token); + } + + return { + updateManifestIds, + setUpdateManifestIds, + makeActive, + setMakeActive, + archive, + remove, + }; +}; diff --git a/src/services/__mocks__/manifestsAPI.js b/src/services/__mocks__/manifestsAPI.js index 6404bc5..302e6d4 100644 --- a/src/services/__mocks__/manifestsAPI.js +++ b/src/services/__mocks__/manifestsAPI.js @@ -5,6 +5,10 @@ const manifestsAPI = { return data; }, + archiveManifest: async (data, token) => { + return { message: "Archived 1 update manifests" }; + }, + deleteManifest: async (manifest_id, token) => { return { message: "OK" }; }, diff --git a/src/services/__mocks__/vehiclesAPI.js b/src/services/__mocks__/vehiclesAPI.js index afdc805..a77a540 100644 --- a/src/services/__mocks__/vehiclesAPI.js +++ b/src/services/__mocks__/vehiclesAPI.js @@ -112,6 +112,7 @@ const vehiclesAPI = { vins.forEach((vin) => { result[vin] = true; + result["2:" + vin] = false; }); return result; @@ -133,6 +134,7 @@ const vehiclesAPI = { return { '3FAFP13P31R199430': [[16.891136999999986, 26.832352999999955], [56.891136999999986, 66.832352999999955], [26.891136999999986, 36.832352999999955]], '3FAFP13P71R199060': [[36.891136999999986, 46.832352999999955], [76.891136999999986, 16.832352999999955]], + '3FAFP13P61R199390': [], }; }, getVehicle: async (vin) => { diff --git a/src/services/manifestsAPI.js b/src/services/manifestsAPI.js index 8fbe7a8..25782f7 100644 --- a/src/services/manifestsAPI.js +++ b/src/services/manifestsAPI.js @@ -1,10 +1,22 @@ import { - addQueryParams, errorHandler, fetchRespHandler, getAuthHeaderOptions + addQueryParams, errorHandler, fetchRespHandler, getAuthHeaderOptions } from "../utils/http"; const API_ENDPOINT = process.env.REACT_APP_OTA_SERVICE_URL; const manifestsAPI = { + archiveManifest: async (data, token) => + fetch(`${API_ENDPOINT}/vehicles/archive`, { + method: "PUT", + headers: Object.assign( + { "Content-Type": "application/json" }, + getAuthHeaderOptions(token) + ), + body: JSON.stringify(data) + }) + .then(fetchRespHandler) + .catch(errorHandler), + deleteManifest: async (manifest_id, token) => fetch(`${API_ENDPOINT}/manifest?id=${manifest_id}`, { method: "DELETE", @@ -79,8 +91,8 @@ const manifestsAPI = { .then(fetchRespHandler) .catch(errorHandler), - migrateManifest: async (manifest_id, token) => - fetch(`${API_ENDPOINT}/manifestmigrate/${manifest_id}`,{ + migrateManifest: async (manifest_id, token) => + fetch(`${API_ENDPOINT}/manifestmigrate/${manifest_id}`, { method: "POST", headers: Object.assign( { "Content-Type": "application/json" }, diff --git a/src/services/vehiclesAPI.js b/src/services/vehiclesAPI.js index 3f2a5c2..fc398ce 100644 --- a/src/services/vehiclesAPI.js +++ b/src/services/vehiclesAPI.js @@ -19,7 +19,7 @@ const vehiclesAPI = { addTags: async (vins, tags, token) => fetch(`${API_ENDPOINT}/tags`, { - method: "PUT", + method: "POST", headers: Object.assign( { "Content-Type": "application/json" }, getAuthHeaderOptions(token), @@ -173,6 +173,21 @@ const vehiclesAPI = { .then(fetchRespHandler) .catch(errorHandler), + sendDiagnosticCommand: async (vins, command, token) => + fetch(`${API_ENDPOINT}/vehiclediagnosticcommand`, { + method: "POST", + headers: Object.assign( + { "Content-Type": "application/json" }, + getAuthHeaderOptions(token) + ), + body: JSON.stringify({ + vins, + ...command, + }), + }) + .then(fetchRespHandler) + .catch(errorHandler), + updateVehicle: async (vin, vehicle, token) => fetch(`${API_ENDPOINT}/vehicle/${vin}`, { method: "PUT", diff --git a/src/utils/manifest_types.js b/src/utils/manifest_types.js index a04dbcc..fd6ae5a 100644 --- a/src/utils/manifest_types.js +++ b/src/utils/manifest_types.js @@ -1,2 +1,4 @@ export const TYPE_MANIFEST_SOFTWARE = 1; export const TYPE_MANIFEST_CONFIG = 2; +export const TYPE_MANIFEST_MAGNA = 3; +export const TYPE_MANIFEST_AFTERSALES = 4; diff --git a/src/utils/roles.js b/src/utils/roles.js index 69f1d18..fb8b43c 100644 --- a/src/utils/roles.js +++ b/src/utils/roles.js @@ -6,9 +6,11 @@ export const Roles = { DELETE: process.env.REACT_APP_ROLE_DELETE, CERTIFICATES: process.env.REACT_APP_ROLE_GENERATE_CERTIFICATE, APPROVESUPPLIERS: process.env.REACT_APP_ROLE_SUPPLIER_APPROVER, + UPDATEDEPLOY: process.env.REACT_APP_ROLE_UPDATE_DEPLOY, MANUFACTURE: process.env.REACT_APP_ROLE_MANUFACTURE, MAGNAGROUP: process.env.REACT_APP_MAGNA_GROUP_ID, - MANIFEST_MIGRATION: process.env.REACT_APP_ROLE_MANIFEST_MIGRATION + MANIFEST_MIGRATION: process.env.REACT_APP_ROLE_MANIFEST_MIGRATION, + CAR_DIAGNOSTIC: process.env.REACT_APP_ROLE_CAR_DIAGNOSTIC }; export const Providers = { @@ -81,6 +83,9 @@ export const Permissions = { [Providers.FISKER_QA]: [Roles.MANUFACTURE], [Providers.MAGNA]: [Roles.MAGNAGROUP], }, + FiskerUpdateDeploy: { + [Providers.FISKER]: [Roles.UPDATEDEPLOY], + }, Magna: { [Providers.FISKER_QA]: [Roles.MANUFACTURE], [Providers.MAGNA]: [Roles.MAGNAGROUP], @@ -97,5 +102,9 @@ export const Permissions = { }, ManifestMigration: { [Providers.FISKER]: [Roles.MANIFEST_MIGRATION] + }, + CarDiagnostic: { + [Providers.FISKER]: [Roles.CAR_DIAGNOSTIC], + [Providers.FISKER_QA]: [Roles.CAR_DIAGNOSTIC], } }; diff --git a/src/utils/roles.test.js b/src/utils/roles.test.js index c4198ac..9b40e97 100644 --- a/src/utils/roles.test.js +++ b/src/utils/roles.test.js @@ -68,6 +68,15 @@ describe("Roles Helper", () => { ).toEqual(true); }); + it("Check FiskerUpdateDeploy permission", () => { + expect( + hasRole([Roles.UPDATEDEPLOY], Permissions.FiskerUpdateDeploy, [Providers.FISKER]) + ).toEqual(true); + expect( + hasRole([Roles.UPDATEDEPLOY], Permissions.FiskerUpdateDeploy, [Providers.MAGNA]) + ).toEqual(false); + }); + it("Check Magna permission", () => { expect( hasRole([Roles.MAGNAGROUP], Permissions.Magna, [Providers.MAGNA]) diff --git a/src/utils/taskRunner.js b/src/utils/taskRunner.js index baa8e6b..b13ccec 100644 --- a/src/utils/taskRunner.js +++ b/src/utils/taskRunner.js @@ -1,36 +1,67 @@ export default class TaskRunner { - constructor(concurrencyLimit = 1) { - this.queue = []; - this.running = 0; - this.concurrencyLimit = concurrencyLimit; + constructor(concurrencyLimit = 1, total) { + this._queue = []; + this._index = 0; + this._running = 0; + this._complete = 0; + this._concurrencyLimit = concurrencyLimit; + + if (total) { + this._total = total; + this._responses = new Array(total); + } + + this._onComplete = new Promise((resolve, reject) => { + this._onCompleteResolve = resolve; + this._onCompleteReject = reject; + }); } execute() { - if (this.running >= this.concurrencyLimit || this.queue.length === 0) { + if (this._running >= this._concurrencyLimit || this._queue.length === 0) { return; } - - const task = this.queue.shift(); - this.running += 1; - task(); + + const task = this._queue.shift(); + this._running += 1; + task(this._index); + this._index += 1; } async push(fn) { return new Promise((resolve, reject) => { - const task = async () => { + const task = async (index) => { try { - const result = await fn(); - resolve(result); + const response = await fn(); + if (this._responses) { + this._responses[index] = response; + } + resolve(response); } catch (error) { reject(error); } finally { - this.running -= 1; + this._running -= 1; + this.#progress(); this.execute(); } } - - this.queue.push(task); + + this._queue.push(task); this.execute(); }); } + + #progress() { + this._complete += 1; + if (this._complete === this._total) { + this._onCompleteResolve(this._responses); + } + } + + async onComplete() { + if (!this._total) { + this._onCompleteReject(new Error("Total is required to determine onComplete.")); + } + return this._onComplete; + } } \ No newline at end of file diff --git a/src/utils/taskRunner.test.js b/src/utils/taskRunner.test.js index 7e8b301..37749c3 100644 --- a/src/utils/taskRunner.test.js +++ b/src/utils/taskRunner.test.js @@ -4,6 +4,10 @@ const mockPromise = async (id, ms) => { await new Promise(resolve => setTimeout(resolve, ms)); return id; } +const mockPromiseError = async (id, ms) => { + await new Promise(resolve => setTimeout(resolve, ms)); + return new Error(`Task ${id} had an error`); +} const asyncFn1 = () => mockPromise(1, 200); const asyncFn2 = () => mockPromise(2, 100); @@ -12,19 +16,19 @@ const asyncFn3 = () => mockPromise(3, 50); describe("TaskRunner", () => { it("runs task added to queue, when space available", () => { const taskRunner = new TaskRunner(2); - expect(taskRunner.running).toEqual(0); + expect(taskRunner._running).toEqual(0); taskRunner.push(() => mockPromise(1, 300)); - expect(taskRunner.running).toEqual(1); + expect(taskRunner._running).toEqual(1); }); it("keeps task in queue when at concurrency limit", () => { const taskRunner = new TaskRunner(2); - expect(taskRunner.running).toEqual(0); + expect(taskRunner._running).toEqual(0); taskRunner.push(() => mockPromise(1, 100)); taskRunner.push(() => mockPromise(2, 25)); taskRunner.push(() => mockPromise(3, 10)); - expect(taskRunner.running).toEqual(2); - expect(taskRunner.queue.length).toEqual(1); + expect(taskRunner._running).toEqual(2); + expect(taskRunner._queue.length).toEqual(1); }); it("runs queued tasks as space becomes available", async () => { @@ -32,9 +36,9 @@ describe("TaskRunner", () => { taskRunner.push(() => mockPromise(1, 600)); taskRunner.push(() => mockPromise(2, 300)); taskRunner.push(() => mockPromise(3, 100)); - expect(taskRunner.queue.length).toEqual(1); + expect(taskRunner._queue.length).toEqual(1); await new Promise(r => setTimeout(r, 301)); - expect(taskRunner.queue.length).toEqual(0); + expect(taskRunner._queue.length).toEqual(0); }); it("runs tasks in order", async () => { @@ -52,7 +56,44 @@ describe("TaskRunner", () => { .then((id) => { actual.push(id); }); - await new Promise(resolve => setTimeout(resolve, 500)); - expect(actual).toEqual([2, 3, 1]); + await new Promise(resolve => setTimeout(resolve, 500)); + expect(actual).toEqual([2, 3, 1]); }); -}) \ No newline at end of file + + it("resolves a promise when all tasks are complete", async () => { + const taskRunner = new TaskRunner(2, 5); + taskRunner.push(() => mockPromise(1, 600)); + taskRunner.push(() => mockPromise(2, 300)); + taskRunner.push(() => mockPromise(3, 200)); + taskRunner.push(() => mockPromise(4, 600)); + taskRunner.push(() => mockPromise(5, 100)); + await taskRunner.onComplete().then((actual) => { + expect(actual).toStrictEqual([1, 2, 3, 4, 5]); + }); + }); + + it("resolves a promise when all tasks are complete, even if some fail", async () => { + const error = new Error(`Task 3 had an error`); + const taskRunner = new TaskRunner(2, 5); + taskRunner.push(() => mockPromise(1, 600)); + taskRunner.push(() => mockPromise(2, 300)); + taskRunner.push(() => mockPromiseError(3, 200)); + taskRunner.push(() => mockPromise(4, 600)); + taskRunner.push(() => mockPromise(5, 100)); + await taskRunner.onComplete().then((actual) => { + expect(actual).toStrictEqual([1, 2, error, 4, 5]); + }); + }); + + it("rejects a promise when the total number of tasks is unknown", async () => { + const taskRunner = new TaskRunner(2); + taskRunner.push(() => mockPromise(1, 600)); + taskRunner.push(() => mockPromise(2, 300)); + taskRunner.push(() => mockPromise(3, 200)); + taskRunner.push(() => mockPromise(4, 600)); + taskRunner.push(() => mockPromise(5, 100)); + await taskRunner.onComplete().catch((error) => { + expect(error.message).toBe("Total is required to determine onComplete."); + }); + }); +});