diff --git a/src/client/app/components/RadarChartComponent.tsx b/src/client/app/components/RadarChartComponent.tsx index 2402769b6..495d78b4d 100644 --- a/src/client/app/components/RadarChartComponent.tsx +++ b/src/client/app/components/RadarChartComponent.tsx @@ -27,6 +27,15 @@ import { lineUnitLabel } from '../utils/graphics'; import { useTranslate } from '../redux/componentHooks'; import SpinnerComponent from './SpinnerComponent'; + +// Display Plotly Buttons Feature +// The number of items in defaultButtons and advancedButtons must differ as discussed below +const defaultButtons: Plotly.ModeBarDefaultButtons[] = [ + 'zoom2d', 'pan2d', 'select2d', 'lasso2d', 'zoomIn2d', + 'zoomOut2d', 'autoScale2d', 'resetScale2d' +]; +const advancedButtons: Plotly.ModeBarDefaultButtons[] = ['select2d', 'lasso2d', 'autoScale2d', 'resetScale2d']; + /** * @returns radar plotly component */ @@ -35,7 +44,6 @@ export default function RadarChartComponent() { const { meterArgs, groupArgs, meterShouldSkip, groupShouldSkip } = useAppSelector(selectRadarChartQueryArgs); const { data: meterReadings, isLoading: meterIsLoading } = readingsApi.useLineQuery(meterArgs, { skip: meterShouldSkip }); const { data: groupData, isLoading: groupIsLoading } = readingsApi.useLineQuery(groupArgs, { skip: groupShouldSkip }); - const datasets: any[] = []; // graphic unit selected const graphingUnit = useAppSelector(selectSelectedUnit); // The current selected rate @@ -48,10 +56,12 @@ export default function RadarChartComponent() { const selectedGroups = useAppSelector(selectSelectedGroups); const meterDataById = useAppSelector(selectMeterDataById); const groupDataById = useAppSelector(selectGroupDataById); + // Manage button states with useState + const [listOfButtons, setListOfButtons] = React.useState(defaultButtons); + const datasets: any[] = []; if (meterIsLoading || groupIsLoading) { return ; - // return } let unitLabel = ''; @@ -70,14 +80,6 @@ export default function RadarChartComponent() { // The rate will be 1 if it is per hour (since state readings are per hour) or no rate scaling so no change. const rateScaling = needsRateScaling ? currentSelectedRate.rate : 1; - // Display Plotly Buttons Feature - // The number of items in defaultButtons and advancedButtons must differ as discussed below - const defaultButtons: Plotly.ModeBarDefaultButtons[] = ['zoom2d', 'pan2d', 'select2d', 'lasso2d', 'zoomIn2d', 'zoomOut2d', 'autoScale2d', - 'resetScale2d']; - const advancedButtons: Plotly.ModeBarDefaultButtons[] = ['select2d', 'lasso2d', 'autoScale2d', 'resetScale2d']; - // Manage button states with useState - const [listOfButtons, setListOfButtons] = React.useState(defaultButtons); - // Add all valid data from existing meters to the radar plot for (const meterID of selectedMeters) { if (meterReadings) { diff --git a/src/client/app/components/conversion/CreateConversionModalComponent.tsx b/src/client/app/components/conversion/CreateConversionModalComponent.tsx index cb4ba8bd1..613b29963 100644 --- a/src/client/app/components/conversion/CreateConversionModalComponent.tsx +++ b/src/client/app/components/conversion/CreateConversionModalComponent.tsx @@ -77,9 +77,6 @@ export default function CreateConversionModalComponent() { setConversionState(defaultValues); }; - // Unlike edit, we decided to discard and inputs when you choose to leave the page. The reasoning is - // that create starts from an empty template. - // Submit const handleSubmit = () => { if (validConversion) { diff --git a/src/client/app/components/conversion/EditConversionModalComponent.tsx b/src/client/app/components/conversion/EditConversionModalComponent.tsx index 156f52d74..b73538e81 100644 --- a/src/client/app/components/conversion/EditConversionModalComponent.tsx +++ b/src/client/app/components/conversion/EditConversionModalComponent.tsx @@ -103,8 +103,8 @@ export default function EditConversionModalComponent(props: EditConversionModalC }; const handleClose = () => { - resetState(); props.handleClose(); + resetState(); }; // Save changes diff --git a/src/client/app/components/groups/CreateGroupModalComponent.tsx b/src/client/app/components/groups/CreateGroupModalComponent.tsx index 5eddfe35f..25b33e403 100644 --- a/src/client/app/components/groups/CreateGroupModalComponent.tsx +++ b/src/client/app/components/groups/CreateGroupModalComponent.tsx @@ -178,9 +178,6 @@ export default function CreateGroupModalComponent() { setGraphicUnitsState(graphicUnitsStateDefaults); }; - // Unlike edit, we decided to discard inputs when you choose to leave the page. The reasoning is - // that create starts from an empty template. - // Save changes const handleSubmit = () => { // Close modal first to avoid repeat clicks diff --git a/src/client/app/components/meters/CreateMeterModalComponent.tsx b/src/client/app/components/meters/CreateMeterModalComponent.tsx index 5ef136581..77962b1b8 100644 --- a/src/client/app/components/meters/CreateMeterModalComponent.tsx +++ b/src/client/app/components/meters/CreateMeterModalComponent.tsx @@ -121,9 +121,6 @@ export default function CreateMeterModalComponent(props: CreateMeterModalProps): resetState(); }; - // Unlike edit, we decided to discard and inputs when you choose to leave the page. The reasoning is - // that create starts from an empty template. - // Submit const handleSubmit = async () => { // Close modal first to avoid repeat clicks @@ -697,4 +694,4 @@ export default function CreateMeterModalComponent(props: CreateMeterModalProps): ); -} \ No newline at end of file +} diff --git a/src/client/app/components/unit/CreateUnitModalComponent.tsx b/src/client/app/components/unit/CreateUnitModalComponent.tsx index 51a0a579e..57c265dad 100644 --- a/src/client/app/components/unit/CreateUnitModalComponent.tsx +++ b/src/client/app/components/unit/CreateUnitModalComponent.tsx @@ -16,6 +16,8 @@ import { unitsApi } from '../../redux/api/unitsApi'; import { useTranslate } from '../../redux/componentHooks'; import { showSuccessNotification, showErrorNotification } from '../../utils/notifications'; import { MAX_VAL, MIN_VAL } from '../../redux/selectors/adminSelectors'; +import { LineGraphRates } from '../../types/redux/graph'; +import { customRateValid, isCustomRate } from '../../utils/unitInput'; /** * Defines the create unit modal form @@ -24,6 +26,7 @@ import { MAX_VAL, MIN_VAL } from '../../redux/selectors/adminSelectors'; export default function CreateUnitModalComponent() { const translate = useTranslate(); const [submitCreateUnit] = unitsApi.useAddUnitMutation(); + const CUSTOM_INPUT = '-77'; const defaultValues = { name: '', @@ -32,7 +35,7 @@ export default function CreateUnitModalComponent() { unitRepresent: UnitRepresentType.quantity, displayable: DisplayableType.all, preferredDisplay: true, - secInRate: 3600, + secInRate: LineGraphRates.hour * 3600, suffix: '', note: '', // These two values are necessary but are not used. @@ -49,14 +52,21 @@ export default function CreateUnitModalComponent() { // Unlike EditUnitModalComponent, there are no props so we don't pass show and close via props. // Modal show const [showModal, setShowModal] = useState(false); - const handleClose = () => { - setShowModal(false); - resetState(); - }; - const handleShow = () => setShowModal(true); // Handlers for each type of input change + // Current unit values const [state, setState] = useState(defaultValues); + // If user can save + const [canSave, setCanSave] = useState(false); + // Sets the starting rate for secInRate box, value of 3600 is chosen as default to result in Hour as default in dropdown box. + const [rate, setRate] = useState(String(defaultValues.secInRate)); + // Holds the value during custom value input and it is separate from standard choices. + // Needs to be valid at start and overwritten before used. + const [customRate, setCustomRate] = useState(1); + // should only update customRate when save all is clicked + // This should keep track of rate's value and set custom rate equal to it when custom rate is clicked + // True if custom value input is active. + const [showCustomInput, setShowCustomInput] = useState(false); const handleStringChange = (e: React.ChangeEvent) => { setState({ ...state, [e.target.name]: e.target.value }); @@ -66,46 +76,107 @@ export default function CreateUnitModalComponent() { setState({ ...state, [e.target.name]: JSON.parse(e.target.value) }); }; - const handleNumberChange = (e: React.ChangeEvent) => { - setState({ ...state, [e.target.name]: Number(e.target.value) }); + /** + * Updates the rate (both custom and regular state) including setting if custom. + * @param newRate The new rate to set. + */ + const updateRates = (newRate: number) => { + const isCustom = isCustomRate(newRate); + setShowCustomInput(isCustom); + if (newRate !== Number(CUSTOM_INPUT)) { + // Should only update with the new rate if did not just select custom + // input from the menu. + setCustomRate(newRate); + } + setRate(isCustom ? CUSTOM_INPUT : newRate.toString()); + }; + + // Keeps react-level state, and redux state in sync for sec. in rate. + // Two different layers in state may differ especially when externally updated (chart link, history buttons.) + React.useEffect(() => { + updateRates(state.secInRate); + }, [state.secInRate]); + + /* + UI events: + - When the user selects a new rate from the dropdown,`rate` is updated. + - If the user selects the custom value option, `showCustomInput` is set to true. + - When the user enters a custom value, `customRate` is updated. + - The initial value of `customRate` is set to the previously chosen value of `rate` + - Make sure that when submit button is clicked, that the state.secInRate is set to the correct value. + */ + const handleRateChange = (e: React.ChangeEvent) => { + const { value } = e.target; + // The input only allows a number so this should be safe. + setState({ ...state, secInRate: Number(value) }); + }; + + const handleCustomRateChange = (e: React.ChangeEvent) => { + const { value } = e.target; + // Don't update state here since wait for enter to allow to enter custom value + // that starts the same as a standard value. + setCustomRate(Number(value)); }; - /* Create Unit Validation: - Name cannot be blank - Sec in Rate must be greater than zero - If type of unit is suffix their must be a suffix - */ - const [validUnit, setValidUnit] = useState(false); + const handleEnter = (key: string) => { + // This detects the enter key and then uses the previously entered custom + // rate to set the rate as a new value. + if (key === 'Enter') { + // Form only allows integers so this should be safe. + setState({ ...state, secInRate: Number(customRate) }); + } + }; + + // Keeps canSave state up to date. Checks if valid and if edit made. useEffect(() => { setValidUnit(state.name !== '' && state.secInRate > 0 && (state.typeOfUnit !== UnitType.suffix || state.suffix !== '') && (state?.minVal < MIN_VAL || state?.minVal > state?.maxVal)); }, [state.name, state.secInRate, state.typeOfUnit, state.suffix]); + + // This checks: + // - Name cannot be blank + // - If type of unit is suffix there must be a suffix + // - The rate is set so not the custom input value. This happens if select custom value but don't input with enter. + // - The custom rate is a positive integer + const validUnit = state.name !== '' && + (state.typeOfUnit !== UnitType.suffix || state.suffix !== '') && state.secInRate !== Number(CUSTOM_INPUT) + && customRateValid(Number(state.secInRate)); + setCanSave(validUnit); + }, [state]); + /* End State */ // Reset the state to default values + // To be used for the discard changes and save button const resetState = () => { setState(defaultValues); + updateRates(state.secInRate); }; - // Unlike edit, we decided to discard inputs when you choose to leave the page. The reasoning is - // that create starts from an empty template. + const handleShow = () => { + setShowModal(true); + }; - // Submit - const handleSubmit = () => { + const handleClose = () => { + setShowModal(false); + resetState(); + }; + + // Save + const handleSaveChanges = () => { // Close modal first to avoid repeat clicks setShowModal(false); - // Set default identifier as name if left blank - state.identifier = (!state.identifier || state.identifier.length === 0) ? state.name : state.identifier; - // set displayable to none if unit is meter - if (state.typeOfUnit == UnitType.meter && state.displayable != DisplayableType.none) { - state.displayable = DisplayableType.none; - } - // set unit to suffix if suffix is not empty - if (state.typeOfUnit != UnitType.suffix && state.suffix != '') { - state.typeOfUnit = UnitType.suffix; - } + const submitState = { + ...state, + // Set default identifier as name if left blank + identifier: !state.identifier || state.identifier.length === 0 ? state.name : state.identifier, + // set displayable to none if unit is meter + displayable: (state.typeOfUnit == UnitType.meter && state.displayable != DisplayableType.none) ? DisplayableType.none : state.displayable, + // set unit to suffix if suffix is not empty + typeOfUnit: (state.typeOfUnit != UnitType.suffix && state.suffix != '') ? UnitType.suffix : state.typeOfUnit + }; // Add the new unit and update the store - submitCreateUnit(state) + submitCreateUnit(submitState) .unwrap() .then(() => { showSuccessNotification(translate('unit.successfully.create.unit')); @@ -124,153 +195,242 @@ export default function CreateUnitModalComponent() { return ( <> {/* Show modal button */} - - + - +
- +
{/* when any of the unit properties are changed call one of the functions. */} - - - {/* Identifier input */} - - - handleStringChange(e)} - value={state.identifier} /> - - {/* Name input */} - - - handleStringChange(e)} - value={state.name} - invalid={state.name === ''} /> - - - - - - - {/* Type of unit input */} - - - handleStringChange(e)} - value={state.typeOfUnit} - invalid={state.typeOfUnit != UnitType.suffix && state.suffix != ''}> - {Object.keys(UnitType).map(key => { - return (); - })} - - - - - - {/* Unit represent input */} - - - handleStringChange(e)} - value={state.unitRepresent}> - {Object.keys(UnitRepresentType).map(key => { - return (); - })} - - - - - {/* Displayable type input */} - - - handleStringChange(e)} - value={state.displayable} - invalid={state.displayable != DisplayableType.none && (state.typeOfUnit == UnitType.meter || state.suffix != '')}> - {Object.keys(DisplayableType).map(key => { - return (); - })} - - - {state.displayable !== DisplayableType.none && state.typeOfUnit == UnitType.meter ? ( - - ) : ( - - )} - - - {/* Preferred display input */} - - - handleBooleanChange(e)}> - {Object.keys(TrueFalseType).map(key => { - return (); - })} - - - - - - {/* Seconds in rate input */} - - - handleNumberChange(e)} - defaultValue={state.secInRate} - min='1' - invalid={state.secInRate <= 0} /> - - - - - {/* Suffix input */} - - - handleStringChange(e)} - value={state.suffix} - invalid={state.typeOfUnit === UnitType.suffix && state.suffix === ''} /> - - - - - + + + + {/* Identifier input */} + + + + handleStringChange(e)} + value={state.identifier} + /> + + + {/* Name input */} + + + + handleStringChange(e)} + value={state.name} + invalid={state.name === ''} + /> + + + + + + + + {/* Type of unit input */} + + + + handleStringChange(e)} + value={state.typeOfUnit} + invalid={state.typeOfUnit != UnitType.suffix && state.suffix != ''} + > + {Object.keys(UnitType).map(key => { + return ( + + ); + })} + + + + + + + {/* Unit represent input */} + + + + handleStringChange(e)} + value={state.unitRepresent} + > + {Object.keys(UnitRepresentType).map(key => { + return ( + + ); + })} + + + + + + {/* Displayable type input */} + + + + handleStringChange(e)} + value={state.displayable} + invalid={ + state.displayable != DisplayableType.none && + (state.typeOfUnit == UnitType.meter || state.suffix != '') + } + > + {Object.keys(DisplayableType).map(key => { + return ( + + ); + })} + + + {state.displayable !== DisplayableType.none && state.typeOfUnit == UnitType.meter ? ( + + ) : ( + + )} + + + + {/* Preferred display input */} + + + + handleBooleanChange(e)} + > + {Object.keys(TrueFalseType).map(key => { + return ( + + ); + })} + + + + + + {/* Seconds in rate input */} + + + + handleRateChange(e)} + > + {Object.entries(LineGraphRates).map( + ([rateKey, rateValue]) => ( + + ) + )} + + + {showCustomInput && ( + <> + + handleCustomRateChange(e)} + // This grabs each key hit and then finishes input when hit enter. + onKeyDown={e => { handleEnter(e.key); }} + /> + + )} + + + {translate('and')}{translate('an.integer')} + + + + {/* Suffix input */} + + + + handleStringChange(e)} + invalid={state.typeOfUnit === UnitType.suffix && state.suffix === '' + } + /> + + + + + + {/* minVal input */} @@ -326,11 +486,11 @@ export default function CreateUnitModalComponent() { {/* Hides the modal */} - {/* On click calls the function handleSaveChanges in this component */} - diff --git a/src/client/app/components/unit/EditUnitModalComponent.tsx b/src/client/app/components/unit/EditUnitModalComponent.tsx index ac4097b46..99535d2ec 100644 --- a/src/client/app/components/unit/EditUnitModalComponent.tsx +++ b/src/client/app/components/unit/EditUnitModalComponent.tsx @@ -22,6 +22,8 @@ import { showErrorNotification, showSuccessNotification } from '../../utils/noti import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; import { MAX_VAL, MIN_VAL } from '../../redux/selectors/adminSelectors'; +import { LineGraphRates } from '../../types/redux/graph'; +import { customRateValid, isCustomRate } from '../../utils/unitInput'; interface EditUnitModalComponentProps { show: boolean; @@ -41,13 +43,29 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp const translate = useTranslate(); const [submitEditedUnit] = unitsApi.useEditUnitMutation(); const [deleteUnit] = unitsApi.useDeleteUnitMutation(); + const CUSTOM_INPUT = '-77'; // Set existing unit values const values = { ...props.unit }; /* State */ // Handlers for each type of input change + // Current unit values const [state, setState] = useState(values); + // Stores if save should be allowed but check for use by a meter is delayed until + // save is hit to avoid doing a lot and to give error message then. + const [canSave, setCanSave] = useState(false); + // The rate for the unit + const [rate, setRate] = useState(String(state.secInRate)); + // Holds the value during custom value input and it is separate from standard choices. + // Needs to be valid at start and overwritten before used. + const [customRate, setCustomRate] = useState(1); + // should only update customRate when save all is clicked + // This should keep track of rate's value and set custom rate equal to it when custom rate is clicked + // True if custom value input is active. + const [showCustomInput, setShowCustomInput] = useState(false); + + // State needed to verify input const conversionData = useAppSelector(selectConversionsDetails); const meterDataByID = useAppSelector(selectMeterDataById); const unitDataByID = useAppSelector(selectUnitDataById); @@ -60,8 +78,47 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp setState({ ...state, [e.target.name]: JSON.parse(e.target.value) }); }; - const handleNumberChange = (e: React.ChangeEvent) => { - setState({ ...state, [e.target.name]: Number(e.target.value) }); + /** + * Updates the rate (both custom and regular state) including setting if custom. + * @param newRate The new rate to set. + */ + const updateRates = (newRate: number) => { + const isCustom = isCustomRate(newRate); + setShowCustomInput(isCustom); + if (newRate !== Number(CUSTOM_INPUT)) { + // Should only update with the new rate if did not just select custom + // input from the menu. + setCustomRate(newRate); + } + setRate(isCustom ? CUSTOM_INPUT : newRate.toString()); + }; + + // Keeps react-level state, and redux state in sync for sec. in rate. + // Two different layers in state may differ especially when externally updated (chart link, history buttons.) + React.useEffect(() => { + updateRates(state.secInRate); + }, [state.secInRate]); + + const handleRateChange = (e: React.ChangeEvent) => { + const { value } = e.target; + // The input only allows a number so this should be safe. + setState({ ...state, secInRate: Number(value) }); + }; + + const handleCustomRateChange = (e: React.ChangeEvent) => { + const { value } = e.target; + // Don't update state here since wait for enter to allow to enter custom value + // that starts the same as a standard value. + setCustomRate(Number(value)); + }; + + const handleEnter = (key: string) => { + // This detects the enter key and then uses the previously entered custom + // rate to set the rate as a new value. + if (key === 'Enter') { + // Form only allows integers so this should be safe. + setState({ ...state, secInRate: Number(customRate) }); + } }; /* Confirm Delete Modal */ @@ -94,18 +151,18 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp let error_message = ''; for (const value of Object.values(meterDataByID)) { // This unit is used by a meter so cannot be deleted. Note if in a group then in a meter so covers both. - if (value.unitId == state.id) { + if (value.unitId === state.id) { // TODO see EditMeterModalComponent for issue with line breaks. Same issue in strings below. error_message += ` ${translate('meter')} "${value.name}" ${translate('uses')} ${translate('unit')} ` + `"${state.name}" ${translate('as.meter.unit')};`; } - if (value.defaultGraphicUnit == state.id) { + if (value.defaultGraphicUnit === state.id) { error_message += ` ${translate('meter')} "${value.name}" ${translate('uses')} ${translate('unit')} ` + `"${state.name}" ${translate('as.meter.defaultgraphicunit')};`; } } for (let i = 0; i < conversionData.length; i++) { - if (conversionData[i].sourceId == state.id) { + if (conversionData[i].sourceId === state.id) { // This unit is the source of a conversion so cannot be deleted. error_message += ` ${translate('conversion')} ${unitDataByID[conversionData[i].sourceId].name}` + `${conversionArrow(conversionData[i].bidirectional)}` + @@ -113,7 +170,7 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp ` "${state.name}" ${translate('unit.source.error')};`; } - if (conversionData[i].destinationId == state.id) { + if (conversionData[i].destinationId === state.id) { // This unit is the destination of a conversion so cannot be deleted. error_message += ` ${translate('conversion')} ${unitDataByID[conversionData[i].sourceId].name}` + `${conversionArrow(conversionData[i].bidirectional)}` + @@ -133,17 +190,34 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp } }; - /* Edit Unit Validation: - Name cannot be blank - Sec in Rate must be greater than zero - Unit type mismatches checked on submit - If type of unit is suffix their must be a suffix - */ - const [validUnit, setValidUnit] = useState(false); + // Keeps canSave state up to date. Checks if valid and if edit made. useEffect(() => { - setValidUnit(state.name !== '' && state.secInRate > 0 && - (state.typeOfUnit !== UnitType.suffix || state.suffix !== '')); - }, [state.name, state.secInRate, state.typeOfUnit, state.suffix]); + // This checks: + // - Name cannot be blank + // - If type of unit is suffix there must be a suffix + // - The rate is set so not the custom input value. This happens if select custom value but don't input with enter. + // - The custom rate is a positive integer + const validUnit = state.name !== '' && + (state.typeOfUnit !== UnitType.suffix || state.suffix !== '') && state.secInRate !== Number(CUSTOM_INPUT) + && customRateValid(Number(state.secInRate)); + // Compare original props to state to see if edit made. Check above avoids thinking edit happened if + // custom edit started without enter hit. + const editMade = + props.unit.name !== state.name + || props.unit.identifier !== state.identifier + || props.unit.typeOfUnit !== state.typeOfUnit + || props.unit.unitRepresent !== state.unitRepresent + || props.unit.displayable !== state.displayable + || props.unit.preferredDisplay !== state.preferredDisplay + || props.unit.secInRate !== state.secInRate + || props.unit.suffix !== state.suffix + || props.unit.note !== state.note; + || props.unit.minVal != state.minVal + || props.unit.maxVal != state.maxVal + || props.unit.disableChecks != state.disableChecks; + setCanSave(validUnit && editMade); + }, [state]); + /* End State */ // Reset the state to default values @@ -153,6 +227,7 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp // Failure to edit units will not trigger a re-render, as no state has changed. Therefore, we must manually reset the values const resetState = () => { setState(values); + updateRates(state.secInRate); }; const handleShow = () => { @@ -167,7 +242,7 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp // Validate the changes and return true if we should update this unit. // Two reasons for not updating the unit: // 1. typeOfUnit is changed from meter to something else while some meters are still linked with this unit - // 2. There are no changes + // 2. There are no changes but save button should stop this. const shouldUpdateUnit = (): boolean => { // true if inputted values are okay and there are changes. let inputOk = true; @@ -184,20 +259,10 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp } } if (inputOk) { - // The input passed validation so check if changes exist. - // Check for case 2 by comparing state to props - return props.unit.name != state.name - || props.unit.identifier != state.identifier - || props.unit.typeOfUnit != state.typeOfUnit - || props.unit.unitRepresent != state.unitRepresent - || props.unit.displayable != state.displayable - || props.unit.preferredDisplay != state.preferredDisplay - || props.unit.secInRate != state.secInRate - || props.unit.suffix != state.suffix - || props.unit.note != state.note - || props.unit.minVal != state.minVal - || props.unit.maxVal != state.maxVal - || props.unit.disableChecks != state.disableChecks; + // The input passed validation so return if canSave set. + // In principle this should always be true since hit save + // be here to be safe and due to old logic setup. + return canSave; } else { // Tell user that not going to update due to input issues. showErrorNotification(`${translate('unit.input.error')}`); @@ -214,6 +279,18 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp props.handleClose(); if (shouldUpdateUnit()) { + + const submitState = { + ...state, + // The updated unit is not fetched to save time. However, the identifier might have been + // automatically set if it was empty. Mimic that here. + identifier: (state.identifier === '') ? state.name : state.identifier, + // set displayable to none if unit is meter + displayable: (state.typeOfUnit === UnitType.meter && state.displayable !== DisplayableType.none) ? DisplayableType.none : state.displayable, + // set unit to suffix if suffix is not empty + typeOfUnit: (state.typeOfUnit !== UnitType.suffix && state.suffix !== '') ? UnitType.suffix : state.typeOfUnit + }; + // Need to redo Cik if the suffix, displayable, or type of unit changes. // For displayable, it only matters if it changes from/to NONE but a more general check is used here for simplification. const shouldRedoCik = props.unit.suffix !== state.suffix @@ -223,16 +300,10 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp const shouldRefreshReadingViews = props.unit.unitRepresent !== state.unitRepresent || (props.unit.secInRate !== state.secInRate && (props.unit.unitRepresent === UnitRepresentType.flow || props.unit.unitRepresent === UnitRepresentType.raw)); - // set displayable to none if unit is meter - if (state.typeOfUnit == UnitType.meter && state.displayable != DisplayableType.none) { - state.displayable = DisplayableType.none; - } - // set unit to suffix if suffix is not empty - if (state.typeOfUnit != UnitType.suffix && state.suffix != '') { - state.typeOfUnit = UnitType.suffix; - } + + // Save our changes by dispatching the submitEditedUnit mutation - submitEditedUnit({ editedUnit: state, shouldRedoCik, shouldRefreshReadingViews }) + submitEditedUnit({ editedUnit: submitState, shouldRedoCik, shouldRefreshReadingViews }) .unwrap() .then(() => { showSuccessNotification(translate('unit.successfully.edited.unit')); @@ -240,11 +311,6 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp .catch(() => { showErrorNotification(translate('unit.failed.to.edit.unit')); }); - // The updated unit is not fetched to save time. However, the identifier might have been - // automatically set if it was empty. Mimic that here. - if (state.identifier === '') { - state.identifier = state.name; - } } }; @@ -283,145 +349,215 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp {/* when any of the unit are changed call one of the functions. */} - - - {/* Identifier input */} - - - handleStringChange(e)} - value={state.identifier} - placeholder='Identifier' /> - - {/* Name input */} - - - handleStringChange(e)} - value={state.name} - invalid={state.name === ''} /> - - - - - - - {/* Type of unit input */} - - - handleStringChange(e)} - value={state.typeOfUnit} - invalid={state.typeOfUnit != UnitType.suffix && state.suffix != ''}> - {Object.keys(UnitType).map(key => { - return (); - })} - - - - - - {/* Unit represent input */} - - - handleStringChange(e)}> - {Object.keys(UnitRepresentType).map(key => { - return (); - })} - - - - - {/* Displayable type input */} - - - handleStringChange(e)} - invalid={state.displayable != DisplayableType.none && (state.typeOfUnit == UnitType.meter || state.suffix != '')}> - {Object.keys(DisplayableType).map(key => { - return (); - })} - - - {state.displayable !== DisplayableType.none && state.typeOfUnit == UnitType.meter ? ( - - ) : ( - - )} - - - {/* Preferred display input */} - - - handleBooleanChange(e)}> - {Object.keys(TrueFalseType).map(key => { - return (); - })} - - - - - - {/* Seconds in rate input */} - - - handleNumberChange(e)} - placeholder='Sec In Rate' - min='1' - invalid={state.secInRate <= 0} /> - - - - - {/* Suffix input */} - - - handleStringChange(e)} - invalid={state.typeOfUnit === UnitType.suffix && state.suffix === ''} /> - - - - - + + + + {/* Identifier input */} + + + + handleStringChange(e)} + value={state.identifier} /> + + + {/* Name input */} + + + + handleStringChange(e)} + value={state.name} + invalid={state.name === ''} /> + + + + + + + + {/* Type of unit input */} + + + + handleStringChange(e)} + value={state.typeOfUnit} + invalid={state.typeOfUnit !== UnitType.suffix && state.suffix !== ''} + > + {Object.keys(UnitType).map(key => { + return ( + + ); + })} + + + + + + + {/* Unit represent input */} + + + + handleStringChange(e)} + > + {Object.keys(UnitRepresentType).map(key => { + return ( + ); + })} + + + + + + {/* Displayable type input */} + + + + handleStringChange(e)} + invalid={ + state.displayable !== DisplayableType.none && + (state.typeOfUnit === UnitType.meter || state.suffix !== '') + } + > + {Object.keys(DisplayableType).map(key => { + return ( + ); + })} + + + {state.displayable !== DisplayableType.none && state.typeOfUnit === UnitType.meter ? ( + + ) : ( + + )} + + + + {/* Preferred display input */} + + + + handleBooleanChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return ( + + ); + })} + + + + + + {/* Seconds in rate input */} + + + + handleRateChange(e)}> + {Object.entries(LineGraphRates).map( + ([rateKey, rateValue]) => ( + + ) + )} + + + {showCustomInput && ( + <> + + handleCustomRateChange(e)} + // This grabs each key hit and then finishes input when hit enter. + onKeyDown={e => { handleEnter(e.key); }} + /> + + )} + + + {translate('and')}{translate('an.integer')} + + + + {/* Suffix input */} + + + + handleStringChange(e)} + invalid={state.typeOfUnit === UnitType.suffix && state.suffix === ''} /> + + + + + + {/* minVal input */} @@ -472,7 +608,6 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp name='note' type='textarea' value={state.note} - placeholder='Note' onChange={e => handleStringChange(e)} /> @@ -485,7 +620,7 @@ export default function EditUnitModalComponent(props: EditUnitModalComponentProp {/* On click calls the function handleSaveChanges in this component */} - diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 2ed7ccbc7..ecca4e8a8 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -16,6 +16,8 @@ const LocaleTranslationData = { "admin.only": "Admin Only", "admin.settings": "Admin Settings", "alphabetically": "Alphabetically", + "an.integer": "an integer", + "and": " and ", "area": "Area:", "area.but.no.unit": "You have entered a nonzero area but no area unit.", "area.calculate.auto": "Calculate Group Area", @@ -29,6 +31,7 @@ const LocaleTranslationData = { "as.meter.unit": "as meter unit", "ascending": "Ascending", "bar": "Bar", + "bar.days.enter": "Input days and then hit enter", "bar.interval": "Bar Interval", "bar.raw": "Cannot create bar graph on raw units such as temperature", "bar.stacking": "Bar Stacking", @@ -491,6 +494,7 @@ const LocaleTranslationData = { "unit.preferred.display": "Preferred Display:", "unit.represent": "Unit Represent:", "unit.sec.in.rate": "Sec in Rate:", + "unit.sec.in.rate.enter": "Input seconds in rate and then hit enter:", "unit.source.error": "as the source unit", "unit.submit.new.unit": "Submit New Unit", "unit.successfully.create.unit": "Successfully created a unit.", @@ -556,6 +560,8 @@ const LocaleTranslationData = { "admin.only": "Uniquement pour Les Administrateurs", "admin.settings": "Admin Settings\u{26A1}", "alphabetically": "Alphabétiquement", + "an.integer": "an integer\u{26A1}", + "and": " and\u{26A1} ", "area": "Région:", "area.but.no.unit": "You have entered a nonzero area but no area unit.\u{26A1}", "area.calculate.auto": "Calculate Group Area\u{26A1}", @@ -569,6 +575,7 @@ const LocaleTranslationData = { "as.meter.unit": "as meter unit\u{26A1}", "ascending": "Ascendant", "bar": "Bande", + "bar.days.enter": "Input days and then hit enter\u{26A1}", "bar.interval": "Intervalle du Diagramme à Bandes", "bar.raw": "Cannot create bar graph on raw units such as temperature\u{26A1}", "bar.stacking": "Empilage de Bandes", @@ -1028,6 +1035,7 @@ const LocaleTranslationData = { "unit.preferred.display": "Preferred Display:\u{26A1}", "unit.represent": "Unit Represent:\u{26A1}", "unit.sec.in.rate": "Sec in Rate:\u{26A1}", + "unit.sec.in.rate.enter": "Input seconds in rate and then hit enter:\u{26A1}", "unit.source.error": "as the source unit\u{26A1}", "unit.submit.new.unit": "Submit New Unit\u{26A1}", "unit.successfully.create.unit": "Successfully created a unit.\u{26A1}", @@ -1096,6 +1104,8 @@ const LocaleTranslationData = { "admin.only": "Solo administrador", "admin.settings": "Admin Settings\u{26A1}", "alphabetically": "Alfabéticamente", + "an.integer": "an integer\u{26A1}", + "and": " y ", "area": "Área:", "area.but.no.unit": "Ha ingresado un área distinta a cero sin unidad de área.", "area.calculate.auto": "Calcular el área del grupo", @@ -1109,6 +1119,7 @@ const LocaleTranslationData = { "as.meter.unit": "como unidad de medidor", "ascending": "Ascendiente", "bar": "Barra", + "bar.days.enter": "input\u{26A1} los días y presione \"Enter\"", "bar.interval": "Intervalo de barra", "bar.raw": "No se puede crear un gráfico de barras con unidades crudas como la temperatura", "bar.stacking": "Apilamiento de barras", @@ -1572,6 +1583,7 @@ const LocaleTranslationData = { "unit.preferred.display": "Visualización preferida:", "unit.represent": "Unidad representa:", "unit.sec.in.rate": "Segundos en tasa:", + "unit.sec.in.rate.enter": "Input seconds in rate and then hit enter:\u{26A1}", "unit.source.error": "como la unidad de la fuente", "unit.submit.new.unit": "Ingresar una nueva unidad", "unit.successfully.create.unit": "Unidad creada con éxito.", diff --git a/src/client/app/utils/unitInput.ts b/src/client/app/utils/unitInput.ts new file mode 100644 index 000000000..01a275a0a --- /dev/null +++ b/src/client/app/utils/unitInput.ts @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { LineGraphRates } from '../types/redux/graph'; + +// Checks if custom rate is valid by verifying that it is a positive integer. +export const customRateValid = (customRate: number) => { + return Number.isInteger(customRate) && customRate >= 1; +}; + +/** + * Determines if the rate is custom. + * @param rate The rate to check + * @returns true if the rate is custom and false if it is a standard value. + */ +export const isCustomRate = (rate: number) => { + // Check if the rate is a custom rate. + return !Object.entries(LineGraphRates).some( + ([, rateValue]) => { + // Multiply each rate value by 3600, round it to the nearest integer, + // and compare it to the given rate + return Math.round(rateValue * 3600) === rate; + }); +}; diff --git a/src/server/test/web/readingsCompareMeterQuantity.js b/src/server/test/web/readingsCompareMeterQuantity.js index 3b1b0fb93..a602f9993 100644 --- a/src/server/test/web/readingsCompareMeterQuantity.js +++ b/src/server/test/web/readingsCompareMeterQuantity.js @@ -69,7 +69,21 @@ mocha.describe('readings API', () => { expectCompareToEqualExpected(res, expected); }); - // Add C4 here + mocha.it('C4: 1 day shift end 2022-11-01 00:00:00 (full day) for 15 minute reading intervals and quantity units & kWh as kWh', async () => { + await prepareTest(unitDatakWh, conversionDatakWh, meterDatakWh); + // Get the unit ID since the DB could use any value. + const unitId = await getUnitId('kWh'); + const expected = [4290.60000224332, 4842.21261747704]; + // for compare, need the unitID, currentStart, currentEnd, shift + const res = await chai.request(app).get(`/api/compareReadings/meters/${METER_ID}`) + .query({ + curr_start: '2022-10-31 00:00:00', + curr_end: '2022-11-01 00:00:00', + shift: 'P1D', + graphicUnitId: unitId + }); + expectCompareToEqualExpected(res, expected); + }); mocha.it('C5: 7 day shift end 2022-11-01 15:00:00 (beyond data) for 15 minute reading intervals and quantity units & kWh as kWh', async () => { await prepareTest(unitDatakWh, conversionDatakWh, meterDatakWh);