diff --git a/src/components/DataRetentionSelector.test.tsx b/src/components/DataRetentionSelector.test.tsx new file mode 100644 index 00000000..c1a087f6 --- /dev/null +++ b/src/components/DataRetentionSelector.test.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import DataRetentionSelector, { + TimeUnit, + DataRetentionValue, +} from "./DataRetentionSelector"; + +describe("DataRetentionSelector", () => { + const defaultValue: DataRetentionValue = { + value: 30, + unit: TimeUnit.DAYS, + }; + + test("renders with default value", () => { + const onChange = jest.fn(); + render(); + + expect(screen.getByLabelText("Retention Time")).toHaveValue(30); + expect(screen.getByLabelText("Unit")).toHaveTextContent("Days"); + }); + + test("calls onChange when value changes", () => { + const onChange = jest.fn(); + render(); + + const valueInput = screen.getByLabelText("Retention Time"); + userEvent.clear(valueInput); + userEvent.type(valueInput, "60"); + + expect(onChange).toHaveBeenCalledWith({ + value: 60, + unit: TimeUnit.DAYS, + }); + }); + + test("calls onChange when unit changes", () => { + const onChange = jest.fn(); + render(); + + const unitSelect = screen.getByLabelText("Unit"); + userEvent.click(unitSelect); + userEvent.click(screen.getByText("Weeks")); + + expect(onChange).toHaveBeenCalledWith({ + value: 30, + unit: TimeUnit.WEEKS, + }); + }); + + test("displays all time unit options", () => { + const onChange = jest.fn(); + render(); + + const unitSelect = screen.getByLabelText("Unit"); + userEvent.click(unitSelect); + + expect(screen.getAllByText("Days").length).toBeGreaterThan(0); + expect(screen.getByText("Weeks")).toBeInTheDocument(); + expect(screen.getByText("Months")).toBeInTheDocument(); + expect(screen.getByText("Years")).toBeInTheDocument(); + }); + + test("can be disabled", () => { + const onChange = jest.fn(); + render( + + ); + + const valueInput = screen.getByLabelText("Retention Time"); + const unitSelect = screen.getByLabelText("Unit"); + + expect(valueInput).toBeDisabled(); + expect(unitSelect).toHaveAttribute("aria-disabled", "true"); + }); + + test("minimum allowed value is 1", () => { + const onChange = jest.fn(); + render(); + + const valueInput = screen.getByLabelText("Retention Time"); + expect(valueInput).toHaveAttribute("min", "1"); + }); +}); diff --git a/src/components/DataRetentionSelector.tsx b/src/components/DataRetentionSelector.tsx new file mode 100644 index 00000000..faae6908 --- /dev/null +++ b/src/components/DataRetentionSelector.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { Stack, TextField, MenuItem } from "@mui/material"; + +export enum TimeUnit { + DAYS = "days", + WEEKS = "weeks", + MONTHS = "months", + YEARS = "years", +} + +export type DataRetentionValue = { + value: number; + unit: TimeUnit; +}; + +type DataRetentionSelectorProps = { + value: DataRetentionValue; + onChange: (value: DataRetentionValue) => void; + disabled?: boolean; +}; + +const DataRetentionSelector: React.FC = ({ + value, + onChange, + disabled = false, +}) => { + const handleValueChange = (event: React.ChangeEvent) => { + const newValue = parseInt(event.target.value, 10); + if (isNaN(newValue) && event.target.value !== "") { + return; + } + onChange({ + ...value, + value: isNaN(newValue) ? 1 : Math.max(1, newValue), + }); + }; + + const handleUnitChange = (event: React.ChangeEvent) => { + onChange({ + ...value, + unit: event.target.value as TimeUnit, + }); + }; + + return ( + + + + Days + Weeks + Months + Years + + + ); +}; + +export default DataRetentionSelector; diff --git a/src/scenes/AdminControl/AdminControl.js b/src/scenes/AdminControl/AdminControl.js index 8ed31555..6a156a75 100644 --- a/src/scenes/AdminControl/AdminControl.js +++ b/src/scenes/AdminControl/AdminControl.js @@ -5,6 +5,7 @@ import AdminAddVehicle from "./Components/AdminAddVehicle"; import AdminAddLocation from "./Components/AdminAddLocation"; import AdminAddDeliverableType from "./Components/AdminAddDeliverableType"; import AdminAddRiderResponsibility from "./Components/AdminAddRiderResponsibility"; +import AdminDataRetention from "./Components/AdminDataRetention"; import { Stack } from "@mui/material"; import { RiderResponsibilityChips } from "./Components/RiderResponsibilityChips"; import { DeliverableTypeChips } from "./Components/DeliverableTypeChips"; @@ -19,6 +20,7 @@ export function AdminControl() { + ); } diff --git a/src/scenes/AdminControl/Components/AdminDataRetention.test.tsx b/src/scenes/AdminControl/Components/AdminDataRetention.test.tsx new file mode 100644 index 00000000..09b11c7c --- /dev/null +++ b/src/scenes/AdminControl/Components/AdminDataRetention.test.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import AdminDataRetention from "./AdminDataRetention"; +import { render } from "../../../test-utils"; +import * as models from "../../../models"; +import { userRoles } from "../../../apiConsts"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const preloadedState = { + whoami: { + user: new models.User({ roles: [userRoles.user, userRoles.admin] }), + }, + loadingReducer: { + GET_WHOAMI: false, + }, + tenantId: "tenant-id", +}; + +describe("AdminDataRetention", () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + test("renders the component with default values", async () => { + render(, { preloadedState }); + + expect( + screen.getByText("Set data retention time") + ).toBeInTheDocument(); + expect( + screen.getByText( + "Determine how long data should be retained before automatic deletion" + ) + ).toBeInTheDocument(); + expect( + screen.getByLabelText( + "Retain data indefinitely (no automatic deletion)" + ) + ).toBeChecked(); + expect(screen.queryByLabelText("Retention Time")).toBeNull(); + }); + + test("allows toggling indefinite retention", () => { + render(, { preloadedState }); + + const checkbox = screen.getByLabelText( + "Retain data indefinitely (no automatic deletion)" + ); + expect(checkbox).toBeChecked(); + + userEvent.click(checkbox); + + expect(checkbox).not.toBeChecked(); + expect(screen.getByLabelText("Retention Time")).toBeInTheDocument(); + expect(screen.getByLabelText("Unit")).toBeInTheDocument(); + }); + + test("allows changing retention time value", () => { + render(, { preloadedState }); + + // Uncheck indefinite retention first + const checkbox = screen.getByLabelText( + "Retain data indefinitely (no automatic deletion)" + ); + userEvent.click(checkbox); + + const valueInput = screen.getByLabelText("Retention Time"); + userEvent.clear(valueInput); + userEvent.type(valueInput, "60"); + + expect(valueInput).toHaveValue(60); + }); + + test("allows changing time unit", () => { + render(, { preloadedState }); + + // Uncheck indefinite retention first + const checkbox = screen.getByLabelText( + "Retain data indefinitely (no automatic deletion)" + ); + userEvent.click(checkbox); + + const unitSelect = screen.getByLabelText("Unit"); + userEvent.click(unitSelect); + userEvent.click(screen.getByText("Weeks")); + + // Check that the unit field now displays "Weeks" + expect(unitSelect).toHaveTextContent("Weeks"); + }); + + test("save button is enabled and clickable", () => { + render(, { preloadedState }); + + const saveButton = screen.getByRole("button", { + name: "Save retention settings", + }); + expect(saveButton).not.toBeDisabled(); + + userEvent.click(saveButton); + // Button should be clickable without errors + }); + + test("shows warning dialog for short retention periods", async () => { + render(, { preloadedState }); + + // Uncheck indefinite retention + const checkbox = screen.getByLabelText( + "Retain data indefinitely (no automatic deletion)" + ); + userEvent.click(checkbox); + + // Set a short retention period (5 days) + const valueInput = screen.getByLabelText("Retention Time"); + userEvent.clear(valueInput); + userEvent.type(valueInput, "5"); + + // Click save + const saveButton = screen.getByRole("button", { + name: "Save retention settings", + }); + userEvent.click(saveButton); + + // Warning dialog should appear + await waitFor(() => { + expect( + screen.getByText("Short Retention Period Warning") + ).toBeInTheDocument(); + }); + }); + + test("displays forbidden message if user is not admin", () => { + render(, { + preloadedState: { + ...preloadedState, + whoami: { user: new models.User({ roles: [userRoles.user] }) }, + }, + }); + + expect(screen.queryByText("Set data retention time")).toBeNull(); + expect( + screen.getByText("You don't have permission to view this page.") + ).toBeInTheDocument(); + }); + + test("displays skeleton while loading", () => { + render(, { + preloadedState: { + ...preloadedState, + loadingReducer: { + GET_WHOAMI: true, + }, + }, + }); + + expect(screen.queryByText("Set data retention time")).toBeNull(); + }); +}); diff --git a/src/scenes/AdminControl/Components/AdminDataRetention.tsx b/src/scenes/AdminControl/Components/AdminDataRetention.tsx new file mode 100644 index 00000000..a79cefd7 --- /dev/null +++ b/src/scenes/AdminControl/Components/AdminDataRetention.tsx @@ -0,0 +1,191 @@ +import { + Button, + Checkbox, + FormControlLabel, + Skeleton, + Stack, + Typography, +} from "@mui/material"; +import { makeStyles } from "tss-react/mui"; +import React, { useState } from "react"; +import { PaddedPaper } from "../../../styles/common"; +import { useDispatch, useSelector } from "react-redux"; +import { getWhoami } from "../../../redux/Selectors"; +import Forbidden from "../../../ErrorComponents/Forbidden"; +import { createLoadingSelector } from "../../../redux/LoadingSelectors"; +import * as models from "../../../models/index"; +import DataRetentionSelector, { + DataRetentionValue, + TimeUnit, +} from "../../../components/DataRetentionSelector"; +import { displayInfoNotification } from "../../../redux/notifications/NotificationsActions"; +import ConfirmationDialog from "../../../components/ConfirmationDialog"; + +const initialDataRetentionState: DataRetentionValue = { + value: 30, + unit: TimeUnit.DAYS, +}; + +const useStyles = makeStyles()({ + root: { + width: "100%", + maxWidth: 460, + }, +}); + +function AdminDataRetention() { + const [state, setState] = useState( + initialDataRetentionState + ); + const [noRetention, setNoRetention] = useState(true); + const [showWarningDialog, setShowWarningDialog] = useState(false); + const loadingSelector = createLoadingSelector(["GET_WHOAMI"]); + const whoamiFetching = useSelector(loadingSelector); + const [isPosting, setIsPosting] = useState(false); + const dispatch = useDispatch(); + const { classes } = useStyles(); + const whoami = useSelector(getWhoami); + + // Calculate total days for comparison + // Note: This uses approximate values for simplicity: + // - Months are assumed to be 30 days + // - Years are assumed to be 365 days + // For precise calculations, backend should use actual calendar dates + const getTotalDays = (value: number, unit: TimeUnit): number => { + switch (unit) { + case TimeUnit.DAYS: + return value; + case TimeUnit.WEEKS: + return value * 7; + case TimeUnit.MONTHS: + return value * 30; // Approximate + case TimeUnit.YEARS: + return value * 365; // Approximate, not accounting for leap years + default: + return value; + } + }; + + function handleSaveConfirmed() { + setIsPosting(true); + // Simulate async operation for better UX + setTimeout(() => { + // TODO: Implement actual save logic with DataStore or API + if (noRetention) { + console.log("Saving data retention settings: indefinite"); + dispatch( + displayInfoNotification( + "Data retention set to indefinite (no automatic deletion)" + ) + ); + } else { + console.log("Saving data retention settings:", state); + dispatch( + displayInfoNotification( + `Data retention set to ${state.value} ${state.unit}` + ) + ); + } + setIsPosting(false); + setShowWarningDialog(false); + }, 500); + } + + function handleSave() { + // Check if retention is enabled and less than 7 days + if (!noRetention) { + const totalDays = getTotalDays(state.value, state.unit); + if (totalDays < 7) { + setShowWarningDialog(true); + return; + } + } + handleSaveConfirmed(); + } + + if (whoamiFetching) { + return ( + + + + ); + } else if (!whoami.roles.includes(models.Role.ADMIN)) { + return ; + } else { + const totalDays = !noRetention + ? getTotalDays(state.value, state.unit) + : 0; + return ( + + + + Set data retention time + + + Determine how long data should be retained before + automatic deletion + + + setNoRetention(e.target.checked) + } + disabled={isPosting} + /> + } + label="Retain data indefinitely (no automatic deletion)" + /> + {!noRetention && ( + + )} + + + setShowWarningDialog(false)} + onConfirmation={handleSaveConfirmed} + dialogTitle="Short Retention Period Warning" + > + + You are setting a very short data retention period of{" "} + + {totalDays} day{totalDays !== 1 ? "s" : ""} + + . All data older than this will be automatically deleted. + + + Are you sure you want to proceed with this setting? + + + + ); + } +} + +export default AdminDataRetention;