From 259b595f6e0e673f373023513feed40356078faa Mon Sep 17 00:00:00 2001 From: Doug Martin Date: Wed, 26 Jun 2024 07:47:59 -0400 Subject: [PATCH] feat: Disable inputs during timeseries collection [PT-187852896] --- package-lock.json | 5 ++ package.json | 1 + .../components/experiment-wrapper.module.scss | 7 ++- .../components/experiment-wrapper.tsx | 25 +++++--- src/mobile-app/components/sensor.module.scss | 2 + src/mobile-app/components/sensor.tsx | 33 +++++----- .../components/data-table-field.module.scss | 5 +- src/shared/components/data-table-field.tsx | 61 +++++++++++++------ src/shared/components/experiment.tsx | 7 ++- src/shared/components/form.tsx | 2 + src/shared/components/menu.module.scss | 2 + src/shared/components/menu.tsx | 6 +- .../components/section-button.module.scss | 2 + src/shared/components/section-button.tsx | 6 +- src/shared/components/section.tsx | 10 ++- src/shared/components/variables.scss | 9 ++- 16 files changed, 131 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc1749f..3866887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7181,6 +7181,11 @@ } } }, + "classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "clean-css": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.2.tgz", diff --git a/package.json b/package.json index e004781..c17e2a9 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "ace-builds": "^1.4.8", "ajv": "5.5.2", "aws-sdk": "^2.454.0", + "classnames": "^2.5.1", "client-oauth2": "^4.2.5", "eventemitter3": "^4.0.0", "firebase": "^7.6.1", diff --git a/src/mobile-app/components/experiment-wrapper.module.scss b/src/mobile-app/components/experiment-wrapper.module.scss index 382fd3f..32529f0 100644 --- a/src/mobile-app/components/experiment-wrapper.module.scss +++ b/src/mobile-app/components/experiment-wrapper.module.scss @@ -24,6 +24,8 @@ padding: 2px 4px; width: 100%; + @include add-disabled-class(); + input{ padding: 0; width: 100%; @@ -43,7 +45,10 @@ .headerBackIcon { margin: 5px 15px 0 15px; padding: 0; - &:hover { + + @include add-disabled-class(); + + &:hover:not(.disabled) { cursor: pointer; } svg { diff --git a/src/mobile-app/components/experiment-wrapper.tsx b/src/mobile-app/components/experiment-wrapper.tsx index dc65cd9..de169c1 100644 --- a/src/mobile-app/components/experiment-wrapper.tsx +++ b/src/mobile-app/components/experiment-wrapper.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import classNames from "classnames"; import { IExperiment, IExperimentConfig, IExperimentData } from "../../shared/experiment-types"; import { Experiment } from "../../shared/components/experiment"; import { Initials } from "../../shared/components/initials"; @@ -29,6 +30,7 @@ const experimentConfig: IExperimentConfig = { export const ExperimentWrapper: React.FC = ({ experiment, experimentIdx, data, onDataChange, onBackBtnClick, onUpload, embeddedPreview }) => { const [editing, isEditing ] = useState(false); + const [inputDisabled, setInputDisabled] = useState(false); const { metadata } = experiment; const workSpaceClass = embeddedPreview ? `${css.workspace} ${css.embeddedPreview}`: css.workspace; @@ -68,27 +70,34 @@ export const ExperimentWrapper: React.FC = ({ experiment, experimentIdx, return (
-
+
-
+
{editing - ? - :
{name}
+ ? + :
{name}
}
{title}
- Save - Upload - Rename + Save + Upload + Rename
- +
); diff --git a/src/mobile-app/components/sensor.module.scss b/src/mobile-app/components/sensor.module.scss index 474d8e0..d811c9a 100644 --- a/src/mobile-app/components/sensor.module.scss +++ b/src/mobile-app/components/sensor.module.scss @@ -19,6 +19,8 @@ align-items: center; flex-grow: 1; /* to allow the label to take up as much room as possible */ + @include add-disabled-class(); + // for selectable sensor select { margin-left: 7px; diff --git a/src/mobile-app/components/sensor.tsx b/src/mobile-app/components/sensor.tsx index 2226296..f52bfab 100644 --- a/src/mobile-app/components/sensor.tsx +++ b/src/mobile-app/components/sensor.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useMemo, useEffect } from "react"; +import classNames from "classnames"; import { SensorValue } from "./sensor-value"; import { Sensor, IConnectDevice, SelectDeviceFn, ITimeSeriesCapabilities, MaxNumberOfTimeSeriesValues, ISensorValues } from "../../sensors/sensor"; import { useSensor } from "../hooks/use-sensor"; @@ -15,11 +16,12 @@ interface ISensorSelectorProps { devices: IConnectDevice[]; selectDevice: SelectDeviceFn; cancel: () => void; + inputDisabled?: boolean; } const maxTime = 3; -export const SensorSelectorComponent: React.FC = ({devices, selectDevice, cancel}) => { +export const SensorSelectorComponent: React.FC = ({devices, selectDevice, cancel, inputDisabled}) => { const sortedDevices = devices.sort((a, b) => a.id.localeCompare(b.id)); const handleCancel = () => cancel(); return ( @@ -27,13 +29,13 @@ export const SensorSelectorComponent: React.FC = ({devices
Choose a sensor...
-
Cancel
+
Cancel
{sortedDevices.map((device, index) => { const handleSelectDevice = () => selectDevice(device); return ( -
+
{device.name}
@@ -54,6 +56,7 @@ interface ISensorComponentProps { selectableSensorId?: any; setTimeSeriesMeasurementPeriod?: (measurementPeriod: number) => void; setSelectableSensorId?: (id: any) => void; + inputDisabled?: boolean; } const iconClass = { @@ -68,7 +71,7 @@ const iconClassHi = { error: css.errorIcon }; -export const SensorComponent: React.FC = ({sensor, manualEntryMode, setManualEntryMode, isTimeSeries, timeSeriesCapabilities, selectableSensorId, setTimeSeriesMeasurementPeriod, setSelectableSensorId}) => { +export const SensorComponent: React.FC = ({sensor, manualEntryMode, setManualEntryMode, isTimeSeries, timeSeriesCapabilities, selectableSensorId, setTimeSeriesMeasurementPeriod, setSelectableSensorId, inputDisabled}) => { const {connected, connecting, deviceName, values, error} = useSensor(sensor); const [devicesFound, setDevicesFound] = useState([]); @@ -159,22 +162,24 @@ export const SensorComponent: React.FC = ({sensor, manual
); + const connectionLabelClassName = classNames(css.connectionLabel, {[css.disabled]: inputDisabled}); + const renderError = () => ( -
+
{renderIcon("error")} {error.toString()}
); const renderDisconnected = () => ( -
+
{renderIcon("disconnected")} No Sensor Connected
); const renderConnecting = () => ( -
+
{renderIcon("connected")} {inCordova ? "Searching..." : "Connecting..."}
@@ -186,14 +191,14 @@ export const SensorComponent: React.FC = ({sensor, manual } return ( - {sensor.selectableSensors.map(s => )} ); }; const renderConnected = () => ( -
+
{renderIcon("connected")} Connected: {renderDeviceName()}
@@ -207,10 +212,10 @@ export const SensorComponent: React.FC = ({sensor, manual return ( {manualEntryMode && canSwitchModes - ? Sensor Mode + ? Sensor Mode : <> - {connected ? Disconnect : Connect} - {canSwitchModes ? Edit Mode : undefined} + {connected ? Disconnect : Connect} + {canSwitchModes ? Edit Mode : undefined} } ); @@ -256,7 +261,7 @@ export const SensorComponent: React.FC = ({sensor, manual
Samples:
- {timeSeriesPeriods.map(p => )}
@@ -330,7 +335,7 @@ export const SensorComponent: React.FC = ({sensor, manual {error ? renderError() : (connected ? renderConnected() : (connecting ? renderConnecting() : renderDisconnected()))} {renderMenu()}
- {showDeviceSelect ? : undefined} + {showDeviceSelect ? : undefined} {isTimeSeries ? renderTimeSeries() : renderValues()}
); diff --git a/src/shared/components/data-table-field.module.scss b/src/shared/components/data-table-field.module.scss index b806f00..b5cc969 100644 --- a/src/shared/components/data-table-field.module.scss +++ b/src/shared/components/data-table-field.module.scss @@ -163,6 +163,9 @@ $headerBorder: #fff; display: flex; justify-content: center; align-items: center; + + @include add-disabled-class($opacity: 0.35); + &.active { background-color: $sensorGreenLight1; &.refresh { @@ -171,7 +174,7 @@ $headerBorder: #fff; &.record { background-color: $sensorGreenLight1; } - &:hover { + &:hover:not(.disabled) { cursor: pointer; } } diff --git a/src/shared/components/data-table-field.tsx b/src/shared/components/data-table-field.tsx index 4d2c4fc..64dc148 100644 --- a/src/shared/components/data-table-field.tsx +++ b/src/shared/components/data-table-field.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import React, { useState, useEffect, useRef, useMemo } from "react"; +import classNames from "classnames"; import { FieldProps } from "react-jsonschema-form"; import { MockSensor } from "../../sensors/mock-sensor"; import { ISensorCapabilities, ISensorConnectionEventData, ITimeSeriesCapabilities, MaxNumberOfTimeSeriesValues, SensorCapabilityKey, SensorEvent } from "../../sensors/sensor"; @@ -187,6 +188,7 @@ export const DataTableField: React.FC = props => { return result; }, [isTimeSeries, formData]); const [selectableSensorId, setSelectableSensorId] = useState(); + const {inputDisabled, setInputDisabled} = formContext; // listen for prop changes from uploads useEffect(() => { @@ -277,10 +279,10 @@ export const DataTableField: React.FC = props => { // for now select is the same as input but that will change once we add input validation const handleSelectChange = (rowIdx: number, propName: string, event: React.FormEvent) => { - handleInputChange(rowIdx, propName, event); + !inputDisabled && handleInputChange(rowIdx, propName, event); }; const handleSelectBlur = () => { - handleInputBlur(); + !inputDisabled && handleInputBlur(); }; const handleInputChange = (rowIdx: number, propName: string, event: React.FormEvent) => { @@ -288,10 +290,14 @@ export const DataTableField: React.FC = props => { const newData = formData.slice(); const rawValue = event.currentTarget.value; newData[rowIdx] = Object.assign({}, newData[rowIdx], { [propName]: rawValue }); - setFormData(newData); + !inputDisabled && setFormData(newData); }; const handleInputBlur = () => { + if (inputDisabled) { + return; + } + const oldData = props.formData; if (formData !== oldData) { // New data has been added or something has been edited. @@ -310,10 +316,13 @@ export const DataTableField: React.FC = props => { } }; - const handleEditSaveButton = () => setManualEntryMode(!manualEntryMode); - const handleCollectButton = () => setShowSensor(!showSensor); + const handleEditSaveButton = () => !inputDisabled && setManualEntryMode(!manualEntryMode); + const handleCollectButton = () => !inputDisabled && setShowSensor(!showSensor); const handleDeleteDataTrial = (rowIdx: number) => { + if (inputDisabled) { + return; + } confirm("Delete Trial?\n\nThis will delete the trial.", () => { const newData = formData.slice(); newData[rowIdx] = {}; @@ -323,6 +332,9 @@ export const DataTableField: React.FC = props => { }; const onSensorRecordClick = (rowIdx: number) => { + if (inputDisabled) { + return; + } if (!sensorOutput.connected) { alert("Sensor not connected"); return; @@ -355,6 +367,8 @@ export const DataTableField: React.FC = props => { return; } + setInputDisabled?.(true); + stopTimeSeriesFnRef.current = sensor.collectTimeSeries(timeSeriesCapabilities.measurementPeriod, selectableSensorId, (values) => { const newData = formData.slice(); newData[rowIdx] = {timeSeries: values}; @@ -384,11 +398,13 @@ export const DataTableField: React.FC = props => { }; const onSensorStopTimeSeries = (finalData: IDataTableRow[]) => { + // no check of input disabled here as this handler updates it if (stopTimeSeriesFnRef.current) { stopTimeSeriesFnRef.current?.(); stopTimeSeriesFnRef.current = undefined; timeSeriesRecordingRowRef.current = undefined; saveData(finalData); + setInputDisabled?.(false); } }; @@ -411,12 +427,19 @@ export const DataTableField: React.FC = props => { const iconName: IconName = sensorFieldsBlank ? (isTimeSeries ? "recordDataTrial" : "record") : (isTimeSeries ? (showStopButton ? "stopDataTrial" : "deleteDataTrial") : "replay"); const onClick = iconName === "deleteDataTrial" ? handleDeleteDataTrial.bind(null, rowIdx) : (rowActive ? (recordingTimeSeries ? onSensorStopTimeSeries.bind(null, formData) : onSensorRecordClick.bind(null, rowIdx)) : null); rowActive = iconName === "deleteDataTrial" ? true : rowActive; + const buttonDisabled = inputDisabled && !showStopButton; + const className = classNames(css.refreshSensorReading, { + [css.active]: rowActive, + [css.refresh]: !sensorFieldsBlank, + [css.record]: sensorFieldsBlank, + [css.disabled]: buttonDisabled, + }); const refreshBtnCell = { anyNonFunctionSensorValues &&
@@ -446,7 +469,7 @@ export const DataTableField: React.FC = props => { = props => { const sensorFieldIdx = sensorFields.indexOf(name); const isSensorField = sensorFieldIdx !== -1; const {valid, error} = isFunction ? {valid: true, error: undefined} : validateInput(name, String(value)); - let classNames = ""; - if (readOnly) classNames += " " + css.readOnly; - if (isSensorField) classNames += " " + css.sensorField; - if (isFunction) classNames += " " + css.function; - if (!valid) { - classNames += " " + css.invalid; - } + const rowClassName = classNames({ + [css.readOnly]: readOnly, + [css.sensorField]: isSensorField, + [css.function]: isFunction, + [css.invalid]: !valid, + }); let contents; if (name === "timeSeries") { @@ -519,7 +541,7 @@ export const DataTableField: React.FC = props => {
; } - return {contents}; + return {contents}; }); }; const renderPromptForData = (isFirst: boolean) => { @@ -533,8 +555,8 @@ export const DataTableField: React.FC = props => { ); }; - const buttonStyle = (enabled: boolean) => `${css.button}${enabled ? "" : ` ${css.buttonDisabled}`}`; - const editEnabled = !showSensor; + const buttonStyle = (enabled: boolean) => classNames(css.button, {[css.buttonDisabled]: !enabled}); + const editEnabled = !showSensor && !inputDisabled; const editTitle = editEnabled ? undefined : "Disabled while using the sensor"; const showSensorEnabled = !manualEntryMode; const showSensorTitle = showSensorEnabled ? undefined : "Disabled while editing"; @@ -549,6 +571,7 @@ export const DataTableField: React.FC = props => { manualEntryMode={manualEntryMode} setManualEntryMode={showShowSensorButton ? undefined : setManualEntryMode} isTimeSeries={isTimeSeries} + inputDisabled={inputDisabled} timeSeriesCapabilities={timeSeriesCapabilities} selectableSensorId={selectableSensorId} setTimeSeriesMeasurementPeriod={setTimeSeriesMeasurementPeriod} diff --git a/src/shared/components/experiment.tsx b/src/shared/components/experiment.tsx index 41bd8d7..6b6b5f5 100644 --- a/src/shared/components/experiment.tsx +++ b/src/shared/components/experiment.tsx @@ -12,9 +12,11 @@ interface IProps { onDataChange?: (newData: IExperimentData) => void; config: IExperimentConfig; defaultSectionIndex?: number; + inputDisabled?: boolean; + setInputDisabled?: React.Dispatch>; } -export const Experiment: React.FC = ({ experiment, data, onDataChange, config, defaultSectionIndex }) => { +export const Experiment: React.FC = ({ experiment, data, onDataChange, config, defaultSectionIndex, inputDisabled, setInputDisabled }) => { const { schema } = experiment; const { sections } = schema; const [section, setSection] = useState(sections[defaultSectionIndex || 0]); @@ -44,6 +46,7 @@ export const Experiment: React.FC = ({ experiment, data, onDataChange, c active={s === section} title={s.title} icon={s.icon as IconName} + disabled={inputDisabled} onClick={setSection.bind(null, s)} /> ) @@ -55,6 +58,8 @@ export const Experiment: React.FC = ({ experiment, data, onDataChange, c experiment={experiment} experimentConfig={config} formData={currentData} + inputDisabled={inputDisabled} + setInputDisabled={setInputDisabled} onDataChange={onExperimentDataChange} />
diff --git a/src/shared/components/form.tsx b/src/shared/components/form.tsx index 5839233..d2a7c40 100644 --- a/src/shared/components/form.tsx +++ b/src/shared/components/form.tsx @@ -12,6 +12,8 @@ export interface IVortexFormContext { experiment: IExperiment; experimentConfig: IExperimentConfig; formData: IExperimentData; + inputDisabled?: boolean; + setInputDisabled?: React.Dispatch>; sensor?: Sensor; } diff --git a/src/shared/components/menu.module.scss b/src/shared/components/menu.module.scss index bf9c23f..b1cfcc3 100644 --- a/src/shared/components/menu.module.scss +++ b/src/shared/components/menu.module.scss @@ -38,4 +38,6 @@ width: 24px; margin-right: 10px; } + + @include add-disabled-class($opacity: 0.35); } diff --git a/src/shared/components/menu.tsx b/src/shared/components/menu.tsx index 5abaa19..45db986 100644 --- a/src/shared/components/menu.tsx +++ b/src/shared/components/menu.tsx @@ -1,17 +1,19 @@ import React, { useState, useEffect, useRef } from "react"; +import classNames from "classnames"; import { Icon, IconName } from "./icon"; import css from "./menu.module.scss"; interface IMenuItemProps { onClick: () => void; + disabled?: boolean; icon?: IconName; } interface IMenuProps { icon?: IconName; } -export const MenuItemComponent: React.FC = ({onClick, icon, children}) => { +export const MenuItemComponent: React.FC = ({onClick, icon, disabled, children}) => { return ( -
+
{icon ? :
} {children}
diff --git a/src/shared/components/section-button.module.scss b/src/shared/components/section-button.module.scss index 43f0939..65ee189 100644 --- a/src/shared/components/section-button.module.scss +++ b/src/shared/components/section-button.module.scss @@ -6,6 +6,8 @@ align-items: center; cursor: pointer; + @include add-disabled-class(); + .icon { width: 40px; height: 40px; diff --git a/src/shared/components/section-button.tsx b/src/shared/components/section-button.tsx index bebb6c9..72f119e 100644 --- a/src/shared/components/section-button.tsx +++ b/src/shared/components/section-button.tsx @@ -1,4 +1,5 @@ import React from "react"; +import classNames from "classnames"; import css from "./section-button.module.scss"; import { Icon, IconName } from "./icon"; @@ -6,11 +7,12 @@ interface IProps { title: string; active: boolean; icon: IconName; + disabled?: boolean; onClick?: (event: React.MouseEvent) => void; } -export const SectionButton: React.FC = ({ title, icon, active, onClick }) => -
+export const SectionButton: React.FC = ({ title, icon, active, disabled, onClick }) => +
{ title }
; diff --git a/src/shared/components/section.tsx b/src/shared/components/section.tsx index db4ad3b..a99c365 100644 --- a/src/shared/components/section.tsx +++ b/src/shared/components/section.tsx @@ -18,13 +18,15 @@ interface IProps { formData: IExperimentData; onDataChange?: (newData: IExperimentData) => void; experimentConfig: IExperimentConfig; + inputDisabled?: boolean; + setInputDisabled?: React.Dispatch>; } const SectionComponent: {[name in SectionComponentName]: SectionComponent} = { metadata: Metadata }; -export const Section: React.FC = ({ section, experiment, formData, onDataChange, experimentConfig }) => { +export const Section: React.FC = ({ section, experiment, formData, onDataChange, experimentConfig, inputDisabled, setInputDisabled }) => { let formSchema: IDataSchema | null = null; if (section.formFields && section.formFields.length > 0) { const dataSchema = experiment.schema.dataSchema; @@ -39,7 +41,7 @@ export const Section: React.FC = ({ section, experiment, formData, onDat const onChange = (event: IChangeEvent) => { // Immediately save the data. - onDataChange && onDataChange(event.formData); + !inputDisabled && onDataChange && onDataChange(event.formData); }; return ( @@ -66,7 +68,9 @@ export const Section: React.FC = ({ section, experiment, formData, onDat experiment, experimentConfig, // Pass the whole form data again, so custom field can access other field values. - formData + formData, + inputDisabled, + setInputDisabled } as IVortexFormContext} > {/* Children are used to render custom action buttons. We don't want any, */} diff --git a/src/shared/components/variables.scss b/src/shared/components/variables.scss index 82acabd..0ea2073 100644 --- a/src/shared/components/variables.scss +++ b/src/shared/components/variables.scss @@ -30,4 +30,11 @@ $sensorGreenLight2: #7fd384; $timeSeriesStroke: #444444; $timeSeriesFill: #99FF8A; -$timeSeriesLeaderFill: #00A80A; \ No newline at end of file +$timeSeriesLeaderFill: #00A80A; + +@mixin add-disabled-class($opacity: 1) { + &.disabled { + opacity: $opacity; + cursor: not-allowed; + } +}