Repository: vgteam/sequenceTubeMap
Pull request #251 (2023-04-09 22:43:57)

Track list item
TrackListItem component combining:
- TrackFilePicker
- TrackTypeDropdown
- TrackDeleteButton
- TrackSettingsButton

Changing the track type clears the file field, preventing invalid track type, file type combinations.

Styling may still need a bit of work.


Patch:
diff --git a/src/App.css b/src/App.css
index ebf6697..914be87 100644
--- a/src/App.css
+++ b/src/App.css
@@ -190,3 +190,15 @@ input[type="file"] {
     opacity: 1;
   }
 }
+
+.tracklist-button {
+  display: flex;
+  align-items: left;
+  justify-content: left;
+  height: 100%;
+  margin-left: 10px;
+}
+
+.tracklist-dropdown {
+  margin-top: 4px;
+}
\ No newline at end of file
diff --git a/src/components/TrackDeleteButton.js b/src/components/TrackDeleteButton.js
index 0fc4088..3c324d9 100644
--- a/src/components/TrackDeleteButton.js
+++ b/src/components/TrackDeleteButton.js
@@ -17,7 +17,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
     // upon click, call onClick function 
     
     return (
-        <Button {...props}><FontAwesomeIcon icon={faX} /></Button>
+        <Button data-testid="delete-button" {...props}><FontAwesomeIcon icon={faX} /></Button>
     );
 }
 
