diff --git a/docs/class-to-function-plan.md b/docs/class-to-function-plan.md deleted file mode 100644 index a93e0d1e..00000000 --- a/docs/class-to-function-plan.md +++ /dev/null @@ -1,171 +0,0 @@ -# Plan: class components → function components - -## What's actually broken - -The stated pain is *"wasting too much time reading through every piece of this app."* -That is not caused by `class` — it's caused by file size and prop indirection: - -| Symptom | Cause | Fixed by | -|---|---|---| -| 644-line `CustomDesignComponent.tsx` | `renderSectionContent()` is a 400-line method | splitting render methods into sibling files | -| "where does `deviceType` come from?" | `connect()` container → 15 spread props | `useSelector` at point of use | -| "will this break?" | 20 components, ~4200 lines, ~3 renderer component tests | one characterization test per conversion | - -Class→function is the **vehicle** for those three, not the goal. Converting a class to a -function and leaving a 644-line file behind buys nothing. - -## What already exists (don't rebuild it) - -Testing "fixtures" are already installed and working — **nothing to add here**: - -- `vitest` + `jsdom` + `globals: true` (`vitest.config.ts`) -- `@testing-library/react` v16, `@testing-library/dom`, `@testing-library/jest-dom` -- `src/test-setup.ts` wired as `setupFiles` -- `eslint-plugin-react-hooks` `recommended-latest` already on (`eslint.config.mjs:47`) — - the exhaustive-deps safety net for hooks is live from day one -- Good precedent test to copy: `src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx` - -The only new test helper worth writing is **one** file (see step 0). - -## Inventory - -20 classes, 4255 lines. Grouped by difficulty: - -**Tier A — trivial (`setState` + render only, no lifecycle).** Mechanical, ~15 min each. -`PreviewButtonComponent` (24), `PreviewExperimentComponent` (49), `InputModal` (84), -`PyodidePlotWidget` (87), `SecondaryNavComponent` (116), `CollectComponent/index` (138), -`HelpSidebar` (191), `CleanSidebar` (172), `PreTestComponent` (175), `InputCollect` (155) - -**Tier B — has `componentDidMount`/`componentDidUpdate`, still simple state.** -`ConnectModal` (306), `StimuliDesignColumn` (203), `EEGExplorationComponent` (144), -`DesignComponent/index` (319) - -**Tier C — RxJS subscription lifecycle.** The only genuinely interesting ones. -`SignalQualityIndicatorComponent` (70), `ViewerComponent` (148) - -**Tier D — big, must be split, not just converted.** -`HomeComponent/index` (379), `CleanComponent/index` (471), `AnalyzeComponent` (560), -`CustomDesignComponent` (644) - -## Step 0 — the one new fixture (~30 lines, do this first) - -`src/renderer/test-utils.tsx`: - -```tsx -// renderWithStore: mounts a component against a real store built from the app's -// reducers, so tests exercise selectors/actions instead of hand-mocked props. -export function renderWithStore(ui, { preloadedState } = {}) { - const store = configureStore({ reducer: rootReducer, preloadedState }); - return { store, ...render({ui}) }; -} -``` - -Plus a `MemoryRouter` wrapper for the four components that touch `navigate`. -That's it. **Skipped: MSW, storybook, snapshot testing, a component-props factory -library.** Add MSW when there's an HTTP call to mock (there isn't — it's all IPC). - -## Step 1 — the per-component loop - -For each component, in Tier order (A → B → C → D): - -1. **Characterization test first, against the class.** 2–4 assertions: renders without - crashing given realistic props, and the one interaction that matters (click → callback - fired with expected args). Do NOT assert on markup details; assert on behavior. -2. **Convert.** `this.state.x` → `useState`; `componentDidMount` → `useEffect(…, [])`; - `componentDidUpdate(prev)` → `useEffect(…, [dep])`; handler methods → plain functions - in the body (no `useCallback` unless a profiler says so). -3. **Test stays green, unmodified.** If the test needed edits, behavior changed — that's - the whole point of writing it first. -4. `npm run typecheck && npm run lint` — `react-hooks/exhaustive-deps` catches the - classic `componentDidUpdate` → `useEffect` dependency mistakes. - -One PR per tier, not per component. Tier D gets one PR per component. - -## Step 2 — Tier C: the subscription pattern - -`SignalQualityIndicatorComponent` and `ViewerComponent` both do: subscribe in -`componentDidMount`, re-subscribe in `componentDidUpdate` when the observable prop -changes, unsubscribe in `componentWillUnmount`. That is exactly one `useEffect`: - -```tsx -useEffect(() => { - if (!observable) return; - const sub = observable.subscribe(setSignalQuality); - return () => sub.unsubscribe(); -}, [observable]); -``` - -The class version has a latent bug worth checking while you're in there: it unsubscribes -in *both* `componentWillUnmount` and the re-subscribe path with no guard against a null -observable arriving mid-flight. The effect form makes that unrepresentable. - -Extract it as `useObservable(observable)` **only after both call sites exist and are -identical** — not before. - -## Step 3 — Tier D: split, don't just convert - -The four big ones. Convert *and* split in the same PR, because converting alone leaves -the file just as unreadable: - -- `AnalyzeComponent` (560): `renderEpochLabels`, `renderHelpContent`, `renderHelp`, - `renderSectionContent` → 4 sibling components in `components/AnalyzeComponent/`. -- `CustomDesignComponent` (644): `renderSectionContent` is ~395 lines and is really - three screens (question/hypothesis/methods vs. conditions vs. preview). Split by screen. -- `CleanComponent/index` (471): `renderStats`, `renderAnalyzeButton`, `renderSelect`, - `renderReview` → siblings. `renderReview` (125 lines) probably wants its own test. -- `HomeComponent/index` (379): already has `ExperimentCard`/`OverviewComponent` siblings; - continue that pattern for the workspace list. - -Target: **no component file over ~250 lines** when done. That's the metric that actually -answers the original complaint. - -## Step 4 — delete the container layer - -`src/renderer/containers/*Container.ts` exist only to wire `connect()` + inject -`navigate`. Once the component is a function, that indirection is pure cost — it's why -reading `HomeComponent` means opening `HomeContainer` to find out what `deviceType` is. - -Per component: replace the `connect()` HOC with `useSelector`/`useDispatch` inside the -component, drop `navigate` prop for `useNavigate()`, delete the container, point the route -at the component directly. Deletes 5 files (`Home`, `Analyze`, `Clean`, `Collect`, -`ExperimentDesign`, `TopNavBar` containers) and shrinks every `Props` interface. - -Do this **after** the conversion, per component, not as a big bang — a converted component -still taking props works fine; that's the safe intermediate state. - -## Step 5 — cleanup (5 minutes, satisfying) - -`vitest.config.ts` loads `@babel/plugin-proposal-decorators` and -`@babel/plugin-proposal-class-properties`. There are **zero decorators** in the codebase -and class properties are native in every supported target. Once the classes are gone, -delete both plugins from the config and both devDependencies. Faster test startup, two -fewer deps. - -## What I'm deliberately not doing - -- **No Storybook.** It's a 4-screen Electron app whose components need IPC and a Pyodide - worker. The setup cost exceeds the value. Add it if a design system emerges. -- **No snapshot tests.** They'd all churn during the split in step 3 and teach nothing. -- **No `React.memo`/`useCallback` pass.** No measured render problem exists. Adding memo - during a refactor hides the regression it's supposed to prevent. -- **No RTL coverage target.** One behavior test per converted component; that's the - regression net, not a coverage goal. -- **Not converting `ExperimentWindow`, `RunComponent`, `EpochReviewer`, etc.** — already - function components. - -## Sequencing / effort - -| Step | Scope | Est. | -|---|---|---| -| 0 | `test-utils.tsx` | 0.5h | -| 1 | Tier A (10 components) | 1 day | -| 1 | Tier B (4 components) | 0.5 day | -| 2 | Tier C (2 components) | 0.5 day | -| 3 | Tier D (4 components, split + test) | 2–3 days | -| 4 | delete containers | 0.5 day | -| 5 | babel plugin cleanup | 0.1h | - -Tiers A–C are safe to do in any order and are individually shippable. Step 3 is where the -actual reading-time win lands — if time is short, **do step 3 first on -`CustomDesignComponent` and `AnalyzeComponent`** and leave Tier A as classes indefinitely. -A 24-line class component costs nobody anything. diff --git a/package-lock.json b/package-lock.json index 928cd5ee..11dc5604 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,8 +51,6 @@ "typesafe-actions": "^5.1.0" }, "devDependencies": { - "@babel/plugin-proposal-class-properties": "^7.10.4", - "@babel/plugin-proposal-decorators": "^7.10.5", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", @@ -219,40 +217,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.10.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-function-name": "^7.10.4", - "@babel/helper-member-expression-to-functions": "^7.10.5", - "@babel/helper-optimise-call-expression": "^7.10.4", - "@babel/helper-plugin-utils": "^7.10.4", - "@babel/helper-replace-supers": "^7.10.4", - "@babel/helper-split-export-declaration": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.10.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-get-function-arity": "^7.10.4", - "@babel/template": "^7.10.4", - "@babel/types": "^7.10.4" - } - }, - "node_modules/@babel/helper-get-function-arity": { - "version": "7.10.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.10.4" - } - }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -263,14 +227,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.11.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.11.0" - } - }, "node_modules/@babel/helper-module-imports": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", @@ -303,14 +259,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.10.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.10.4" - } - }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", "dev": true, @@ -319,25 +267,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.10.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.10.4", - "@babel/helper-optimise-call-expression": "^7.10.4", - "@babel/traverse": "^7.10.4", - "@babel/types": "^7.10.4" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.11.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.11.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -398,42 +327,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-proposal-class-properties": { - "version": "7.10.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.10.4", - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.10.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.10.5", - "@babel/helper-plugin-utils": "^7.10.4", - "@babel/plugin-syntax-decorators": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.10.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-arrow-functions": { "version": "7.27.1", "dev": true, diff --git a/package.json b/package.json index 56a79777..fa9e07be 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,9 @@ "protocols": [ { "name": "BrainWaves", - "schemes": ["brainwaves"] + "schemes": [ + "brainwaves" + ] } ], "asarUnpack": [ @@ -135,8 +137,6 @@ ], "homepage": "https://github.com/makebrainwaves/BrainWaves/", "devDependencies": { - "@babel/plugin-proposal-class-properties": "^7.10.4", - "@babel/plugin-proposal-decorators": "^7.10.5", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", diff --git a/src/main/stimulusFileAccess.ts b/src/main/stimulusFileAccess.ts index adbe8af3..f757596a 100644 --- a/src/main/stimulusFileAccess.ts +++ b/src/main/stimulusFileAccess.ts @@ -30,14 +30,21 @@ export class StimulusFileAccess { resolveUrl(requestUrl: string): string { const url = new URL(requestUrl); const requestedPath = url.searchParams.get('path'); - if (url.protocol !== 'bwfile:' || !requestedPath || !path.isAbsolute(requestedPath)) { + if ( + url.protocol !== 'bwfile:' || + !requestedPath || + !path.isAbsolute(requestedPath) + ) { throw new Error('StimulusFileAccess.resolveUrl: invalid stimulus URL'); } const canonical = fs.realpathSync(requestedPath); const authorized = [...this.directories].some((directory) => { const relative = path.relative(directory, canonical); - return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ); }); if (!authorized) { throw new Error( diff --git a/src/renderer/actions/deviceActions.ts b/src/renderer/actions/deviceActions.ts index 7c2c451a..14ea6318 100644 --- a/src/renderer/actions/deviceActions.ts +++ b/src/renderer/actions/deviceActions.ts @@ -1,6 +1,10 @@ import { createAction } from '@reduxjs/toolkit'; import { ActionType } from 'typesafe-actions'; -import { DEVICES, DEVICE_AVAILABILITY, CONNECTION_STATUS } from '../constants/constants'; +import { + DEVICES, + DEVICE_AVAILABILITY, + CONNECTION_STATUS, +} from '../constants/constants'; import { Device, DeviceInfo } from '../constants/interfaces'; import type { DiscoveredStream } from '../../shared/lslTypes'; diff --git a/src/renderer/components/AnalyzeComponent.tsx b/src/renderer/components/AnalyzeComponent.tsx index 69ea8e96..29e86c7d 100644 --- a/src/renderer/components/AnalyzeComponent.tsx +++ b/src/renderer/components/AnalyzeComponent.tsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { Button } from './ui/button'; import { isNil } from 'lodash'; @@ -53,236 +53,205 @@ interface Props { PyodideActions: typeof PyodideActions; } -interface State { - activeStep: string; - selectedChannel: string; - eegFilePaths: Array<{ - key: string; - text: string; - value: { name: string; dir: string }; - }>; - behaviorFilePaths: Array<{ key: string; text: string; value: string }>; - selectedFilePaths: Array; - selectedBehaviorFilePaths: Array; - selectedSubjects: Array; - selectedDependentVariable: string; - removeOutliers: boolean; - showDataPoints: boolean; - isSidebarVisible: boolean; - displayMode: string; - dataToPlot: PlotlyData[]; - layout: Record; - helpMode: string; - dependentVariables: Array<{ key: string; text: string; value: string }>; -} - -export default class Analyze extends Component { - constructor(props: Props) { - super(props); - this.state = { - activeStep: - this.props.isEEGEnabled === true - ? ANALYZE_STEPS.OVERVIEW - : ANALYZE_STEPS.BEHAVIOR, - eegFilePaths: [{ key: '', text: '', value: { name: '', dir: '' } }], - behaviorFilePaths: [{ key: '', text: '', value: '' }], - dependentVariables: [{ key: '', text: '', value: '' }], - dataToPlot: [] as PlotlyData[], - layout: {}, - selectedDependentVariable: '', - removeOutliers: true, - showDataPoints: false, - isSidebarVisible: false, - displayMode: 'errorbars', - helpMode: 'errorbars', - selectedFilePaths: [], - selectedBehaviorFilePaths: [], - selectedSubjects: [], - selectedChannel: - MUSE_CHANNELS[0], - }; - this.handleChannelSelect = this.handleChannelSelect.bind(this); - this.handleDatasetChange = this.handleDatasetChange.bind(this); - this.handleBehaviorDatasetChange = - this.handleBehaviorDatasetChange.bind(this); - this.handleDependentVariableChange = - this.handleDependentVariableChange.bind(this); - this.handleRemoveOutliers = this.handleRemoveOutliers.bind(this); - this.handleDisplayModeChange = this.handleDisplayModeChange.bind(this); - this.handleDataPoints = this.handleDataPoints.bind(this); - this.saveSelectedDatasets = this.saveSelectedDatasets.bind(this); - this.handleStepClick = this.handleStepClick.bind(this); - this.handleDropdownClick = this.handleDropdownClick.bind(this); - this.toggleDisplayInfoVisibility = - this.toggleDisplayInfoVisibility.bind(this); - } +export default function Analyze(props: Props) { + const [activeStep, setActiveStep] = useState( + props.isEEGEnabled === true + ? ANALYZE_STEPS.OVERVIEW + : ANALYZE_STEPS.BEHAVIOR + ); + const [eegFilePaths, setEegFilePaths] = useState< + Array<{ key: string; text: string; value: { name: string; dir: string } }> + >([{ key: '', text: '', value: { name: '', dir: '' } }]); + const [behaviorFilePaths, setBehaviorFilePaths] = useState< + Array<{ key: string; text: string; value: string }> + >([{ key: '', text: '', value: '' }]); + const [dependentVariables, setDependentVariables] = useState< + Array<{ key: string; text: string; value: string }> + >([{ key: '', text: '', value: '' }]); + const [dataToPlot, setDataToPlot] = useState([]); + const [layout, setLayout] = useState>({}); + const [selectedDependentVariable, setSelectedDependentVariable] = + useState(''); + const [removeOutliers, setRemoveOutliers] = useState(true); + const [showDataPoints, setShowDataPoints] = useState(false); + const [isSidebarVisible, setIsSidebarVisible] = useState(false); + const [displayMode, setDisplayMode] = useState('errorbars'); + const [helpMode, setHelpMode] = useState('errorbars'); + const [selectedFilePaths, setSelectedFilePaths] = useState>([]); + const [selectedBehaviorFilePaths, setSelectedBehaviorFilePaths] = useState< + Array + >([]); + const [selectedSubjects, setSelectedSubjects] = useState>([]); + const [selectedChannel, setSelectedChannel] = useState(MUSE_CHANNELS[0]); - async componentDidMount() { - const workspaceCleanData = await readWorkspaceCleanedEEGData( - this.props.title - ); - const behavioralData = await readWorkspaceBehaviorData(this.props.title); - this.setState({ - eegFilePaths: workspaceCleanData.map((filepath) => ({ - key: filepath.name, - text: filepath.name, - value: filepath.path, - })), - behaviorFilePaths: behavioralData.map((filepath) => ({ - key: filepath.name, - text: filepath.name, - value: filepath.path, - })), - dependentVariables: ['Response Time', 'Accuracy'].map((dv) => ({ + useEffect(() => { + let cancelled = false; + (async () => { + const workspaceCleanData = await readWorkspaceCleanedEEGData(props.title); + const behavioralData = await readWorkspaceBehaviorData(props.title); + if (cancelled) return; + setEegFilePaths( + workspaceCleanData.map((filepath) => ({ + key: filepath.name, + text: filepath.name, + value: filepath.path, + })) + ); + setBehaviorFilePaths( + behavioralData.map((filepath) => ({ + key: filepath.name, + text: filepath.name, + value: filepath.path, + })) + ); + const dvs = ['Response Time', 'Accuracy'].map((dv) => ({ key: dv, text: dv, value: dv, - })), - selectedDependentVariable: 'Response Time', - }); - } + })); + setDependentVariables(dvs); + setSelectedDependentVariable('Response Time'); + })(); + return () => { + cancelled = true; + }; + }, [props.title]); - concatSubjectNames = (subjects: Array) => { + function concatSubjectNames(subjects: Array) { if (subjects.length < 1) return ''; return subjects.reduce((acc, curr) => `${acc}-${curr}`); - }; + } - handleDatasetChange(e: React.ChangeEvent) { + function handleDatasetChange(e: React.ChangeEvent) { const values = Array.from(e.target.selectedOptions, (o) => o.value); - this.setState({ - selectedFilePaths: values, - selectedSubjects: getSubjectNamesFromFiles(values), - }); - this.props.PyodideActions.LoadCleanedEpochs(values); + setSelectedFilePaths(values); + setSelectedSubjects(getSubjectNamesFromFiles(values)); + props.PyodideActions.LoadCleanedEpochs(values); } - handleBehaviorDatasetChange(e: React.ChangeEvent) { + function handleBehaviorDatasetChange( + e: React.ChangeEvent + ) { const values = Array.from(e.target.selectedOptions, (o) => o.value); const aggregatedData = aggregateDataForPlot( readBehaviorData(values), - this.state.selectedDependentVariable, - this.state.removeOutliers, - this.state.showDataPoints, - this.state.displayMode + selectedDependentVariable, + removeOutliers, + showDataPoints, + displayMode ); if (!aggregatedData) return; - const { dataToPlot, layout } = aggregatedData; - this.setState({ - selectedBehaviorFilePaths: values, - selectedSubjects: getSubjectNamesFromFiles(values), - dataToPlot, - layout, - }); + const { dataToPlot: data, layout: lay } = aggregatedData; + setSelectedBehaviorFilePaths(values); + setSelectedSubjects(getSubjectNamesFromFiles(values)); + setDataToPlot(data); + setLayout(lay); } - async handleDropdownClick() { - const behavioralData = await readWorkspaceBehaviorData(this.props.title); - if (behavioralData.length !== this.state.behaviorFilePaths.length) { - this.setState({ - behaviorFilePaths: behavioralData.map((filepath) => ({ + async function handleDropdownClick() { + const behavioralData = await readWorkspaceBehaviorData(props.title); + if (behavioralData.length !== behaviorFilePaths.length) { + setBehaviorFilePaths( + behavioralData.map((filepath) => ({ key: filepath.name, text: filepath.name, value: filepath.path, - })), - }); + })) + ); } } - handleDependentVariableChange(e: React.ChangeEvent) { + function handleDependentVariableChange( + e: React.ChangeEvent + ) { const { value } = e.target; const aggregatedData = aggregateDataForPlot( - readBehaviorData(this.state.selectedBehaviorFilePaths), + readBehaviorData(selectedBehaviorFilePaths), value, - this.state.removeOutliers, - this.state.showDataPoints, - this.state.displayMode + removeOutliers, + showDataPoints, + displayMode ); if (!aggregatedData) return; - const { dataToPlot, layout } = aggregatedData; - this.setState({ selectedDependentVariable: value, dataToPlot, layout }); + const { dataToPlot: data, layout: lay } = aggregatedData; + setSelectedDependentVariable(value); + setDataToPlot(data); + setLayout(lay); } - handleRemoveOutliers() { + function handleRemoveOutliers() { const aggregatedData = aggregateDataForPlot( - readBehaviorData(this.state.selectedBehaviorFilePaths), - this.state.selectedDependentVariable, - !this.state.removeOutliers, - this.state.showDataPoints, - this.state.displayMode + readBehaviorData(selectedBehaviorFilePaths), + selectedDependentVariable, + !removeOutliers, + showDataPoints, + displayMode ); if (!aggregatedData) return; - const { dataToPlot, layout } = aggregatedData; - this.setState({ - removeOutliers: !this.state.removeOutliers, - dataToPlot, - layout, - helpMode: 'outliers', - }); + const { dataToPlot: data, layout: lay } = aggregatedData; + setRemoveOutliers(!removeOutliers); + setDataToPlot(data); + setLayout(lay); + setHelpMode('outliers'); } - handleDataPoints() { + function handleDisplayModeChange(value: string) { const aggregatedData = aggregateDataForPlot( - readBehaviorData(this.state.selectedBehaviorFilePaths), - this.state.selectedDependentVariable, - this.state.removeOutliers, - !this.state.showDataPoints, - this.state.displayMode + readBehaviorData(selectedBehaviorFilePaths), + selectedDependentVariable, + removeOutliers, + showDataPoints, + value ); if (!aggregatedData) return; - const { dataToPlot, layout } = aggregatedData; - this.setState({ - showDataPoints: !this.state.showDataPoints, - dataToPlot, - layout, - }); + const { dataToPlot: data, layout: lay } = aggregatedData; + setDisplayMode(value); + setDataToPlot(data); + setLayout(lay); + setHelpMode(value); } - handleDisplayModeChange(displayMode) { - if ( - this.state.selectedBehaviorFilePaths && - this.state.selectedBehaviorFilePaths.length > 0 - ) { - const aggregatedData = aggregateDataForPlot( - readBehaviorData(this.state.selectedBehaviorFilePaths), - this.state.selectedDependentVariable, - this.state.removeOutliers, - this.state.showDataPoints, - displayMode - ); - if (!aggregatedData) return; - const { dataToPlot, layout } = aggregatedData; - this.setState({ dataToPlot, layout, displayMode, helpMode: displayMode }); - } + function handleDataPoints() { + const aggregatedData = aggregateDataForPlot( + readBehaviorData(selectedBehaviorFilePaths), + selectedDependentVariable, + removeOutliers, + !showDataPoints, + displayMode + ); + if (!aggregatedData) return; + const { dataToPlot: data, layout: lay } = aggregatedData; + setShowDataPoints(!showDataPoints); + setDataToPlot(data); + setLayout(lay); } - toggleDisplayInfoVisibility() { - this.setState({ isSidebarVisible: !this.state.isSidebarVisible }); + function toggleDisplayInfoVisibility() { + setIsSidebarVisible((prev) => !prev); } - saveSelectedDatasets() { - const data = readBehaviorData(this.state.selectedBehaviorFilePaths); - const aggregatedData = aggregateBehaviorDataToSave( - data, - this.state.removeOutliers + function saveSelectedDatasets() { + const data = readBehaviorData(selectedBehaviorFilePaths); + const aggregatedData = aggregateBehaviorDataToSave(data, removeOutliers); + storeAggregatedBehaviorData( + aggregatedData as Parameters[0], + props.title ); - storeAggregatedBehaviorData(aggregatedData, this.props.title); } - handleChannelSelect(channelName: string) { - this.setState({ selectedChannel: channelName }); - this.props.PyodideActions.LoadERP(channelName); + function handleChannelSelect(channelName: string) { + setSelectedChannel(channelName); + props.PyodideActions.LoadERP(channelName); } - handleStepClick(step: string) { - this.setState({ activeStep: step }); + function handleStepClick(step: string) { + setActiveStep(step); } - renderEpochLabels() { - if ( - !isNil(this.props.epochsInfo) && - this.state.selectedFilePaths.length >= 1 - ) { - const numberConditions = this.props.epochsInfo.filter( + function renderEpochLabels() { + const { epochsInfo } = props; + if (!isNil(epochsInfo) && selectedFilePaths.length >= 1) { + const numberConditions = epochsInfo.filter( (infoObj) => infoObj.name !== 'Drop Percentage' && infoObj.name !== 'Total Epochs' ).length; @@ -292,7 +261,7 @@ export default class Analyze extends Component { : ['red', 'green', 'teal', 'orange']; return (
- {this.props.epochsInfo + {epochsInfo .filter( (infoObj) => infoObj.name !== 'Drop Percentage' && @@ -310,51 +279,38 @@ export default class Analyze extends Component { return
; } - renderHelpContent() { - switch (this.state.helpMode) { + function renderHelpContent() { + switch (helpMode) { case 'datapoints': - return this.renderHelp( + return renderHelp( 'Data Points', - `In this graph, each dot refers to one data point, clustered by group (e.g., conditions). - It's the most "neutral" way of presenting the data, of course, but it may be difficult to see any patterns. - Why is it always a good idea to look at all your datapoints before interpreting any trends in the data?` + 'In this graph, each dot refers to one data point, clustered by group (e.g., conditions).' ); case 'errorbars': - return this.renderHelp( + return renderHelp( 'Bar Graph', - `Bar graphs are the most common way to summarize data. - It allows you to compare mean values between groups of datapoints (e.g., conditions), - and the error bars give some indication of the variance (here: the standard error of the mean). - Importantly, this way of summarizing data assumes that the mean is in fact representative of the data. - Many researchers have veered away from bar graphs because they can be deceptive, especially without error bars. - Can you think of any such cases?` + 'Bar graphs are the most common way to summarize data.' ); case 'whiskers': - return this.renderHelp( + return renderHelp( 'Box Plot', - `Box plots summarize the data in a more informative way: - they actually tell you something about the distribution of datapoints within a group, - by taking the median as its reference point instead of the mean. - The boxes represent so-called "quartiles". - The lines ("whiskers") show how much variability there is in the data outside of those quartiles; - any outliers are shown as individual points.` + 'Box plots summarize the data in a more informative way.' ); case 'outliers': default: - return this.renderHelp( + return renderHelp( 'Outliers', - `A datapoint is tagged as an "outlier" if its value exceeds 2 standard deviations below or above the mean of all data in the group. - Removing such outliers can help unskew the data.` + 'A datapoint is tagged as an "outlier" if its value exceeds 2 standard deviations.' ); } } - renderHelp(header: string, content: string) { + function renderHelp(header: string, content: string) { return (
-
- )} -
-
+ function renderOverview() { + const { child: psdChild } = props.psdPlot; + const { child: topoChild } = props.topoPlot; + return ( +
+

Overview

+ {renderEpochLabels()} +
+
+ + +
+
+

PSD Plot

+ {psdChild ? ( -
- - ); - case ANALYZE_STEPS.ERP: - return ( - <> -
-

ERP

-

- The event-related potential represents EEG activity elicited by - a particular sensory event -

- -
- {this.renderEpochLabels()} -
-
+ ) : ( +

No PSD data available. Clean some data first.

+ )} +
+
+

Topography

+ {topoChild ? ( -
- - ); - case ANALYZE_STEPS.BEHAVIOR: - return ( - <> -
-

Overview

-

- Load datasets from different subjects and view behavioral - results -

-
- Datasets - -
- -
-

Dependent Variable

- -
-
-
- -
- -
-
- {(['datapoints', 'errorbars', 'whiskers'] as const).map( - (mode) => ( - - ) - )} -
- - {this.state.isSidebarVisible && ( -
{this.renderHelpContent()}
- )} + {channel}
-
- - ); - } + ))} +
+
+
+ ); } - render() { + function renderBehavior() { return ( -
- -
- {this.renderSectionContent()} +
+

Behavioral Data

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+ + + +
+
+ {dataToPlot.length > 0 ? ( + + ) : ( +

Select datasets to see plots.

+ )} +
); } + + const steps = props.isEEGEnabled ? ANALYZE_STEPS : ANALYZE_STEPS_BEHAVIOR; + + return ( +
+ + {isSidebarVisible ? 'Hide' : 'Show'} help + + } + /> + {isSidebarVisible && renderHelpContent()} + {activeStep === ANALYZE_STEPS.OVERVIEW && renderOverview()} + {activeStep === ANALYZE_STEPS.ERP && renderERP()} + {activeStep === ANALYZE_STEPS.BEHAVIOR && renderBehavior()} +
+ ); } diff --git a/src/renderer/components/CleanComponent/CleanSidebar.tsx b/src/renderer/components/CleanComponent/CleanSidebar.tsx index cf8b14b3..527fddbd 100644 --- a/src/renderer/components/CleanComponent/CleanSidebar.tsx +++ b/src/renderer/components/CleanComponent/CleanSidebar.tsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { useState } from 'react'; import { Button } from '../ui/button'; enum HELP_STEP { @@ -17,46 +17,33 @@ interface Props { handleClose: () => void; } -interface State { - helpStep: HELP_STEP; -} - -export default class CleanSidebar 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 default function CleanSidebar(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({ helpStep: this.state.helpStep + 1 }); + setHelpStep((prev) => prev + 1); } } - handleBack() { - this.setState({ helpStep: this.state.helpStep - 1 }); + function handleBack() { + setHelpStep((prev) => prev - 1); } - renderMenu() { + function renderMenu() { return (

What would you like to do?

@@ -64,8 +51,8 @@ export default class CleanSidebar 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
@@ -73,8 +60,8 @@ export default class CleanSidebar 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
@@ -82,7 +69,7 @@ export default class CleanSidebar extends Component { ); } - renderHelp(header: string, content: string) { + function renderHelp(header: string, content: string) { return ( <>
@@ -90,18 +77,10 @@ export default class CleanSidebar extends Component { {content}
- -
@@ -109,64 +88,62 @@ export default class CleanSidebar 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_SALINE: - return this.renderHelp( + return renderHelp( 'Tip #1: Saturate the sensors in saline', 'Make sure the sensors are thoroughly soaked with saline solution. They should be wet to the touch' ); 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. 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_THOUGHT: - 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()} +
+ ); } 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

{filteredFilePaths.map((fp) => (
)} @@ -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 (
@@ -146,7 +166,7 @@ export default class ConnectModal extends Component { @@ -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.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 && ( )} -
); } - 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()}
@@ -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 ( - { - if (!open) this.props.onClose(); - }} - > - - {this.renderContent()} - - - ); - } + return ( + { + if (!open) props.onClose(); + }} + > + + {renderContent()} + + + ); } 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}
- -
@@ -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)} + /> +
    - )} -
    -
    -

    Collect

    -
    - this.handlePreview(e)} - /> - -
    +
    +
    +
    + {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 (
    -
    - Research Question - -