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 */}
-
{/* 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);