Repository: vgteam/sequenceTubeMap
Pull request #285 (2023-06-02 22:07:00)

Merge TrackPicker
I decided to put the button component directly into TrackPicker, it made sense to me to skip a generic button component.

- TrackPicker calls onChange when TrackPickerDisplay does, perhaps it might be better to do this when the user exits the popup?
- Changed TrackSettings to exit upon clicking outside the popup, while the TrackPicker popup only exits upon clicking the X button
- TrackListItem now only takes up a fixed # of pixels in width, removing white space in TrackPicker


Patch:
diff --git a/src/components/TrackAddButton.js b/src/components/TrackAddButton.js
index 407f777..ac56ee3 100644
--- a/src/components/TrackAddButton.js
+++ b/src/components/TrackAddButton.js
@@ -15,7 +15,7 @@ export const TrackAddButton = ({
             aria-label="TrackAdd" 
             onClick={onChange}
             data-testid={testID}
-            style={{width : "40px", marginLeft: "15px"}}
+            style={{width : "40px", marginLeft: "25px", marginTop: "5px"}}
         >
         <FontAwesomeIcon icon={faPlus}/>
         </Button>
diff --git a/src/components/TrackList.js b/src/components/TrackList.js
index 40edb14..571e0b2 100644
--- a/src/components/TrackList.js
+++ b/src/components/TrackList.js
@@ -1,8 +1,4 @@
 import PropTypes from "prop-types";
-import {
-    Container,
-    Row
-  } from "reactstrap";
 import {TrackListItem} from './TrackListItem';
 
 
@@ -36,7 +32,6 @@ export const TrackList = ({
       Object.keys(tracks).forEach((trackID, index) => {
           const trackProps = tracks[trackID]
           trackHTML.push(          
-          <Row key={trackID}>
               <TrackListItem 
               trackProps={trackProps}
               availableTracks={availableTracks}
@@ -44,16 +39,15 @@ export const TrackList = ({
               onChange={trackItemOnChange}
               onDelete={onDelete}
               trackID={trackID}/>
-          </Row>
           );
       })
 
       return trackHTML;
   }
 
-  return(<Container>
+  return(<div>
             {renderTracks()}
-         </Container>);
+         </div>);
   
 }
 
diff --git a/src/components/TrackListItem.js b/src/components/TrackListItem.js
index f8cb968..8c2d4a8 100644
--- a/src/components/TrackListItem.js
+++ b/src/components/TrackListItem.js
@@ -69,19 +69,21 @@ export const TrackListItem = ({
       }
     }, [propChanges, onChange, trackProps, trackID]);
 
+
+
     // displayed elements uses propChanges(local state) first, then uses trackProps
     // Isn't just a || because propChanges can set the trackFile to be undefined
     const displayedFile = "trackFile" in propChanges ? propChanges["trackFile"] : trackProps["trackFile"];
     return (
-      <Container key={trackID}>
+      <Container key={trackID} style={{ width: "800px", marginLeft: 0 }}>
         <Row className="g-0">
-          <Col className="tracklist-dropdown" sm="2">
+          <Col className="tracklist-dropdown" lg="5">
             <TrackTypeDropdown value={propChanges["trackType"] || trackProps["trackType"]} 
                               onChange={trackTypeOnChange}
                               testID={"file-type-select-component".concat(trackID)}
                               />
           </Col>
-          <Col className="tracklist-dropdown" sm="3">
+          <Col className="tracklist-dropdown" lg="5">
             <TrackFilePicker tracks={availableTracks} 
                             fileType={propChanges["trackType"] || trackProps["trackType"]} 
                             value={displayedFile}
@@ -90,7 +92,7 @@ export const TrackListItem = ({
                             testID={"file-select-component".concat(trackID)}
                             />
           </Col>
-          <Col className="tracklist-button" sm="1">
+          <Col className="tracklist-button" lg="1">
             <TrackSettingsButton fileType={propChanges["trackType"] || trackProps["trackType"]}
                                 trackColorSettings={propChanges["trackColorSettings"] || trackProps["trackColorSettings"]}
                                 setTrackColorSetting={trackSettingsOnChange}
diff --git a/src/components/TrackPicker.demo.js b/src/components/TrackPicker.demo.js
new file mode 100644
index 0000000..da2a946
--- /dev/null
+++ b/src/components/TrackPicker.demo.js
@@ -0,0 +1,35 @@
+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 config from "./../config.json";
+import TrackPicker from "./TrackPicker";
+
+export default (<Demo
+  props={{
+    tracks:P.json({
+        1: config.defaultTrackProps,
+        2: config.defaultTrackProps,
+        3: config.defaultTrackProps,
+    }),
+    availableTracks: P.json([
+        {"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"}]}
+      ])
+  }}
+>
+  {
+    (props, update) => {
+      return <TrackPicker {...props} 
+        onChange={(newTracks) => {
+          update({tracks: newTracks});
+        }} 
+        />
+    }
+  }
+</Demo>)
+
+
diff --git a/src/components/TrackPicker.js b/src/components/TrackPicker.js
new file mode 100644
index 0000000..9a9e13c
--- /dev/null
+++ b/src/components/TrackPicker.js
@@ -0,0 +1,54 @@
+import React, { useState } from 'react';
+import Popup from 'reactjs-popup';
+import PropTypes from "prop-types";
+import TrackPickerDisplay from "./TrackPickerDisplay.js";
+import { Button } from 'reactstrap'
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faList, faX } from '@fortawesome/free-solid-svg-icons';
+
+import {
+  CardBody,
+  Card,
+} from 'reactstrap';
+
+export const TrackPicker = ({
+    tracks, // expects a trackList, same as trackListDisplay
+    availableTracks,
+    availableColors,
+    onChange,
+}) => {
+    // based off of https://react-popup.elazizi.com/controlled-popup/#using-open-prop
+    const [open, setOpen] = useState(false);
+    const close = () => setOpen(false);
+
+    return(
+      <div>
+        
+        <Button aria-label="TrackPicker" data-testid={"TrackPickerButton"} onClick={() => setOpen(!open)}><FontAwesomeIcon icon={faList} /></Button>
+
+        <Popup open={open} closeOnDocumentClick={false} style={{ width: "100%" }} modal>
+            <Card>
+                <CardBody>
+                    <Button className="closePopup" onClick={close}><FontAwesomeIcon icon={faX} data-testid={"TrackPickerExitButton"} /></Button>
+                    <TrackPickerDisplay
+                        tracks={tracks}
+                        availableTracks={availableTracks}
+                        availableColors={availableColors}
+                        onChange={onChange}
+                    />
+                </CardBody>
+            </Card>
+        </Popup>
+      </div>
+    )
+}
+
+TrackPicker.propTypes = {
+    tracks: PropTypes.object.isRequired,
+    availableTracks: PropTypes.array.isRequired,
+    availableColors: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired
+}
+
+
+export default TrackPicker;
diff --git a/src/components/TrackSettingsButton.js b/src/components/TrackSettingsButton.js
index 6c2b72f..fb958fd 100644
--- a/src/components/TrackSettingsButton.js
+++ b/src/components/TrackSettingsButton.js
@@ -29,9 +29,6 @@ export const TrackSettingsButton = ({
             label={label}
           />
         </PopupDialog>
-
-
-        
       </div>
     )
 }


Test patch:
diff --git a/src/components/TrackPicker.test.js b/src/components/TrackPicker.test.js
new file mode 100644
index 0000000..ceee2f5
--- /dev/null
+++ b/src/components/TrackPicker.test.js
@@ -0,0 +1,205 @@
+import React from 'react';
+import { render, fireEvent, waitFor }  from '@testing-library/react';
+import {TrackPicker} from './TrackPicker';
+import '@testing-library/jest-dom'
+import config from "./../config.json";
+
+
+describe('TrackPicker', () => {
+    const tracks = {
+        1: config.defaultTrackProps,
+        2: config.defaultTrackProps,
+        3: config.defaultTrackProps,
+    }
+    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"}]}];
+
+    it('should render without errors', async () => {
+        const fakeOnChange = jest.fn();
+        const { queryByTestId } = render(
+            <TrackPicker
+                tracks={tracks}
+                availableTracks={availableTracks}
+                availableColors={availableColors}
+                onChange={fakeOnChange}
+            />
+        );
+
+        const trackPickerButton = queryByTestId("TrackPickerButton");
+
+        expect(trackPickerButton).toBeTruthy();
+
+        fireEvent.click(trackPickerButton);
+
+        expect(queryByTestId("file-type-select-component1")).toBeTruthy();
+
+        expect(queryByTestId("file-type-select-component2")).toBeTruthy();
+
+        expect(queryByTestId("file-select-component1")).toBeTruthy();
+
+        expect(queryByTestId("settings-button-component1")).toBeTruthy();
+
+        expect(queryByTestId("delete-button-component1")).toBeTruthy();
+
+        expect(queryByTestId("track-add-button-component")).toBeTruthy();
+    });
+
+
+    it('should add track items when the add button is pressed', async () => {
+        const fakeOnChange = jest.fn();
+        const { queryByTestId } = render(
+            <TrackPicker
+                tracks={tracks}
+                availableTracks={availableTracks}
+                availableColors={availableColors}
+                onChange={fakeOnChange}
+            />
+        );
+
+        fireEvent.click(queryByTestId("TrackPickerButton"));
+
+        expect(queryByTestId('file-type-select-component4')).toBeFalsy();
+
+        const addButtonComponent = queryByTestId('track-add-button-component');
+        fireEvent.click(addButtonComponent);
+
+        expect(queryByTestId('file-type-select-component4')).toBeTruthy();
+        expect(queryByTestId('file-select-component4')).toBeTruthy();
+        expect(queryByTestId('settings-button-component4')).toBeTruthy();
+
+        fireEvent.click(addButtonComponent);
+
+        expect(queryByTestId('file-type-select-component5')).toBeTruthy();
+        expect(queryByTestId('file-select-component5')).toBeTruthy();
+        expect(queryByTestId('settings-button-component5')).toBeTruthy();
+
+
+
+    });
+
+    it('should call onChange when all files are selected', async () => {
+        const fakeOnChange = jest.fn();
+        const { queryByTestId, getByText, rerender } = render(
+            <TrackPicker
+                tracks={tracks}
+                availableTracks={availableTracks}
+                availableColors={availableColors}
+                onChange={fakeOnChange}
+            />
+        );
+
+        fireEvent.click(queryByTestId("TrackPickerButton"));
+
+        expect(fakeOnChange).toHaveBeenCalledTimes(0);
+
+        // select a file for all three track items
+
+        // first track item
+        fireEvent.keyDown(queryByTestId('file-select-component1').firstChild, {key: "ArrowDown"});
+        await waitFor(() => getByText("fileA1.vg"));
+        fireEvent.click(getByText("fileA1.vg"));
+
+        // onChange should not be called
+        expect(fakeOnChange).toHaveBeenCalledTimes(0);
+
+        // second track item
+        fireEvent.keyDown(queryByTestId("file-type-select-component2").firstChild, {key: "ArrowDown"});
+        await waitFor(() => getByText("haplotype"));
+        fireEvent.click(getByText("haplotype"));
+
+        fireEvent.keyDown(queryByTestId('file-select-component2').firstChild, {key: "ArrowDown"});
+        await waitFor(() => getByText("fileB1.gbwt"));
+        fireEvent.click(getByText("fileB1.gbwt"));
+
+        expect(fakeOnChange).toHaveBeenCalledTimes(0);
+
+        // third track item
+        fireEvent.keyDown(queryByTestId('file-select-component3').firstChild, {key: "ArrowDown"});
+        await waitFor(() => getByText("fileC1.xg"));
+        fireEvent.click(getByText("fileC1.xg"));
+
+        let newTracks = JSON.parse(JSON.stringify(tracks));
+
+        newTracks[1].trackFile = {"name": "fileA1.vg", "type": "graph"};
+        newTracks[2].trackType = "haplotype";
+        newTracks[2].trackFile = {"name": "fileB1.gbwt", "type": "haplotype"};
+        newTracks[3].trackFile = {"name": "fileC1.xg", "type": "graph"};
+
+        expect(fakeOnChange).toHaveBeenCalledTimes(1);
+        expect(fakeOnChange).toHaveBeenCalledWith(newTracks);
+
+        // add a track item and select a file
+        const addButtonComponent = queryByTestId('track-add-button-component');
+        fireEvent.click(addButtonComponent);
+
+        newTracks[4] = config.defaultTrackProps;
+        rerender(
+            <TrackPicker
+                tracks={newTracks}
+                availableTracks={availableTracks}
+                availableColors={availableColors}
+                onChange={fakeOnChange}
+            />
+        )
+
+        fireEvent.keyDown(queryByTestId("file-type-select-component4").firstChild, {key: "ArrowDown"});
+        await waitFor(() => getByText("read"));
+        fireEvent.click(getByText("read"));
+
+        fireEvent.keyDown(queryByTestId('file-select-component4').firstChild, {key: "ArrowDown"});
+        await waitFor(() => getByText("fileB2.gam"));
+        fireEvent.click(getByText("fileB2.gam"));
+
+        newTracks[4] = {
+            trackFile: {"name": "fileB2.gam", "type": "read"},
+            trackType: "read",
+            trackColorSettings: {    
+                mainPalette: "blues",
+                auxPalette: "reds",
+                colorReadsByMappingQuality: false
+            }
+        }
+
+        expect(fakeOnChange).toHaveBeenCalledTimes(2);
+        expect(fakeOnChange).toHaveBeenCalledWith(newTracks);
+
+    });    
+
+    it('should close when the exit button is pressed', async () => {
+        const fakeOnChange = jest.fn();
+        const { queryByTestId, getByText, rerender } = render(
+            <TrackPicker
+                tracks={tracks}
+                availableTracks={availableTracks}
+                availableColors={availableColors}
+                onChange={fakeOnChange}
+            />
+        );
+
+        // open popup
+        fireEvent.click(queryByTestId("TrackPickerButton"));
+
+        expect(queryByTestId("file-type-select-component1")).toBeTruthy();
+
+        expect(queryByTestId("file-select-component1")).toBeTruthy();
+
+        expect(queryByTestId("settings-button-component1")).toBeTruthy();
+
+        // close popup
+        fireEvent.click(queryByTestId("TrackPickerExitButton"));
+
+        expect(queryByTestId("file-type-select-component1")).toBeFalsy();
+
+        expect(queryByTestId("file-select-component1")).toBeFalsy();
+
+        expect(queryByTestId("settings-button-component1")).toBeFalsy();
+
+
+
+    });
+
+});