-
-
-
- {this.renderHelpContent()}
+ return (
+
+
+
- );
- }
+ {renderHelpContent()}
+
+ );
}
diff --git a/src/renderer/components/CleanComponent/EpochReviewer.tsx b/src/renderer/components/CleanComponent/EpochReviewer.tsx
index 4420919e..805be365 100644
--- a/src/renderer/components/CleanComponent/EpochReviewer.tsx
+++ b/src/renderer/components/CleanComponent/EpochReviewer.tsx
@@ -2,10 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { EpochArraysMeta } from '../../actions';
import { Button } from '../ui/button';
import { cssColorForIndex } from '../../utils/eeg/conditionPalette';
-import {
- downsampleMinMax,
- epochChannelSeries,
-} from './epochArrays';
+import { downsampleMinMax, epochChannelSeries } from './epochArrays';
// Interactive epoch reviewer: epochs run across (x), channels stacked (y).
// Click an epoch column to reject it; click a channel label to flag it bad
@@ -219,7 +216,16 @@ export default function EpochReviewer({
}
}
}
- }, [epochArrays, meta, rejected, clampedStart, perPage, badChannels, visibleCount, uniqueSortedCodes]);
+ }, [
+ epochArrays,
+ meta,
+ rejected,
+ clampedStart,
+ perPage,
+ badChannels,
+ visibleCount,
+ uniqueSortedCodes,
+ ]);
// Empty state — friendly, brand-styled, student-facing.
if (!epochArrays || !meta || meta.n_epochs === 0) {
diff --git a/src/renderer/components/CleanComponent/__tests__/CleanRejections.test.tsx b/src/renderer/components/CleanComponent/__tests__/CleanRejections.test.tsx
new file mode 100644
index 00000000..e281266e
--- /dev/null
+++ b/src/renderer/components/CleanComponent/__tests__/CleanRejections.test.tsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { EXPERIMENTS, DEVICES } from '../../../constants/constants';
+import type { SuggestedRejection } from '../../../actions';
+import Clean, { Props as CleanProps } from '../index';
+
+// We mock the children to capture the `rejected` prop sent to EpochReviewer.
+let mockRejected: Set
= new Set();
+
+vi.mock('../EpochReviewer', () => ({
+ default: (props: { rejected: Set }) => {
+ mockRejected = props.rejected;
+ return ;
+ },
+}));
+
+vi.mock('../CleanSidebar', () => ({
+ default: () => ,
+}));
+
+vi.mock('../LiveErpPane', () => ({
+ default: () => ,
+}));
+
+vi.mock('lab.js', () => ({}));
+
+vi.mock('../../../utils/filesystem/storage', () => ({
+ readWorkspaceRawEEGData: vi.fn(async () => [
+ { name: 'session_1.fif', path: '/sub-01/session_1.fif' },
+ ]),
+}));
+
+const fakeEpochArrays = {
+ buffer: new ArrayBuffer(8),
+ meta: {
+ n_epochs: 3,
+ n_channels: 2,
+ n_times: 4,
+ ch_names: ['Fp1', 'Fp2'],
+ times: [-0.1, 0, 0.1, 0.2],
+ event_codes: [1, 2, 1],
+ },
+};
+
+const baseProps: Record = {
+ type: EXPERIMENTS.N170,
+ title: 'Test_Experiment',
+ deviceType: DEVICES.MUSE,
+ epochsInfo: [{ name: 'N170', value: 100 }],
+ epochArrays: fakeEpochArrays,
+ PyodideActions: { LoadEpochs: vi.fn() },
+ ExperimentActions: { SetSubject: vi.fn() },
+ subject: '',
+ session: 0,
+ params: null,
+ suggestedRejections: [] as SuggestedRejection[],
+};
+
+describe('Clean suggestedRejections merge', () => {
+ beforeEach(() => {
+ mockRejected = new Set();
+ });
+
+ it('merges suggestedRejections indices into rejectedEpochs', async () => {
+ const { rerender } = render(
+
+
+
+ );
+
+ // The mount effect reads workspace data and sets subjects/file paths.
+ // After that, the user selects a file and loads the dataset.
+ // The file-path multi-select fires onChange via handleRecordingChange.
+ // Wait for the mount effect to populate the select options.
+
+ await waitFor(() => {
+ expect(screen.getByText('Load Dataset →')).toBeInTheDocument();
+ });
+
+ // Select the first (and only) file path.
+ const select = screen.getByRole('listbox') as HTMLSelectElement;
+ const option = screen.getByRole('option', {
+ name: 'session_1.fif',
+ }) as HTMLOptionElement;
+ option.selected = true;
+ await act(async () => {
+ fireEvent.change(select);
+ });
+
+ // Click "Load Dataset" to switch to review view.
+ await act(async () => {
+ fireEvent.click(screen.getByText('Load Dataset →'));
+ });
+
+ // Now EpochReviewer should be rendered.
+ expect(screen.getByTestId('epoch-reviewer')).toBeInTheDocument();
+
+ // Rerender with suggestedRejections.
+ const suggestions: SuggestedRejection[] = [
+ { index: 2, reason: 'High peak-to-peak' },
+ { index: 5, reason: 'Muscle artifact' },
+ ];
+ rerender(
+
+
+
+ );
+
+ // The componentDidUpdate in the class (and later the useEffect in the
+ // function component) merges the new indices into rejectedEpochs.
+ expect(mockRejected.has(2)).toBe(true);
+ expect(mockRejected.has(5)).toBe(true);
+ // The set should not contain an index that was never suggested (1).
+ expect(mockRejected.size).toBe(2);
+ });
+});
diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx
index a38c1b92..448311ac 100644
--- a/src/renderer/components/CleanComponent/index.tsx
+++ b/src/renderer/components/CleanComponent/index.tsx
@@ -1,13 +1,9 @@
-import React, { Component } from 'react';
+import React, { useEffect, useState } from 'react';
import path from 'pathe';
import { Link } from 'react-router-dom';
import { isNil, isString, memoize } from 'lodash';
import { Button } from '../ui/button';
-import {
- EXPERIMENTS,
- DEVICES,
- PTP_THRESHOLD,
-} from '../../constants/constants';
+import { EXPERIMENTS, DEVICES, PTP_THRESHOLD } from '../../constants/constants';
import { ExperimentParameters } from '../../constants/interfaces';
import { buildMarkerRegistry } from '../../utils/eeg/markerRegistry';
import { readWorkspaceRawEEGData } from '../../utils/filesystem/storage';
@@ -49,142 +45,114 @@ interface DropdownOption {
value: string;
}
-interface State {
- // Which screen is showing: dataset picker vs. the interactive editor.
- view: 'select' | 'review';
- subjects: Array;
- eegFilePaths: Array;
- selectedSubject: string;
- selectedFilePaths: Array;
- isSidebarVisible: boolean;
- // ABSOLUTE epoch indices the student has marked for rejection.
- rejectedEpochs: Set;
- // Channel names the student has flagged as bad (dropped across all epochs).
- badChannels: Set;
- // Peak-to-peak threshold (µV) used by the auto-flag request.
- autoFlagThreshold: number;
- // Whether the auto-flag threshold settings panel is open.
- showAutoFlagSettings: boolean;
-}
-
-export default class Clean extends Component {
- icons: string[];
+export default function Clean(props: Props) {
+ const [view, setView] = useState<'select' | 'review'>('select');
+ const [subjects, setSubjects] = useState>([]);
+ const [eegFilePaths, setEegFilePaths] = useState>([
+ { key: '', text: '', value: '' },
+ ]);
+ const [selectedSubject, setSelectedSubject] = useState(props.subject);
+ const [selectedFilePaths, setSelectedFilePaths] = useState>([]);
+ const [isSidebarVisible, setIsSidebarVisible] = useState(false);
+ const [rejectedEpochs, setRejectedEpochs] = useState>(new Set());
+ const [badChannels, setBadChannels] = useState>(new Set());
+ const [autoFlagThreshold, setAutoFlagThreshold] = useState(
+ PTP_THRESHOLD.default
+ );
+ const [showAutoFlagSettings, setShowAutoFlagSettings] = useState(false);
+ const [icons] = useState(() =>
+ props.type === EXPERIMENTS.N170
+ ? ['😊', '🏠', '✕', '📖']
+ : ['★', '☆', '✕', '📖']
+ );
- constructor(props: Props) {
- super(props);
- this.state = {
- view: 'select',
- subjects: [],
- eegFilePaths: [{ key: '', text: '', value: '' }],
- selectedFilePaths: [],
- selectedSubject: props.subject,
- isSidebarVisible: false,
- rejectedEpochs: new Set(),
- badChannels: new Set(),
- autoFlagThreshold: PTP_THRESHOLD.default,
- showAutoFlagSettings: false,
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ const workspaceRawData = await readWorkspaceRawEEGData(props.title);
+ if (cancelled) return;
+ setSubjects(
+ workspaceRawData
+ .map(
+ (filepath) =>
+ filepath.path.split(path.sep)[
+ filepath.path.split(path.sep).length - 3
+ ]
+ )
+ .reduce((acc, curr) => {
+ if (acc.find((subject) => subject.key === curr)) {
+ return acc;
+ }
+ return acc.concat({ key: curr, text: curr, value: curr });
+ }, [] as DropdownOption[])
+ );
+ setEegFilePaths(
+ workspaceRawData.map((filepath) => ({
+ key: filepath.name,
+ text: filepath.name,
+ value: filepath.path,
+ }))
+ );
+ })();
+ return () => {
+ cancelled = true;
};
- this.handleRecordingChange = this.handleRecordingChange.bind(this);
- this.handleLoadData = this.handleLoadData.bind(this);
- this.handleSidebarToggle = this.handleSidebarToggle.bind(this);
- this.handleSubjectChange = this.handleSubjectChange.bind(this);
- this.handleToggleEpoch = this.handleToggleEpoch.bind(this);
- this.handleToggleChannel = this.handleToggleChannel.bind(this);
- this.handleAutoFlag = this.handleAutoFlag.bind(this);
- this.handleCleanData = this.handleCleanData.bind(this);
- this.handleThresholdChange = this.handleThresholdChange.bind(this);
- this.icons =
- props.type === EXPERIMENTS.N170
- ? ['😊', '🏠', '✕', '📖']
- : ['★', '☆', '✕', '📖'];
- }
+ }, [props.title]);
- async componentDidMount() {
- const workspaceRawData = await readWorkspaceRawEEGData(this.props.title);
- this.setState({
- subjects: workspaceRawData
- .map(
- (filepath) =>
- filepath.path.split(path.sep)[
- filepath.path.split(path.sep).length - 3
- ]
- )
- .reduce((acc, curr) => {
- if (acc.find((subject) => subject.key === curr)) {
- return acc;
- }
- return acc.concat({ key: curr, text: curr, value: curr });
- }, []),
- eegFilePaths: workspaceRawData.map((filepath) => ({
- key: filepath.name,
- text: filepath.name,
- value: filepath.path,
- })),
- });
- }
+ useEffect(() => {
+ if (props.suggestedRejections.length > 0) {
+ setRejectedEpochs((prev) => {
+ const next = new Set(prev);
+ for (const s of props.suggestedRejections) next.add(s.index);
+ return next;
+ });
+ }
+ }, [props.suggestedRejections]);
- handleRecordingChange(e: React.ChangeEvent) {
+ function handleRecordingChange(e: React.ChangeEvent) {
const filePaths = Array.from(e.target.selectedOptions, (o) => o.value);
- this.setState({ selectedFilePaths: filePaths });
+ setSelectedFilePaths(filePaths);
}
- handleSubjectChange(e: React.ChangeEvent) {
+ function handleSubjectChange(e: React.ChangeEvent) {
const { value } = e.target;
if (!isNil(value) && isString(value)) {
- this.setState({ selectedSubject: value, selectedFilePaths: [] });
+ setSelectedSubject(value);
+ setSelectedFilePaths([]);
}
}
- handleLoadData() {
- this.props.ExperimentActions.SetSubject(this.state.selectedSubject);
- this.props.PyodideActions.LoadEpochs(this.state.selectedFilePaths);
- // Launch the editor; a fresh dataset invalidates any previously selected
- // epoch indices and bad-channel selections.
- this.setState({
- view: 'review',
- rejectedEpochs: new Set(),
- badChannels: new Set(),
- });
- }
-
- componentDidUpdate(prevProps: Props) {
- if (prevProps.suggestedRejections !== this.props.suggestedRejections) {
- const suggested = this.props.suggestedRejections;
- if (suggested.length > 0) {
- this.setState((prev) => {
- const next = new Set(prev.rejectedEpochs);
- for (const s of suggested) next.add(s.index);
- return { rejectedEpochs: next };
- });
- }
- }
+ function handleLoadData() {
+ props.ExperimentActions.SetSubject(selectedSubject);
+ props.PyodideActions.LoadEpochs(selectedFilePaths);
+ setView('review');
+ setRejectedEpochs(new Set());
+ setBadChannels(new Set());
}
- handleToggleEpoch(index: number) {
- this.setState((prev) => {
- const next = new Set(prev.rejectedEpochs);
+ function handleToggleEpoch(index: number) {
+ setRejectedEpochs((prev) => {
+ const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
- return { rejectedEpochs: next };
+ return next;
});
}
- handleToggleChannel(name: string) {
- const next = new Set(this.state.badChannels);
+ function handleToggleChannel(name: string) {
+ const next = new Set(badChannels);
const adding = !next.has(name);
if (adding) {
next.add(name);
} else {
next.delete(name);
}
- this.setState({ badChannels: next });
+ setBadChannels(next);
- // Dropping >1 of a 4-channel (Muse) recording loses a lot of signal —
- // informational only; they can still proceed.
- if (adding && next.size > 1 && this.props.epochArrays?.meta.n_channels === 4) {
+ if (adding && next.size > 1 && props.epochArrays?.meta.n_channels === 4) {
window.electronAPI.showMessageBox({
buttons: ['Got it'],
message:
@@ -195,17 +163,13 @@ export default class Clean extends Component {
}
}
- handleAutoFlag() {
- this.props.PyodideActions.GetSuggestedRejections(
- this.state.autoFlagThreshold
- );
+ function handleAutoFlag() {
+ props.PyodideActions.GetSuggestedRejections(autoFlagThreshold);
}
- async handleCleanData() {
- const total = this.props.epochArrays?.meta.n_epochs ?? 0;
- const nDropped = this.state.rejectedEpochs.size;
- // Rejecting every epoch produces an empty dataset that can't be analyzed
- // (and previously wrote a degenerate .fif with no error). Warn first.
+ async function handleCleanData() {
+ const total = props.epochArrays?.meta.n_epochs ?? 0;
+ const nDropped = rejectedEpochs.size;
if (total > 0 && nDropped >= total) {
const response = await window.electronAPI.showMessageBox({
buttons: ['Cancel', 'Reject all anyway'],
@@ -215,31 +179,27 @@ export default class Clean extends Component {
return;
}
}
- this.props.PyodideActions.CleanEpochs({
- dropIndices: Array.from(this.state.rejectedEpochs),
- badChannels: Array.from(this.state.badChannels),
- });
- // After Clean, raw_epochs is re-fetched with fewer epochs, so the old
- // absolute indices no longer apply.
- this.setState({
- rejectedEpochs: new Set(),
- badChannels: new Set(),
+ props.PyodideActions.CleanEpochs({
+ dropIndices: [...rejectedEpochs],
+ badChannels: [...badChannels],
});
+ setRejectedEpochs(new Set());
+ setBadChannels(new Set());
}
- handleThresholdChange(e: React.ChangeEvent) {
+ function handleThresholdChange(e: React.ChangeEvent) {
const parsed = parseFloat(e.target.value);
if (!Number.isNaN(parsed)) {
- this.setState({ autoFlagThreshold: parsed });
+ setAutoFlagThreshold(parsed);
}
}
- handleSidebarToggle() {
- this.setState({ isSidebarVisible: !this.state.isSidebarVisible });
+ function handleSidebarToggle() {
+ setIsSidebarVisible((prev) => !prev);
}
- renderStats() {
- const { epochsInfo } = this.props;
+ function renderStats() {
+ const { epochsInfo } = props;
if (isNil(epochsInfo) || epochsInfo.length === 0) {
return null;
}
@@ -247,7 +207,7 @@ export default class Clean extends Component {
{epochsInfo.map((infoObj, index) => (
- {this.icons[index]}
+ {icons[index]}
{infoObj.name}:{' '}
{infoObj.value}
@@ -256,10 +216,8 @@ export default class Clean extends Component
{
);
}
- renderAnalyzeButton() {
- const { epochsInfo } = this.props;
- // Show whenever epoch stats exist — let the user decide from the numbers,
- // instead of only surfacing the button when the data looked bad (drop >= 2).
+ function renderAnalyzeButton() {
+ const { epochsInfo } = props;
if (!isNil(epochsInfo) && epochsInfo.length > 0) {
return (
@@ -270,7 +228,7 @@ export default class Clean extends Component {
return null;
}
- renderSelect(filteredFilePaths: DropdownOption[]) {
+ function renderSelect(filteredFilePaths: DropdownOption[]) {
return (
Clean
@@ -282,10 +240,10 @@ export default class Clean extends Component
{
Select Subject
)}
@@ -414,21 +362,21 @@ export default class Clean extends Component {
)}
- {this.renderStats()}
+ {renderStats()}
{hasEpochs ? (
@@ -441,31 +389,29 @@ export default class Clean extends Component {
);
}
- render() {
- const filteredFilePaths = this.state.eegFilePaths.filter((filepath) => {
- const strVal = filepath.value;
- const subjectFromFilepath = strVal.split(path.sep)[
- strVal.split(path.sep).length - 3
- ];
- return this.state.selectedSubject === subjectFromFilepath;
- });
+ const filteredFilePaths = eegFilePaths.filter((filepath) => {
+ const strVal = filepath.value;
+ const subjectFromFilepath = strVal.split(path.sep)[
+ strVal.split(path.sep).length - 3
+ ];
+ return selectedSubject === subjectFromFilepath;
+ });
- const codeToLabel = codeToLabelFor(this.props.params?.stimuli);
- const { suggestedRejections } = this.props;
+ const codeToLabel = codeToLabelFor(props.params?.stimuli);
+ const { suggestedRejections } = props;
- return (
-
- {this.state.isSidebarVisible && (
-
-
-
- )}
-
- {this.state.view === 'select'
- ? this.renderSelect(filteredFilePaths)
- : this.renderReview(codeToLabel, suggestedRejections)}
+ return (
+
+ {isSidebarVisible && (
+
+
+ )}
+
+ {view === 'select'
+ ? renderSelect(filteredFilePaths)
+ : renderReview(codeToLabel, suggestedRejections)}
- );
- }
+
+ );
}
diff --git a/src/renderer/components/CollectComponent/ConnectModal.tsx b/src/renderer/components/CollectComponent/ConnectModal.tsx
index 1fbfca1f..df8a4b59 100644
--- a/src/renderer/components/CollectComponent/ConnectModal.tsx
+++ b/src/renderer/components/CollectComponent/ConnectModal.tsx
@@ -1,5 +1,5 @@
import { Observable } from 'rxjs';
-import React, { Component } from 'react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
import { isNil, debounce } from 'lodash';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
@@ -31,103 +31,123 @@ interface Props {
availableLSLStreams?: Array
;
}
-interface State {
- selectedDevice: Device | null;
- instructionProgress: INSTRUCTION_PROGRESS;
- // True only when native liblsl loaded in the main process. The "External LSL
- // stream" device option is hidden otherwise so the app works without liblsl.
- lslAvailable: boolean;
-}
-
enum INSTRUCTION_PROGRESS {
SEARCHING,
TURN_ON,
}
-export default class ConnectModal extends Component {
- static getDeviceName(device: Device | null) {
- if (device != null) {
- return device.name ?? device.id;
- }
- return '';
+function getDeviceName(device: Device | null) {
+ if (device != null) {
+ return device.name ?? device.id;
}
+ return '';
+}
- constructor(props: Props) {
- super(props);
- this.state = {
- selectedDevice: null,
- instructionProgress: INSTRUCTION_PROGRESS.SEARCHING,
- lslAvailable: false,
- };
- this.handleSearch = debounce(this.handleSearch.bind(this), 300, {
- leading: true,
- trailing: false,
- });
- this.handleConnect = debounce(this.handleConnect.bind(this), 1000, {
- leading: true,
- trailing: false,
- });
- this.handleinstructionProgress = this.handleinstructionProgress.bind(this);
- }
+export default function ConnectModal(props: Props) {
+ const [selectedDevice, setSelectedDevice] = useState(null);
+ const [instructionProgress, setInstructionProgress] = useState(
+ INSTRUCTION_PROGRESS.SEARCHING
+ );
+ const [lslAvailable, setLslAvailable] = useState(false);
+
+ const propsRef = useRef(props);
+ propsRef.current = props;
- componentDidMount() {
+ // Debounced handlers — recreate once, read current state via refs
+ const selectedDeviceRef = useRef(selectedDevice);
+ selectedDeviceRef.current = selectedDevice;
+
+ const handleSearch = useMemo(
+ () =>
+ debounce(
+ function handleSearch() {
+ setInstructionProgress(0);
+ propsRef.current.DeviceActions.SetDeviceAvailability(
+ DEVICE_AVAILABILITY.SEARCHING
+ );
+ },
+ 300,
+ { leading: true, trailing: false }
+ ),
+ []
+ );
+
+ const handleConnect = useMemo(
+ () =>
+ debounce(
+ function handleConnect() {
+ const device = selectedDeviceRef.current;
+ if (device) {
+ propsRef.current.DeviceActions.ConnectToDevice(device);
+ }
+ },
+ 1000,
+ { leading: true, trailing: false }
+ ),
+ []
+ );
+
+ useEffect(
+ () => () => {
+ handleSearch.cancel();
+ handleConnect.cancel();
+ },
+ [handleSearch, handleConnect]
+ );
+
+ // Mount: check LSL availability
+ useEffect(() => {
window.electronAPI
?.isLSLAvailable?.()
- .then((ok) => this.setState({ lslAvailable: ok }))
- .catch(() => this.setState({ lslAvailable: false }));
- }
+ .then((ok) => setLslAvailable(ok))
+ .catch(() => setLslAvailable(false));
+ }, []);
- UNSAFE_componentWillUpdate(nextProps: Props) {
+ // UNSAFE_componentWillUpdate → useEffect with prevAvailability ref.
+ // Class version fires BEFORE render; useEffect fires AFTER.
+ // Accept the one-frame delay — the semantics (transitioning between
+ // deviceAvailability values) is unaffected for the user.
+ const prevAvailability = useRef(props.deviceAvailability);
+ useEffect(() => {
+ const prev = prevAvailability.current;
+ prevAvailability.current = props.deviceAvailability;
if (
- nextProps.deviceAvailability === DEVICE_AVAILABILITY.NONE &&
- this.props.deviceAvailability === DEVICE_AVAILABILITY.SEARCHING
+ props.deviceAvailability === DEVICE_AVAILABILITY.NONE &&
+ prev === DEVICE_AVAILABILITY.SEARCHING
) {
- this.setState({ instructionProgress: 1 });
+ setInstructionProgress(INSTRUCTION_PROGRESS.TURN_ON);
}
if (
- nextProps.deviceAvailability === DEVICE_AVAILABILITY.AVAILABLE &&
- this.props.deviceAvailability === DEVICE_AVAILABILITY.NONE
+ props.deviceAvailability === DEVICE_AVAILABILITY.AVAILABLE &&
+ prev === DEVICE_AVAILABILITY.NONE
) {
- this.setState({ instructionProgress: 0 });
+ setInstructionProgress(INSTRUCTION_PROGRESS.SEARCHING);
}
- }
+ }, [props.deviceAvailability]);
- handleSearch() {
- this.setState({ instructionProgress: 0 });
- this.props.DeviceActions.SetDeviceAvailability(
- DEVICE_AVAILABILITY.SEARCHING
- );
+ function handleDiscoverLSLStreams() {
+ props.DeviceActions.DiscoverLSLStreams();
}
- handleConnect() {
- if (this.state.selectedDevice) {
- this.props.DeviceActions.ConnectToDevice(this.state.selectedDevice);
- }
+ function handleConnectLSLStream(stream: DiscoveredStream) {
+ props.DeviceActions.ConnectToLSLStream(stream);
}
- handleDiscoverLSLStreams = () => {
- this.props.DeviceActions.DiscoverLSLStreams();
- };
-
- handleConnectLSLStream = (stream: DiscoveredStream) => {
- this.props.DeviceActions.ConnectToLSLStream(stream);
- };
-
- handleinstructionProgress(progress: INSTRUCTION_PROGRESS) {
+ function handleinstructionProgress(progress: INSTRUCTION_PROGRESS) {
if (progress !== 0) {
- this.setState({ instructionProgress: progress });
+ setInstructionProgress(progress);
}
}
- renderLSLDiscovery() {
- const streams = this.props.availableLSLStreams ?? [];
+ function renderLSLDiscovery() {
+ const streams = props.availableLSLStreams ?? [];
const eegStreams = streams.filter((s) => s.type === 'EEG');
return (
Scan for LSL streams
@@ -146,7 +166,7 @@ export default class ConnectModal extends Component
{
this.handleConnectLSLStream(stream)}
+ onClick={() => handleConnectLSLStream(stream)}
>
Connect
@@ -158,31 +178,29 @@ export default class ConnectModal extends Component {
);
}
- renderAvailableDeviceList() {
+ function renderAvailableDeviceList() {
return (
- {this.props.availableDevices.map((device) => (
+ {props.availableDevices.map((device) => (
- this.setState({ selectedDevice: device })}
- onKeyDown={(e) =>
- e.key === 'Enter' && this.setState({ selectedDevice: device })
- }
+ onClick={() => setSelectedDevice(device)}
+ onKeyDown={(e) => e.key === 'Enter' && setSelectedDevice(device)}
>
- {this.state.selectedDevice === device ? '✓' : '○'}
- {ConnectModal.getDeviceName(device)}
+ {selectedDevice === device ? '✓' : '○'}
+ {getDeviceName(device)}
))}
);
}
- renderContent() {
- if (this.props.deviceAvailability === DEVICE_AVAILABILITY.SEARCHING) {
+ function renderContent() {
+ if (props.deviceAvailability === DEVICE_AVAILABILITY.SEARCHING) {
return (
@@ -190,18 +208,17 @@ export default class ConnectModal extends Component
{
);
}
- if (this.props.connectionStatus === CONNECTION_STATUS.CONNECTING) {
+ if (props.connectionStatus === CONNECTION_STATUS.CONNECTING) {
return (
- Connecting to{' '}
- {ConnectModal.getDeviceName(this.state.selectedDevice)}...
+ Connecting to {getDeviceName(selectedDevice)}...
);
}
- if (this.state.instructionProgress === INSTRUCTION_PROGRESS.TURN_ON) {
+ if (instructionProgress === INSTRUCTION_PROGRESS.TURN_ON) {
return (
<>
Turn your headset on
@@ -210,67 +227,61 @@ export default class ConnectModal extends Component {
Device type
- this.props.DeviceActions.SetDeviceType(
- e.target.value as DEVICES
- )
+ props.DeviceActions.SetDeviceType(e.target.value as DEVICES)
}
className="w-full rounded border px-2 py-1"
>
- {this.state.lslAvailable && (
+ {lslAvailable && (
)}
- {this.props.deviceType === DEVICES.LSL && this.renderLSLDiscovery()}
+ {props.deviceType === DEVICES.LSL && renderLSLDiscovery()}
Make sure your headset is on and fully charged.
If the headset needs charging, set the power switch to off and plug
in the headset. Do not charge the headset while wearing it
- {(this.state.instructionProgress as number) !== 0 && (
+ {(instructionProgress as number) !== 0 && (
this.handleinstructionProgress(0)}
+ onClick={() => handleinstructionProgress(0)}
>
Back
)}
-
+
Next
>
);
}
- if (this.props.deviceAvailability === DEVICE_AVAILABILITY.AVAILABLE) {
+ if (props.deviceAvailability === DEVICE_AVAILABILITY.AVAILABLE) {
return (
<>
Headset(s) found
Please select which headset you would like to connect.
- {this.renderAvailableDeviceList()}
+ {renderAvailableDeviceList()}
this.handleinstructionProgress(1)}
+ onClick={() => handleinstructionProgress(1)}
>
Back
Connect
@@ -279,7 +290,7 @@ export default class ConnectModal extends Component
{
role="link"
tabIndex={0}
className="block mt-2 text-sm cursor-pointer"
- onClick={() => this.handleinstructionProgress(1)}
+ onClick={() => handleinstructionProgress(1)}
>
Don't see your device?
@@ -289,18 +300,16 @@ export default class ConnectModal extends Component {
return null;
}
- render() {
- return (
-
- );
- }
+ return (
+
+ );
}
diff --git a/src/renderer/components/CollectComponent/HelpSidebar.tsx b/src/renderer/components/CollectComponent/HelpSidebar.tsx
index c0785594..5b1a70e5 100644
--- a/src/renderer/components/CollectComponent/HelpSidebar.tsx
+++ b/src/renderer/components/CollectComponent/HelpSidebar.tsx
@@ -1,4 +1,4 @@
-import React, { Component } from 'react';
+import React, { useState } from 'react';
import { Button } from '../ui/button';
enum HELP_STEP {
@@ -17,53 +17,34 @@ interface Props {
handleClose: () => void;
}
-interface State {
- helpStep: HELP_STEP;
-}
-
// TODO: Refactor this into a more reusable Sidebar component that can be used in Collect, Clean, and Analyze screen
-export class HelpSidebar extends Component {
- constructor(props) {
- super(props);
- this.state = {
- helpStep: HELP_STEP.MENU,
- };
- this.handleStartLearn = this.handleStartLearn.bind(this);
- this.handleStartSignal = this.handleStartSignal.bind(this);
- this.handleNext = this.handleNext.bind(this);
- this.handleBack = this.handleBack.bind(this);
- }
+export function HelpSidebar(props: Props) {
+ const [helpStep, setHelpStep] = useState(HELP_STEP.MENU);
- handleStartSignal() {
- this.setState({ helpStep: HELP_STEP.SIGNAL_EXPLANATION });
+ function handleStartSignal() {
+ setHelpStep(HELP_STEP.SIGNAL_EXPLANATION);
}
- handleStartLearn() {
- this.setState({ helpStep: HELP_STEP.LEARN_BRAIN });
+ function handleStartLearn() {
+ setHelpStep(HELP_STEP.LEARN_BRAIN);
}
- handleNext() {
+ function handleNext() {
if (
- this.state.helpStep === HELP_STEP.SIGNAL_MOVEMENT ||
- this.state.helpStep === HELP_STEP.LEARN_ALPHA
+ helpStep === HELP_STEP.SIGNAL_MOVEMENT ||
+ helpStep === HELP_STEP.LEARN_ALPHA
) {
- this.setState({ helpStep: HELP_STEP.MENU });
+ setHelpStep(HELP_STEP.MENU);
} else {
- this.setState((prevState) => ({
- ...prevState,
- helpStep: prevState.helpStep + 1,
- }));
+ setHelpStep((prev) => prev + 1);
}
}
- handleBack() {
- this.setState((prevState) => ({
- ...prevState,
- helpStep: prevState.helpStep - 1,
- }));
+ function handleBack() {
+ setHelpStep((prev) => prev - 1);
}
- renderMenu() {
+ function renderMenu() {
return (
What would you like to do?
@@ -71,8 +52,8 @@ export class HelpSidebar extends Component
{
role="button"
tabIndex={0}
className="text-lg p-1 cursor-pointer hover:bg-gray-100"
- onClick={this.handleStartSignal}
- onKeyDown={(e) => e.key === 'Enter' && this.handleStartSignal()}
+ onClick={handleStartSignal}
+ onKeyDown={(e) => e.key === 'Enter' && handleStartSignal()}
>
★ Improve the signal quality of your sensors
@@ -80,8 +61,8 @@ export class HelpSidebar extends Component {
role="button"
tabIndex={0}
className="text-lg p-1 cursor-pointer hover:bg-gray-100"
- onClick={this.handleStartLearn}
- onKeyDown={(e) => e.key === 'Enter' && this.handleStartLearn()}
+ onClick={handleStartLearn}
+ onKeyDown={(e) => e.key === 'Enter' && handleStartLearn()}
>
⚠ Learn about how the subjects movements create noise
@@ -89,7 +70,7 @@ export class HelpSidebar extends Component {
);
}
- renderHelp(header: string, content: string) {
+ function renderHelp(header: string, content: string) {
return (
<>
@@ -97,18 +78,10 @@ export class HelpSidebar extends Component
{
{content}
-
+
Back
-
+
Next
@@ -116,66 +89,64 @@ export class HelpSidebar extends Component {
);
}
- renderHelpContent() {
- switch (this.state.helpStep) {
+ function renderHelpContent() {
+ switch (helpStep) {
case HELP_STEP.SIGNAL_EXPLANATION:
- return this.renderHelp(
+ return renderHelp(
'Improve the signal quality',
'In order to collect quality data, you want to make sure that all electrodes have a strong connection'
);
case HELP_STEP.SIGNAL_SETTLING:
- return this.renderHelp(
+ return renderHelp(
'Tip #1: Good skin contact (and give it a minute)',
"The sensors read best against clean, bare skin — sweep hair out from under them and wipe away any makeup or lotion. When you first put the headset on the signal often looks red and jumpy: that's normal while the sensors settle into contact. Sit still and it should calm down and turn green within a minute."
);
case HELP_STEP.SIGNAL_CONTACT:
- return this.renderHelp(
+ return renderHelp(
'Tip #2: Ensure the sensors are making firm contact',
'Re-seat the headset to make sure that all sensors contact the head with some tension. Take extra care to make sure the reference electrodes (the ones right behind the ears) make proper contact. You may need to sweep hair out of the way to accomplish this'
);
case HELP_STEP.SIGNAL_MOVEMENT:
- return this.renderHelp(
+ return renderHelp(
'Tip #3: Stay still',
'To reduce noise during your experiment, ensure your subject is relaxed and has both feet on the floor. Sometimes, focusing on relaxing the jaw and the tongue can improve the EEG signal'
);
case HELP_STEP.LEARN_BRAIN:
- return this.renderHelp(
+ return renderHelp(
'Your brain produces electricity',
'Using the device that you are wearing, we can detect the electrical activity of your brain.'
);
case HELP_STEP.LEARN_BLINK:
- return this.renderHelp(
+ return renderHelp(
'Try blinking your eyes',
'Does the signal change? Eye movements create noise in the EEG signal'
);
case HELP_STEP.LEARN_THOUGHTS:
- return this.renderHelp(
+ return renderHelp(
'Try thinking of a cat',
"Does the signal change? Although EEG can measure overall brain activity, it's not capable of reading minds"
);
case HELP_STEP.LEARN_ALPHA:
- return this.renderHelp(
+ return renderHelp(
'Try closing your eyes for 10 seconds',
'You may notice a change in your signal due to an increase in alpha waves'
);
case HELP_STEP.MENU:
default:
- return this.renderMenu();
+ return renderMenu();
}
}
- render() {
- return (
-
-
-
- ✕
-
-
- {this.renderHelpContent()}
+ return (
+
+
+
+ ✕
+
- );
- }
+ {renderHelpContent()}
+
+ );
}
export const HelpButton: React.FC<{ onClick: () => void }> = ({ onClick }) => {
diff --git a/src/renderer/components/CollectComponent/PreTestComponent.tsx b/src/renderer/components/CollectComponent/PreTestComponent.tsx
index cb561f74..a09c5c99 100644
--- a/src/renderer/components/CollectComponent/PreTestComponent.tsx
+++ b/src/renderer/components/CollectComponent/PreTestComponent.tsx
@@ -1,4 +1,4 @@
-import React, { Component } from 'react';
+import React, { useState, useEffect } from 'react';
import { Button } from '../ui/button';
import Mousetrap from 'mousetrap';
import ViewerComponent from '../ViewerComponent';
@@ -40,68 +40,48 @@ interface Props {
openRunComponent: () => void;
}
-interface State {
- isPreviewing: boolean;
- isSidebarVisible: boolean;
-}
+export default function PreTestComponent(props: Props) {
+ const [isPreviewing, setIsPreviewing] = useState(false);
+ const [isSidebarVisible, setIsSidebarVisible] = useState(true);
-export default class PreTestComponent extends Component
{
- constructor(props: Props) {
- super(props);
- this.state = {
- isPreviewing: false,
- isSidebarVisible: true,
+ useEffect(() => {
+ Mousetrap.bind('esc', props.ExperimentActions.Stop);
+ return () => {
+ Mousetrap.unbind('esc');
};
- this.handlePreview = this.handlePreview.bind(this);
- this.handleSidebarToggle = this.handleSidebarToggle.bind(this);
- this.endPreview = this.endPreview.bind(this);
- }
-
- componentDidMount() {
- Mousetrap.bind('esc', this.props.ExperimentActions.Stop);
- }
-
- componentWillUnmount() {
- Mousetrap.unbind('esc');
- }
+ }, [props.ExperimentActions]);
- endPreview() {
- this.setState({ isPreviewing: false });
+ function endPreview() {
+ setIsPreviewing(false);
}
- handlePreview(e) {
+ function handlePreview(e) {
e.target.blur();
- this.setState((prevState) => ({
- ...prevState,
- isSidebarVisible: false,
- isPreviewing: !prevState.isPreviewing,
- }));
+ setIsSidebarVisible(false);
+ setIsPreviewing((prev) => !prev);
}
- handleSidebarToggle() {
- this.setState((prevState) => ({
- ...prevState,
- isSidebarVisible: !prevState.isSidebarVisible,
- }));
+ function handleSidebarToggle() {
+ setIsSidebarVisible((prev) => !prev);
}
- renderSignalQualityOrPreview() {
- if (this.state.isPreviewing) {
+ function renderSignalQualityOrPreview() {
+ if (isPreviewing) {
return (
);
}
return (
@@ -122,54 +102,50 @@ export default class PreTestComponent extends Component {
);
}
- renderHelpButton() {
- if (!this.state.isSidebarVisible) {
- return ;
+ function renderHelpButton() {
+ if (!isSidebarVisible) {
+ return ;
}
}
- render() {
- return (
-
- {this.state.isSidebarVisible && (
-
-
+ return (
+
+ {isSidebarVisible && (
+
+
+
+ )}
+
+
+
Collect
+
+
handlePreview(e)}
+ />
+
+ Run & Record Experiment
+
- )}
-
-
-
Collect
-
-
this.handlePreview(e)}
- />
-
- Run & Record Experiment
-
-
+
+
+
+ {renderSignalQualityOrPreview()}
-
-
- {this.renderSignalQualityOrPreview()}
-
-
-
- {this.renderHelpButton()}
-
+
+
+ {renderHelpButton()}
- );
- }
+
+ );
}
diff --git a/src/renderer/components/CollectComponent/__tests__/CollectModal.test.tsx b/src/renderer/components/CollectComponent/__tests__/CollectModal.test.tsx
new file mode 100644
index 00000000..87149197
--- /dev/null
+++ b/src/renderer/components/CollectComponent/__tests__/CollectModal.test.tsx
@@ -0,0 +1,97 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import {
+ CONNECTION_STATUS,
+ DEVICE_AVAILABILITY,
+ DEVICES,
+} from '../../../constants/constants';
+import Collect, { Props as CollectProps } from '../index';
+
+const mockSetDeviceAvailability = vi.fn();
+
+vi.mock('lab.js', () => ({}));
+
+vi.mock('../ConnectModal', () => ({
+ default: (props: { open: boolean }) =>
+ props.open ?
ConnectModal
: null,
+}));
+
+vi.mock('../PreTestComponent', () => ({
+ default: () =>
PreTest
,
+}));
+
+vi.mock('../RunComponent', () => ({
+ default: () =>
Run
,
+}));
+
+const baseProps: Record
= {
+ ExperimentActions: {
+ Stop: vi.fn(),
+ SetIsRunning: vi.fn(),
+ SetSubject: vi.fn(),
+ StartCustomExperiment: vi.fn(),
+ },
+ DeviceActions: {
+ ConnectToDevice: vi.fn(),
+ DisconnectFromDevice: vi.fn(),
+ SetDeviceAvailability: mockSetDeviceAvailability,
+ SetDeviceType: vi.fn(),
+ DiscoverLSLStreams: vi.fn(),
+ ConnectToLSLStream: vi.fn(),
+ },
+ connectedDevice: null,
+ deviceAvailability: DEVICE_AVAILABILITY.NONE,
+ connectionStatus: CONNECTION_STATUS.DISCONNECTED,
+ deviceType: DEVICES.MUSE,
+ availableDevices: [],
+ availableLSLStreams: [],
+ type: 'Faces_and_Houses' as const,
+ experimentObject: {},
+ signalQualityObservable: undefined,
+ isRunning: false,
+ params: null,
+ subject: '',
+ group: '',
+ session: 0,
+ isEEGEnabled: true,
+ title: 'Test',
+};
+
+describe('Collect modal', () => {
+ it('opens the connect modal on mount when EEG is enabled and not connected', () => {
+ render();
+
+ expect(screen.getByTestId('connect-modal')).toBeInTheDocument();
+ expect(mockSetDeviceAvailability).toHaveBeenCalledWith(
+ DEVICE_AVAILABILITY.SEARCHING
+ );
+ });
+
+ it('does not open the connect modal on mount when EEG is disabled', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId('connect-modal')).not.toBeInTheDocument();
+ });
+
+ it('closes the connect modal when connection status changes to CONNECTED', () => {
+ const { rerender } = render(
+
+ );
+ expect(screen.getByTestId('connect-modal')).toBeInTheDocument();
+
+ rerender(
+
+ );
+
+ expect(screen.queryByTestId('connect-modal')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/renderer/components/CollectComponent/index.tsx b/src/renderer/components/CollectComponent/index.tsx
index ff854b5c..8fbe3b3a 100644
--- a/src/renderer/components/CollectComponent/index.tsx
+++ b/src/renderer/components/CollectComponent/index.tsx
@@ -1,5 +1,5 @@
import { Observable } from 'rxjs';
-import React, { Component } from 'react';
+import React, { useEffect, useState } from 'react';
import {
EXPERIMENTS,
CONNECTION_STATUS,
@@ -40,99 +40,79 @@ export interface Props {
title: string;
}
-interface State {
- isConnectModalOpen: boolean;
- isRunComponentOpen: boolean;
-}
-
-export default class Collect extends Component {
- constructor(props: Props) {
- super(props);
- this.state = {
- isConnectModalOpen: false,
- isRunComponentOpen: !props.isEEGEnabled,
- };
- this.handleStartConnect = this.handleStartConnect.bind(this);
- this.handleConnectModalClose = this.handleConnectModalClose.bind(this);
- this.handleRunComponentOpen = this.handleRunComponentOpen.bind(this);
- this.handleRunComponentClose = this.handleRunComponentClose.bind(this);
- }
+export default function Collect(props: Props) {
+ const [isConnectModalOpen, setIsConnectModalOpen] = useState(false);
+ const [isRunComponentOpen, setIsRunComponentOpen] = useState(
+ !props.isEEGEnabled
+ );
- componentDidMount() {
+ useEffect(() => {
if (
- this.props.connectionStatus !== CONNECTION_STATUS.CONNECTED &&
- this.props.isEEGEnabled
+ props.connectionStatus !== CONNECTION_STATUS.CONNECTED &&
+ props.isEEGEnabled
) {
- this.handleStartConnect();
+ handleStartConnect();
}
- }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
- componentDidUpdate = (prevProps: Props, prevState: State) => {
- if (
- this.props.connectionStatus === CONNECTION_STATUS.CONNECTED &&
- prevState.isConnectModalOpen
- ) {
- this.setState({ isConnectModalOpen: false });
+ useEffect(() => {
+ if (props.connectionStatus === CONNECTION_STATUS.CONNECTED) {
+ setIsConnectModalOpen(false);
}
- };
+ }, [props.connectionStatus]);
- handleStartConnect() {
- this.setState({ isConnectModalOpen: true });
- this.props.DeviceActions.SetDeviceAvailability(
- DEVICE_AVAILABILITY.SEARCHING
- );
+ function handleStartConnect() {
+ setIsConnectModalOpen(true);
+ props.DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.SEARCHING);
}
- handleConnectModalClose() {
- this.setState({ isConnectModalOpen: false });
+ function handleConnectModalClose() {
+ setIsConnectModalOpen(false);
}
- handleRunComponentOpen() {
- this.setState({ isRunComponentOpen: true });
+ function handleRunComponentOpen() {
+ setIsRunComponentOpen(true);
}
- handleRunComponentClose() {
- this.setState({ isRunComponentOpen: false });
+ function handleRunComponentClose() {
+ setIsRunComponentOpen(false);
}
- render() {
- if (this.state.isRunComponentOpen) {
- return ;
- }
- return (
- <>
-
-
- >
- );
+ if (isRunComponentOpen) {
+ return ;
}
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/src/renderer/components/DesignComponent/CustomDesignComponent.tsx b/src/renderer/components/DesignComponent/CustomDesignComponent.tsx
index fc39e4d6..4f23ad7e 100644
--- a/src/renderer/components/DesignComponent/CustomDesignComponent.tsx
+++ b/src/renderer/components/DesignComponent/CustomDesignComponent.tsx
@@ -1,4 +1,4 @@
-import React, { Component } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import { Button } from '../ui/button';
import {
Table,
@@ -44,98 +44,85 @@ const CUSTOM_STEPS = {
PREVIEW: 'PREVIEW',
};
-const FIELDS = {
- QUESTION: 'Research Question',
- HYPOTHESIS: 'Hypothesis',
- METHODS: 'Methods',
- INTRO: 'Experiment Instructions',
- HELP: 'Instructions for the task screen',
-};
+export default function CustomDesign(props: DesignProps) {
+ const [activeStep, setActiveStep] = useState(CUSTOM_STEPS.OVERVIEW);
+ const [isPreviewing, setIsPreviewing] = useState(true);
+ const [params, setParams] = useState(() => mergeCustomParams(props.params));
+ const [saved, setSaved] = useState(false);
-interface State {
- activeStep: string;
- isPreviewing: boolean;
- params: ExperimentParameters;
- saved: boolean;
-}
+ const conditionParamsRef = useRef(mergeCustomParams(props.params));
+ const conditionRevisionRef = useRef(0);
-export default class CustomDesign extends Component {
- private conditionParams: ExperimentParameters;
- private conditionRevision = 0;
- constructor(props: DesignProps) {
- super(props);
- const customParams = mergeCustomParams(props.params);
- this.conditionParams = customParams;
- this.state = {
- activeStep: CUSTOM_STEPS.OVERVIEW,
- isPreviewing: true,
- params: customParams,
- saved: false,
+ useEffect(() => {
+ return () => {
+ props.ExperimentActions.SetParams(conditionParamsRef.current);
+ props.ExperimentActions.SaveWorkspace();
};
- this.handleStepClick = this.handleStepClick.bind(this);
- this.handleStartExperiment = this.handleStartExperiment.bind(this);
- this.handlePreview = this.handlePreview.bind(this);
- this.handleSaveParams = this.handleSaveParams.bind(this);
- this.handleProgressBar = this.handleProgressBar.bind(this);
- this.handleEEGEnabled = this.handleEEGEnabled.bind(this);
- this.endPreview = this.endPreview.bind(this);
- }
-
- componentWillUnmount() {
- this.props.ExperimentActions.SetParams(this.conditionParams);
- this.props.ExperimentActions.SaveWorkspace();
- }
+ }, [props.ExperimentActions]);
- endPreview() {
- this.setState({ isPreviewing: false });
+ function handleSaveParams(
+ newParams: ExperimentParameters = conditionParamsRef.current
+ ) {
+ conditionParamsRef.current = newParams;
+ props.ExperimentActions.SetParams(newParams);
+ props.ExperimentActions.SaveWorkspace();
+ setSaved(true);
+ setParams(newParams);
}
- handleStepClick(step: string) {
- this.handleSaveParams();
- this.setState({ activeStep: step });
+ function handleStepClick(step: string) {
+ handleSaveParams();
+ setActiveStep(step);
}
- handleProgressBar(e: React.ChangeEvent) {
+ function handleProgressBar(e: React.ChangeEvent) {
const { checked } = e.target;
- this.setState((prevState) => ({
- params: { ...prevState.params, showProgessBar: checked },
+ setParams((prev) => ({
+ ...prev,
+ showProgressBar: checked,
}));
+ conditionParamsRef.current = {
+ ...conditionParamsRef.current,
+ showProgressBar: checked,
+ };
}
- handleEEGEnabled(e: React.ChangeEvent) {
- this.props.ExperimentActions.SetEEGEnabled(e.target.checked);
+ function handleEEGEnabled(e: React.ChangeEvent) {
+ props.ExperimentActions.SetEEGEnabled(e.target.checked);
}
- handleStartExperiment() {
- this.props.navigate(SCREENS.COLLECT.route);
+ function handleStartExperiment() {
+ props.navigate(SCREENS.COLLECT.route);
}
- handlePreview(e) {
- e.target.blur();
- this.setState({ isPreviewing: !this.state.isPreviewing });
+ function handlePreview(e: React.MouseEvent) {
+ e.currentTarget.blur();
+ setIsPreviewing((prev) => !prev);
}
- handleSaveParams(params: ExperimentParameters = this.conditionParams) {
- this.conditionParams = params;
- this.props.ExperimentActions.SetParams(params);
- this.props.ExperimentActions.SaveWorkspace();
- this.setState({ saved: true, params });
+ function endPreview() {
+ setIsPreviewing(false);
}
- handleSetText(text: string, section: 'hypothesis' | 'methods' | 'question') {
- const params: ExperimentParameters = {
- ...this.conditionParams,
+ function handleSetText(
+ text: string,
+ section: 'hypothesis' | 'methods' | 'question'
+ ) {
+ const newParams: ExperimentParameters = {
+ ...conditionParamsRef.current,
description: {
...defaultCustomParams.description,
- ...this.conditionParams.description,
+ ...conditionParamsRef.current.description,
[section]: text,
},
};
- this.setState({ params, saved: false });
- this.handleSaveParams(params);
+ conditionParamsRef.current = newParams;
+ setParams(newParams);
+ setSaved(false);
+ handleSaveParams(newParams);
}
- handleConditionChange = async (
+ const handleConditionChange = async (
key: string,
data: string,
changedName: string
@@ -145,13 +132,13 @@ export default class CustomDesign extends Component {
if (!slotMeta) return;
const previousSlot =
- this.conditionParams[slotName] ??
+ conditionParamsRef.current[slotName] ??
emptyConditionSlot(slotMeta.type, '');
let nextParams: ExperimentParameters = {
- ...this.conditionParams,
+ ...conditionParamsRef.current,
[slotName]: { ...previousSlot, [key]: data },
};
- this.conditionParams = nextParams;
+ conditionParamsRef.current = nextParams;
if (key !== 'dir' && key !== 'audioDir') {
const changedSlot = nextParams[slotName]!;
@@ -165,20 +152,21 @@ export default class CustomDesign extends Component {
: stimulus
);
nextParams = { ...nextParams, stimuli };
- this.setState({ params: nextParams, saved: false });
- this.handleSaveParams(nextParams);
+ setParams(nextParams);
+ setSaved(false);
+ handleSaveParams(nextParams);
return;
}
- const revision = ++this.conditionRevision;
+ const revision = ++conditionRevisionRef.current;
const rebuiltStimuli = await rebuildStimuliFromSlots(
nextParams,
readImages,
readAudioFiles
);
- if (revision !== this.conditionRevision) return;
+ if (revision !== conditionRevisionRef.current) return;
- const latestParams = this.conditionParams;
+ const latestParams = conditionParamsRef.current;
const stimuli = rebuiltStimuli.map((stimulus) => {
const slot = CONDITION_SLOTS.find(({ type }) => type === stimulus.type);
const condition = slot ? latestParams[slot.name] : undefined;
@@ -191,93 +179,95 @@ export default class CustomDesign extends Component {
: stimulus;
});
const { nbTrials, nbPracticeTrials } = countPhases(stimuli);
- const params = { ...latestParams, stimuli, nbTrials, nbPracticeTrials };
- this.setState({ params, saved: false });
- this.handleSaveParams(params);
+ const newParams: ExperimentParameters = {
+ ...latestParams,
+ stimuli,
+ nbTrials,
+ nbPracticeTrials,
+ };
+ setParams(newParams);
+ setSaved(false);
};
- handleDeleteTrial = (deletedNum: number) => {
- const stimuli = [...(this.state.params.stimuli ?? [])];
+ const handleDeleteTrial = (deletedNum: number) => {
+ const stimuli = [...(params.stimuli ?? [])];
stimuli.splice(deletedNum, 1);
const { nbTrials, nbPracticeTrials } = countPhases(stimuli);
- const params = { ...this.state.params, stimuli, nbTrials, nbPracticeTrials };
- this.setState({ params, saved: false });
- this.handleSaveParams(params);
+ const newParams: ExperimentParameters = {
+ ...params,
+ stimuli,
+ nbTrials,
+ nbPracticeTrials,
+ };
+ setParams(newParams);
+ setSaved(false);
+ handleSaveParams(newParams);
};
- handleChangeTrial = (changedNum: number, key: string, data: string) => {
- const stimuli: Stimulus[] = [...(this.state.params.stimuli ?? [])];
+ const handleChangeTrial = (changedNum: number, key: string, data: string) => {
+ const stimuli: Stimulus[] = [...(params.stimuli ?? [])];
const current = stimuli[changedNum];
if (!current) return;
stimuli[changedNum] = { ...current, [key]: data };
const { nbTrials, nbPracticeTrials } = countPhases(stimuli);
- const params = { ...this.state.params, stimuli, nbTrials, nbPracticeTrials };
- this.setState({ params, saved: false });
- this.handleSaveParams(params);
+ const newParams: ExperimentParameters = {
+ ...params,
+ stimuli,
+ nbTrials,
+ nbPracticeTrials,
+ };
+ setParams(newParams);
+ setSaved(false);
+ handleSaveParams(newParams);
};
- renderSectionContent() {
- switch (this.state.activeStep) {
+ function renderSectionContent() {
+ switch (activeStep) {
case CUSTOM_STEPS.OVERVIEW:
default:
return (
-
-

-
-
);
@@ -289,25 +279,8 @@ export default class CustomDesign extends Component
{
Conditions
{`Select the folder with images for each condition and choose
- the correct response. Accepted image extensions: ".png",
- ".jpg", ".jpeg", ".gif", ".webp". You can also add an
- optional folder of sounds (".mp3", ".wav", ".m4a", ".ogg") —
- each trial's sound plays the moment the trial appears.`}
+ the correct response.`}
-
- Tip: make sure your images are high enough resolution before
- previewing your experiment. You can resize or compress them in
- an image editor or with{' '}
-
- imageresizer.com
-
- .
-
@@ -320,8 +293,7 @@ export default class CustomDesign extends Component {
{CONDITION_SLOTS.map(({ name, number, type }) => {
- const slot =
- this.state.params[name] ?? emptyConditionSlot(type, '');
+ const slot = params[name] ?? emptyConditionSlot(type, '');
return (
{
dir={slot.dir ?? ''}
audioDir={slot.audioDir ?? ''}
numberImages={
- this.state.params.stimuli?.filter(
- (trial) => trial.type === number
- ).length
+ params.stimuli?.filter((trial) => trial.type === number)
+ .length
}
- onChange={this.handleConditionChange}
+ onChange={handleConditionChange}
/>
);
})}
@@ -354,18 +325,22 @@ export default class CustomDesign extends Component {
-
+
{
const val = event.target.value;
if (val === 'sequential' || val === 'random') {
- this.setState({
- params: { ...this.state.params, randomize: val },
- saved: false,
- });
+ const newParams: ExperimentParameters = {
+ ...params,
+ randomize: val as ExperimentParameters['randomize'],
+ };
+ setParams(newParams);
+ setSaved(false);
}
}}
>
@@ -381,36 +356,37 @@ export default class CustomDesign extends Component {
id="nb-trials"
type="number"
className="border border-gray-300 rounded px-2 py-1"
- value={this.state.params.nbTrials}
- onChange={(event) =>
- this.setState({
- params: {
- ...this.state.params,
- nbTrials: parseInt(event.target.value, 10),
- },
- saved: false,
- })
- }
+ value={params.nbTrials}
+ onChange={(event) => {
+ const newParams: ExperimentParameters = {
+ ...params,
+ nbTrials: parseInt(event.target.value, 10),
+ };
+ setParams(newParams);
+ setSaved(false);
+ }}
/>
-
@@ -421,23 +397,25 @@ export default class CustomDesign extends Component {
Name
Sound
Condition
- Correct Key Response
- Trial Type
+ Default Key Response
+ Image File
-
- {(this.state.params.stimuli ?? []).map((trial, num) => (
+
+ {params.stimuli?.map((stimulus, i) => (
handleDeleteTrial(i)}
+ onChange={(num, key, data) =>
+ handleChangeTrial(num, key, data)
+ }
/>
))}
@@ -460,7 +438,7 @@ export default class CustomDesign extends Component {
{
8: '2',
}}
msConversion="250"
- onChange={(value) =>
- this.setState({
- params: { ...this.state.params, iti: value },
- saved: false,
- })
- }
+ onChange={(value) => {
+ const newParams: ExperimentParameters = {
+ ...params,
+ iti: value,
+ };
+ setParams(newParams);
+ setSaved(false);
+ }}
/>
@@ -493,29 +473,24 @@ export default class CustomDesign extends Component {
- this.setState({
- params: {
- ...this.state.params,
- selfPaced: !this.state.params.selfPaced,
- },
- saved: false,
- })
- }
+ defaultChecked={params.selfPaced}
+ onChange={() => {
+ const newParams: ExperimentParameters = {
+ ...params,
+ selfPaced: !params.selfPaced,
+ };
+ setParams(newParams);
+ setSaved(false);
+ }}
/>
Self-paced data collection
- {!this.state.params.selfPaced ? (
+ {!params.selfPaced ? (
{
8: '2',
}}
msConversion="250"
- onChange={(value) =>
- this.setState({
- params: {
- ...this.state.params,
- presentationTime: value,
- },
- saved: false,
- })
- }
+ onChange={(value) => {
+ const newParams: ExperimentParameters = {
+ ...params,
+ presentationTime: value,
+ };
+ setParams(newParams);
+ setSaved(false);
+ }}
/>
) : (
@@ -547,47 +521,47 @@ export default class CustomDesign extends Component {
case CUSTOM_STEPS.INSTRUCTIONS:
return (
-
-
-
Experiment Instructions
-
- Edit the instruction that will be displayed on the first screen.
-
-
-
-
Instructions for the task screen
-
- Edit the instruction that will be displayed in the footer during
- the task.
-
-