diff --git a/src/components/TrackListItem.demo.js b/src/components/TrackListItem.demo.js
new file mode 100644
index 0000000..cf90743
--- /dev/null
+++ b/src/components/TrackListItem.demo.js
@@ -0,0 +1,37 @@
+import Demo, {props as P} from 'react-demo'
+// See https://github.com/rpominov/react-demo for how to make a demo
+
+import React from "react";
+
+import TrackListItem from "./TrackListItem";
+
+export default (<Demo
+  props={{
+    trackProps: P.json({
+      trackFile: undefined,
+      trackType: "graph",
+      availableTracks: [{"files": [{"name": "fileA1.vg", "type": "graph"},
+                                          {"name": "fileA2.gbwt", "type": "haplotype"}]},
+                               {"files": [{"name": "fileB1.gbwt", "type": "haplotype"},
+                                          {"name": "fileB2.gam", "type": "read"}]},
+                               {"files": [{"name": "fileC1.xg", "type": "graph"}]}],
+      trackColorSettings: {    
+        mainPallete: "blues",
+        auxPallete: "reds",
+        colorReadsByMappingQuality: false},
+    })
+  }}
+>
+  {
+    (props, update) => {
+      return <TrackListItem {...props} 
+        onChange={(newTrackProps) => {
+
+          update({trackProps: newTrackProps});
+        }} 
+        onDelete={() => {console.log("track deleted")}}/>
+    }
+  }
+</Demo>)
+
+
diff --git a/src/components/TrackListItem.js b/src/components/TrackListItem.js
new file mode 100644
index 0000000..a01cc3a
--- /dev/null
+++ b/src/components/TrackListItem.js
@@ -0,0 +1,113 @@
+import PropTypes from "prop-types";
+import {
+    Container,
+    Row,
+    Col
+  } from "reactstrap";
+import {TrackFilePicker} from './TrackFilePicker';
+import {TrackTypeDropdown} from './TrackTypeDropdown';
+import {TrackDeleteButton} from './TrackDeleteButton';
+import {TrackSettingsButton} from './TrackSettingsButton';
+import React, { useEffect, useReducer, useRef } from 'react';
+
+
+export const TrackListItem = ({
+    trackProps,
+    // trackProps expects an object with
+      // trackType: string
+      // trackFile: file object / undefined
+      // availableTracks: array of tracks(see types.ts)
+      // trackColorSettings: object(aka. colorScheme)
+      // availableColors: array of ColorPalletes
+    onChange, // expects a new trackProps object
+    onDelete,
+  }) => {
+    const [myTrackProps, dispatch] = useReducer(reducer, trackProps);
+    const _onChange = useRef(onChange);
+
+    // https://overreacted.io/a-complete-guide-to-useeffect/
+    function reducer(state, action) {
+      let newState = {...state};
+      switch (action.type){
+        case "setType":
+          newState["trackFile"] = undefined;
+          newState["trackType"] = action.payload;
+          break;
+        case "setFile":
+          newState["trackFile"] = action.payload;
+          break;
+        case "setColorSettings":
+          newState["trackColorSettings"] = action.payload;
+          break;
+        default:
+          throw new Error();
+      }
+      return newState;
+    }
+  
+    const trackTypeOnChange = async(newTrackType) => {
+      // wait until file change to call onChange
+      dispatch({type: "setType", payload: newTrackType});
+    }
+
+    const trackFileOnChange = async(newFile) => {
+      dispatch({type: "setFile", payload: newFile});
+    }
+
+    const trackSettingsOnChange = async(key, value) => {
+      let newTrackColorSettings = {...trackProps["trackColorSettings"]};
+      newTrackColorSettings[key] = value;
+      dispatch({type: "setColorSettings", payload: newTrackColorSettings});
+    }
+
+    // https://stackoverflow.com/questions/55840294/how-to-fix-missing-dependency-warning-when-using-useeffect-react-hook
+    // useEffect hook to tell react to call onchange after state changes
+    useEffect(() => {
+      if (myTrackProps.trackFile !== undefined && JSON.stringify(trackProps) !== JSON.stringify(myTrackProps)) {
+        _onChange.current(myTrackProps);
+      }
+      
+    }, [myTrackProps.trackFile, myTrackProps.trackType, myTrackProps.trackColorSettings, myTrackProps, _onChange, trackProps]);
+
+    return (
+      <Container>
+        <Row className="g-0">
+          <Col className="tracklist-dropdown" sm="2">
+            <TrackTypeDropdown value={myTrackProps["trackType"]} 
+                              onChange={trackTypeOnChange}
+                              />
+          </Col>
+          <Col className="tracklist-dropdown" sm="3">
+            <TrackFilePicker tracks={myTrackProps["availableTracks"]} 
+                            fileType={myTrackProps["trackType"]} 
+                            value={myTrackProps["trackFile"]}
+                            pickerType={"dropdown"} 
+                            handleInputChange={trackFileOnChange}
+                            />
+          </Col>
+          <Col className="tracklist-button" sm="1">
+            <TrackSettingsButton fileType={myTrackProps["trackType"]}
+                                trackColorSettings={myTrackProps["trackColorSettings"]}
+                                setTrackColorSetting={trackSettingsOnChange}
+                                availableColors={trackProps["availableColors"]}
+                                />
+            <TrackDeleteButton onClick={onDelete}
+                              />
+          </Col>
+
+        </Row>
+      </Container>
+    );
+    
+  }
+  
+  TrackListItem.propTypes = {
+    trackProps: PropTypes.object.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onDelete: PropTypes.func.isRequired,
+  }
+    
+  
+  export default TrackListItem;
+  
+  
diff --git a/src/components/TrackSettingsButton.js b/src/components/TrackSettingsButton.js
index c46281c..74e3553 100644
--- a/src/components/TrackSettingsButton.js
+++ b/src/components/TrackSettingsButton.js
@@ -29,6 +29,7 @@ export const TrackSettingsButton = ({
       
         {/* Popup has a trigger option, but passing in a button to trigger did not allow for the popup to be opened and closed
          with a Reactstrap button. We had to use onClick and onClose instead to open and close depending on the state of the popup */}
+        
         <Popup open={open} closeOnDocumentClick onClose={close} contentStyle={{width: "760px"}} modal>
           <Container>
             <Card>
diff --git a/src/components/TrackTypeDropdown.js b/src/components/TrackTypeDropdown.js
index 7f402ce..20c12b6 100644
--- a/src/components/TrackTypeDropdown.js
+++ b/src/components/TrackTypeDropdown.js
@@ -1,5 +1,7 @@
 import React from "react";
 import PropTypes from "prop-types";
+import Select from "react-select";
+
 /**
  * A track type dropdown component.
  * Created using composition of functions approach.
@@ -10,32 +12,21 @@ import PropTypes from "prop-types";
  */
 
 export function TrackTypeDropdown (props) {
-
-    // eventToString: given a change event on a dropdown with various options, return the 
-    //string value for the user's selection 
-    const eventToString = (changeEvent) => {
-      return changeEvent.target.value;
-    }
-
-    // composition function
-    const compose = (f, g) => x => f(g(x));
-
-    // input string function, and output function to take in an event 
-    // props.onChange will be the outer function accepting result of eventToString, 
-    //  to result in a function that accepts a selection event
-    const stringFnToEventFn = (StringFn) => {
-      return compose(StringFn, eventToString);
-    }
   
     
     // dropdown and selections
     // upon selection of a dropdown option, call onChange function 
     let dropdown = (
-      <select {...props} onChange={stringFnToEventFn(props.onChange)}>
-        <option value="graph">graph</option>
-        <option value="haplotype">haplotype</option>
-        <option value="read">read</option>
-      </select>
+      <div data-testid="file-type-select-component">
+        <Select {...props} onChange={o => {
+          props.onChange(o.value)}
+        }
+          options={["graph", "haplotype", "read"].map(o => ({
+            label: o, value: o
+          }))}
+          value={{label: props.value, value: props.value}}
+        />
+      </div>
     );
     
     return (


Test patch:
diff --git a/src/components/TrackFilePicker.test.js b/src/components/TrackFilePicker.test.js
index 5e80653..52b4976 100644
--- a/src/components/TrackFilePicker.test.js
+++ b/src/components/TrackFilePicker.test.js
@@ -1,5 +1,5 @@
 import React from 'react';
-import { render, fireEvent, waitFor, cleanup }  from '@testing-library/react';
+import { render, fireEvent, waitFor }  from '@testing-library/react';
 import {TrackFilePicker} from './TrackFilePicker';
 
 
diff --git a/src/components/TrackListItem.test.js b/src/components/TrackListItem.test.js
new file mode 100644
index 0000000..7680174
--- /dev/null
+++ b/src/components/TrackListItem.test.js
@@ -0,0 +1,143 @@
+import React from 'react';
+import { render, fireEvent, waitFor }  from '@testing-library/react';
+import {TrackListItem} from './TrackListItem';
+import '@testing-library/jest-dom'
+
+describe('TrackSettings', () => {
+    const trackFile = undefined;
+    const trackType = "graph";
+    const availableColors = ["greys", "ygreys", "reds", "plainColors", "lightColors"];
+    const availableTracks = [{"files": [{"name": "fileA1.vg", "type": "graph"},
+                                        {"name": "fileA2.gbwt", "type": "haplotype"}]},
+                             {"files": [{"name": "fileB1.gbwt", "type": "haplotype"},
+                                        {"name": "fileB2.gam", "type": "read"}]},
+                             {"files": [{"name": "fileC1.xg", "type": "graph"}]}];
+    const trackColorSettings = {    
+        mainPallete: "blues",
+        auxPallete: "reds",
+        colorReadsByMappingQuality: false
+    };
+
+
+    it('should render without errors', async () => {
+        const fakeOnChange = jest.fn();
+        const fakeOnDelete = jest.fn();
+        const { getByText, getByRole } = render(
+            <TrackListItem 
+              trackProps={{
+                trackFile: trackFile,
+                trackType: trackType,
+                availableColors: availableColors,
+                availableTracks: availableTracks,
+                trackColorSettings: trackColorSettings
+              }}
+              onChange = {fakeOnChange}
+              onDelete = {fakeOnDelete}
+             />
+        );
+
+        let placeholder = getByRole("button", {name: /Settings/i});
+        expect(placeholder).toBeTruthy();
+
+        placeholder = await getByText("graph");
+        expect(placeholder).toBeTruthy();
+
+        placeholder = await getByText("Select a file");
+        expect(placeholder).toBeTruthy();
+    });
+
+    it('should call onChange correctly', async () => {
+        const fakeOnChange = jest.fn();
+        const fakeOnDelete = jest.fn();
+        
+        const { getByText, queryByTestId, rerender, getByRole } = render(
+            <TrackListItem 
+            trackProps={{
+                trackFile: trackFile,
+                trackType: trackType,
+                availableColors: availableColors,
+                availableTracks: availableTracks,
+                trackColorSettings: trackColorSettings
+              }}
+              onChange = {fakeOnChange}
+              onDelete = {fakeOnDelete}
+             />
+        );
+
+        expect(fakeOnChange).toHaveBeenCalledTimes(0);
+
+        // change track type
+        const fileTypeSelectComponent = queryByTestId("file-type-select-component");
+        fireEvent.keyDown(fileTypeSelectComponent.firstChild, {key: "ArrowDown"});
+        await waitFor(() => getByText("haplotype"));
+        fireEvent.click(getByText("haplotype"));
+
+        // should wait for file select to call onChange
+        expect(fakeOnChange).toHaveBeenCalledTimes(0);
+
+        // simulate onchange rerendering component
+        rerender(
+            <TrackListItem 
+            trackProps={{
+                trackFile: trackFile,
+                trackType: "haplotype",
+                availableColors: availableColors,
+                availableTracks: availableTracks,
+                trackColorSettings: trackColorSettings
+              }}
+              onChange = {fakeOnChange}
+              onDelete = {fakeOnDelete}
+             />
+        );
+
+        // change file name
+        const fileSelectComponent = queryByTestId('file-select-component');
+        fireEvent.keyDown(fileSelectComponent.firstChild, {key: "ArrowDown"});
+        await waitFor(() => getByText("fileB1.gbwt"));
+        fireEvent.click(getByText("fileB1.gbwt"));
+
+        expect(fakeOnChange).toHaveBeenCalledTimes(1);
+        expect(fakeOnChange).toHaveBeenCalledWith({
+            trackFile: {"name": "fileB1.gbwt", "type": "haplotype"},
+            trackType: "haplotype",
+            availableColors: availableColors,
+            availableTracks: availableTracks,
+            trackColorSettings: trackColorSettings
+        });
+
+
+        // change color settings
+        fireEvent.click(getByRole("button", {name: /Settings/i}));
+        await waitFor(() => getByText("reds"));
+        fireEvent.click(getByText("reds"));
+        fireEvent.click(document);
+
+        expect(fakeOnChange).toHaveBeenCalledTimes(2); 
+
+
+    });
+    
+    it('should call onDelete correctly', async () => {
+        const fakeOnChange = jest.fn();
+        const fakeOnDelete = jest.fn();
+        const { queryByTestId } = render(
+            <TrackListItem 
+            trackProps={{
+                trackFile: trackFile,
+                trackType: trackType,
+                availableColors: availableColors,
+                availableTracks: availableTracks,
+                trackColorSettings: trackColorSettings
+              }}
+              onChange = {fakeOnChange}
+              onDelete = {fakeOnDelete}
+             />
+        );
+
+        expect(fakeOnDelete).toHaveBeenCalledTimes(0); 
+
+        fireEvent.click(queryByTestId("delete-button"));
+        expect(fakeOnDelete).toHaveBeenCalledTimes(1); 
+    });
+
+});
\ No newline at end of file
diff --git a/src/components/TrackTypeDropdown.test.js b/src/components/TrackTypeDropdown.test.js
index 9792b24..ce75c55 100644
--- a/src/components/TrackTypeDropdown.test.js
+++ b/src/components/TrackTypeDropdown.test.js
@@ -1,20 +1,20 @@
 import React from 'react';
-import {render} from '@testing-library/react';
+import { render, fireEvent, waitFor }  from '@testing-library/react';
 import {TrackTypeDropdown} from './TrackTypeDropdown';
 import {screen} from '@testing-library/react';
-import userEvent from "@testing-library/user-event";
 
 describe('TrackTypeDropdown', () => {
     it('calls the onChange callback handler', async () => {
         const onChange = jest.fn();
-        const result = render(
+        const {queryByTestId} = render(
             <TrackTypeDropdown value="haplotype" onChange={onChange}>
             </TrackTypeDropdown>
         );
-        await userEvent.selectOptions(
-            screen.getByRole('combobox'),
-            'graph'
-          );
+        const fileTypeSelectComponent = queryByTestId('file-type-select-component');
+        fireEvent.keyDown(fileTypeSelectComponent.firstChild, {key: "ArrowDown"});
+        await waitFor(() => screen.getByText("graph"));
+        fireEvent.click(screen.getByText("graph"));
+
         expect(onChange).toHaveBeenCalledTimes(1);
         expect(onChange).toHaveBeenLastCalledWith("graph");
     });