Repository: vgteam/sequenceTubeMap
Pull request #279 (2023-05-21 22:15:37)

Implement TrackListDisplay
Issues #227 and #228

Implement TrackListDisplay, with functionality for add and delete buttons. 

- Local state of Track Items is kept
       - Changes are pushed when all files have been selected
- Add and delete buttons modify the track list respectively


Patch:
diff --git a/src/components/ColorPicker.js b/src/components/ColorPicker.js
index 292bd89..774d9d1 100644
--- a/src/components/ColorPicker.js
+++ b/src/components/ColorPicker.js
@@ -59,4 +59,8 @@ ColorPicker.propTypes = {
     testID: PropTypes.string
 }
 
+ColorPicker.defaultProps = {
+    testID: "color-picker-component"
+}
+
 export default ColorPicker;
\ No newline at end of file
diff --git a/src/components/TrackAddButton.js b/src/components/TrackAddButton.js
new file mode 100644
index 0000000..407f777
--- /dev/null
+++ b/src/components/TrackAddButton.js
@@ -0,0 +1,34 @@
+import React from "react";
+import { Button } from "reactstrap";
+import { faPlus } from '@fortawesome/free-solid-svg-icons';
+import PropTypes from "prop-types";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+// Component in TrackListDisplay, adds new track item into TrackList when pressed
+export const TrackAddButton = ({
+    onChange,
+    testID,
+}) => {
+
+    return(
+        <Button 
+            aria-label="TrackAdd" 
+            onClick={onChange}
+            data-testid={testID}
+            style={{width : "40px", marginLeft: "15px"}}
+        >
+        <FontAwesomeIcon icon={faPlus}/>
+        </Button>
+    );
+}
+
+TrackAddButton.propTypes = {
+    onChange: PropTypes.func.isRequired,
+    testID: PropTypes.string
+}
+
+TrackAddButton.defaultProps = {
+    testID: "track-add-button-component"
+}
+
+export default TrackAddButton;
\ No newline at end of file
diff --git a/src/components/TrackFilePicker.js b/src/components/TrackFilePicker.js
index 9526d8f..396a588 100644
--- a/src/components/TrackFilePicker.js
+++ b/src/components/TrackFilePicker.js
@@ -82,7 +82,7 @@ export const TrackFilePicker = ({
 
 TrackFilePicker.propTypes = {
     tracks: PropTypes.array.isRequired,
-    fileType: PropTypes.string.isRequired,
+    fileType: PropTypes.string,
     value: PropTypes.object,
     handleInputChange: PropTypes.func.isRequired,
     pickerType: PropTypes.string,
@@ -92,6 +92,7 @@ TrackFilePicker.propTypes = {
   
 TrackFilePicker.defaultProps = {
   value: {"name": "Select a file", "type": undefined},
+  fileType: "graph",
   pickerType: "dropdown",
   className: undefined,
   testID: "file-select-component"
diff --git a/src/components/TrackList.js b/src/components/TrackList.js
index 141d14f..40edb14 100644
--- a/src/components/TrackList.js
+++ b/src/components/TrackList.js
@@ -21,7 +21,6 @@ export const TrackList = ({
 }) => {
 
   function trackItemOnChange(trackID, trackProps) {
-    (function(callback){ console.log(callback.toString()); })(onChange);
       let newTracks = {...tracks};
       newTracks[trackID] = trackProps;
       if (JSON.stringify(newTracks) !== JSON.stringify(tracks)) {
diff --git a/src/components/TrackListItem.js b/src/components/TrackListItem.js
index 74911af..f8cb968 100644
--- a/src/components/TrackListItem.js
+++ b/src/components/TrackListItem.js
@@ -62,8 +62,8 @@ export const TrackListItem = ({
         newTrackProps[key] = propChanges[key];
       }
       
-      // push it if trackFile has been selected and changes are made
-      if (newTrackProps.trackFile !== undefined && JSON.stringify(trackProps) !== JSON.stringify(newTrackProps)) {
+      // push it if changes are made
+      if (JSON.stringify(trackProps) !== JSON.stringify(newTrackProps)) {
         onChange(trackID, newTrackProps);
         setPropChanges({});
       }
@@ -97,7 +97,7 @@ export const TrackListItem = ({
                                 availableColors={availableColors}
                                 testID={"settings-button-component".concat(trackID)}
                                 />
-            <TrackDeleteButton onClick={onDelete}
+            <TrackDeleteButton onClick={() => {onDelete(trackID)}}
                                testID={"delete-button-component".concat(trackID)}
                               />
           </Col>
diff --git a/src/components/TrackPickerDisplay.demo.js b/src/components/TrackPickerDisplay.demo.js
new file mode 100644
index 0000000..c23c538
--- /dev/null
+++ b/src/components/TrackPickerDisplay.demo.js
@@ -0,0 +1,59 @@
+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 TrackPickerDisplay from "./TrackPickerDisplay";
+
+export default (<Demo
+  props={{
+    tracks:P.json({
+        1: {
+            trackFile: undefined,
+            trackType: "graph",
+            trackColorSettings: {    
+                mainPalette: "blues",
+                auxPalette: "reds",
+                colorReadsByMappingQuality: false
+            }
+        },
+        2: {
+            trackFile: undefined,
+            trackType: "graph",
+            trackColorSettings: {    
+                mainPalette: "blues",
+                auxPalette: "reds",
+                colorReadsByMappingQuality: false
+            }
+        },
+        3: {
+            trackFile: undefined,
+            trackType: "graph",
+            trackColorSettings: {    
+                mainPalette: "blues",
+                auxPalette: "reds",
+                colorReadsByMappingQuality: false
+            }
+        },
+    }),
+    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 <TrackPickerDisplay {...props} 
+        onChange={(newTracks) => {
+          update({tracks: newTracks});
+        }} 
+        />
+    }
+  }
+</Demo>)
+
+
diff --git a/src/components/TrackPickerDisplay.js b/src/components/TrackPickerDisplay.js
new file mode 100644
index 0000000..3c79108
--- /dev/null
+++ b/src/components/TrackPickerDisplay.js
@@ -0,0 +1,120 @@
+import PropTypes from "prop-types";
+import React, { useState, useEffect } from "react";
+import {
+    Row,
+    Col
+  } from "reactstrap";
+import {TrackList} from "./TrackList";
+import {TrackAddButton} from "./TrackAddButton";
+import config from "./../config.json";
+
+export const TrackPickerDisplay = ({
+    // tracks expects an object mapping a trackID to trackProps, which includes
+      // * trackType: string
+      // * trackFile: file object / undefined
+      // * trackColorSettings: object(aka. colorScheme)
+    tracks, 
+    availableTracks, // array of tracks(see types.ts)
+    availableColors, // array of ColorPalettes
+    onChange, // expects a new tracks object
+}) => {
+
+    const [trackListChanges, setTrackListChanges] = useState({});
+
+    // gets the highest trackID bewteen pending changes and tracks + 1
+    let nextTrackID = parseInt(Object.keys({...tracks, ...trackListChanges}).reduce((t1, t2) => {
+        if (t1 > t2) {
+            return t1;
+        }
+        return t2;
+    })) + 1;
+
+    // returns an updated track list combining the 2 inputs, with trackChanges taking priority
+    const applyTrackListChanges = (tracks, trackChanges) => {
+        let newTrackList = {...tracks};
+        Object.keys(trackChanges).forEach((trackID, index) => {
+            // if the trackprops are marked as -1, they are deleted
+            if (trackChanges[trackID] === -1) {
+                delete newTrackList[trackID];
+            } else {
+                newTrackList[trackID] = trackChanges[trackID];
+            }
+        });
+        return newTrackList;
+    }
+
+    const addTrackItem = () => {
+        let newTrackList = {...trackListChanges};
+
+        // create a track item with default trackProps
+        newTrackList[nextTrackID.toString()] = {...config.defaultTrackProps};
+        setTrackListChanges(newTrackList);
+    }
+
+    const trackListOnChange = (newTracks) => {
+        setTrackListChanges(applyTrackListChanges(trackListChanges, newTracks));
+    }
+
+    const onDelete = (trackID) => {
+        let newTrackList = {...trackListChanges};
+        // store to be deleted tracks as -1
+        newTrackList[trackID] = -1;
+        setTrackListChanges(newTrackList);
+    }
+
+
+    useEffect(() => {
+        // construct the new track list
+        let newTrackList = applyTrackListChanges(tracks, trackListChanges)
+
+        let validTrackList = true;
+        // track list is valid to commit if all fileNames have been selected
+        Object.keys(newTrackList).forEach((trackID, index) => {
+            const trackProps = newTrackList[trackID];
+            if (trackProps.trackFile === undefined) {
+                validTrackList = false;
+            }
+        });
+
+        // call onChange if the track list is valid and changes have been made        
+        if (validTrackList && JSON.stringify(newTrackList) !== JSON.stringify(tracks)) {
+            console.log("tracks", tracks);
+            console.log("trackChanges", trackListChanges);
+            console.log("calling Track Picker Display onChange with ", newTrackList);
+            onChange(newTrackList);
+            // clear out pending changes
+            setTrackListChanges({});
+        }
+
+
+    }, [trackListChanges, onChange, tracks]);
+
+    return(
+        <Col>
+            <Row>
+                <TrackList
+                    tracks={applyTrackListChanges(tracks, trackListChanges)}
+                    availableTracks={availableTracks}
+                    availableColors={availableColors}
+                    onChange={trackListOnChange}
+                    onDelete={onDelete}
+                />
+            </Row>
+            <Row>
+                <TrackAddButton
+                    onChange={addTrackItem}
+                />
+            </Row>
+        </Col>
+
+    );
+};
+
+TrackPickerDisplay.propTypes = {
+    tracks: PropTypes.object.isRequired,
+    availableTracks: PropTypes.array.isRequired,
+    availableColors: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired
+}
+
+export default TrackPickerDisplay;
\ No newline at end of file
diff --git a/src/components/TrackSettings.js b/src/components/TrackSettings.js
index def5f3b..e0daf8c 100644
--- a/src/components/TrackSettings.js
+++ b/src/components/TrackSettings.js
@@ -56,7 +56,6 @@ export const TrackSettings = ({
                                 <ColorPicker
                                     presetColors={presetColors}
                                     onChange={(color) => {
-                                        console.log("calling onchange with ", color);
                                         setTrackColorSetting("mainPalette", color);
                                     }}
                                 />
@@ -81,7 +80,6 @@ export const TrackSettings = ({
                                 <ColorPicker
                                     presetColors={presetColors}
                                     onChange={(color) => {
-                                        console.log("calling onchange with ", color);
                                         setTrackColorSetting("mainPalette", color);
                                     }}
                                 />
@@ -101,7 +99,6 @@ export const TrackSettings = ({
                                 <ColorPicker
                                     presetColors={presetColors}
                                     onChange={(color) => {
-                                        console.log("calling onchange with ", color);
                                         setTrackColorSetting("auxPalette", color);
                                     }}
                                 />
@@ -124,7 +121,7 @@ export const TrackSettings = ({
 
 TrackSettings.propTypes = {
     fileType: PropTypes.string.isRequired,
-    trackColorSettings: PropTypes.object.isRequired,
+    trackColorSettings: PropTypes.object,
     setTrackColorSetting: PropTypes.func.isRequired,
     label: PropTypes.string,
     availableColors: PropTypes.array,
@@ -134,7 +131,12 @@ TrackSettings.propTypes = {
 TrackSettings.defaultProps = {
     fileType: "haplotype",
     availableColors: ["greys", "ygreys", "blues", "reds", "plainColors", "lightColors"],
-    presetColors: ['#FF6900', '#FCB900', '#7BDCB5', '#00D084', '#8ED1FC', '#0693E3', '#ABB8C3', '#EB144C', '#F78DA7', '#9900EF']
+    presetColors: ['#FF6900', '#FCB900', '#7BDCB5', '#00D084', '#8ED1FC', '#0693E3', '#ABB8C3', '#EB144C', '#F78DA7', '#9900EF'],
+    trackColorSettings: {    
+        mainPalette: "blues",
+        auxPalette: "reds",
+        colorReadsByMappingQuality: false
+    },
 }
 
 export default TrackSettings;
diff --git a/src/components/TrackTypeDropdown.js b/src/components/TrackTypeDropdown.js
index 61c20b4..0e5292b 100644
--- a/src/components/TrackTypeDropdown.js
+++ b/src/components/TrackTypeDropdown.js
@@ -50,7 +50,7 @@ TrackTypeDropdown.propTypes = {
 TrackTypeDropdown.defaultProps = {
   id: undefined,
   className: undefined,
-  value: undefined,
+  value: "graph",
   testID: "file-type-select-component"
 };
 
diff --git a/src/config.json b/src/config.json
index ce5af3f..fc5b15f 100644
--- a/src/config.json
+++ b/src/config.json
@@ -56,5 +56,15 @@
     "mainPalette": "reds", 
     "auxPalette": "blues", 
     "colorReadsByMappingQuality": false
+  },
+
+  "defaultTrackProps" : {
+    "trackType": "graph",
+    "trackColorSettings": {
+      "mainPalette": "blues",
+      "auxPalette": "reds",
+      "colorReadsByMappingQuality": false
+    }
+
   }
 }


Test patch:
diff --git a/src/components/TrackList.test.js b/src/components/TrackList.test.js
index 66f62a5..dde95f9 100644
--- a/src/components/TrackList.test.js
+++ b/src/components/TrackList.test.js
@@ -98,8 +98,10 @@ describe('TrackList', () => {
         await waitFor(() => getByText("haplotype"));
         fireEvent.click(getByText("haplotype"));
 
-        // should wait for file to be selected
-        expect(fakeOnChange).toHaveBeenCalledTimes(0);
+        expect(fakeOnChange).toHaveBeenCalledTimes(1);
+        let newTracks = JSON.parse(JSON.stringify(tracks));
+        newTracks[1].trackType = "haplotype";
+        rerenderTrackList(rerender, newTracks, fakeOnChange, fakeOnDelete);
 
         // change file name
         const fileSelectComponent = queryByTestId('file-select-component1');
@@ -107,9 +109,7 @@ describe('TrackList', () => {
         await waitFor(() => getByText("fileB1.gbwt"));
         fireEvent.click(getByText("fileB1.gbwt"));
 
-        expect(fakeOnChange).toHaveBeenCalledTimes(1);
-        let newTracks = JSON.parse(JSON.stringify(tracks));
-        newTracks[1].trackType = "haplotype";
+        expect(fakeOnChange).toHaveBeenCalledTimes(2);
         newTracks[1].trackFile = {"name": "fileB1.gbwt", "type": "haplotype"};
         expect(fakeOnChange).toHaveBeenCalledWith(newTracks);
 
@@ -123,7 +123,7 @@ describe('TrackList', () => {
         fireEvent.click(getByText("reds"));
         fireEvent.click(document);
 
-        expect(fakeOnChange).toHaveBeenCalledTimes(2); 
+        expect(fakeOnChange).toHaveBeenCalledTimes(3); 
 
         newTracks[1].trackColorSettings.mainPalette = "reds";
         expect(fakeOnChange).toHaveBeenCalledWith(newTracks);
diff --git a/src/components/TrackListItem.test.js b/src/components/TrackListItem.test.js
index c42487b..ed34273 100644
--- a/src/components/TrackListItem.test.js
+++ b/src/components/TrackListItem.test.js
@@ -74,8 +74,7 @@ describe('TrackListItem', () => {
         await waitFor(() => getByText("haplotype"));
         fireEvent.click(getByText("haplotype"));
 
-        // should wait for file select to call onChange
-        expect(fakeOnChange).toHaveBeenCalledTimes(0);
+        expect(fakeOnChange).toHaveBeenCalledTimes(1);
 
         // simulate onchange rerendering component
         rerender(
@@ -99,7 +98,7 @@ describe('TrackListItem', () => {
         await waitFor(() => getByText("fileB1.gbwt"));
         fireEvent.click(getByText("fileB1.gbwt"));
 
-        expect(fakeOnChange).toHaveBeenCalledTimes(1);
+        expect(fakeOnChange).toHaveBeenCalledTimes(2);
         expect(fakeOnChange).toHaveBeenCalledWith(1, {
             trackFile: {"name": "fileB1.gbwt", "type": "haplotype"},
             trackType: "haplotype",
@@ -128,7 +127,7 @@ describe('TrackListItem', () => {
         fireEvent.click(getByText("reds"));
         fireEvent.click(document);
 
-        expect(fakeOnChange).toHaveBeenCalledTimes(2); 
+        expect(fakeOnChange).toHaveBeenCalledTimes(3); 
 
 
     });
diff --git a/src/components/TrackPickerDisplay.test.js b/src/components/TrackPickerDisplay.test.js
new file mode 100644
index 0000000..6d2e15b
--- /dev/null
+++ b/src/components/TrackPickerDisplay.test.js
@@ -0,0 +1,186 @@
+import React from 'react';
+import { render, fireEvent, waitFor }  from '@testing-library/react';
+import {TrackPickerDisplay} from './TrackPickerDisplay';
+import '@testing-library/jest-dom'
+import config from "./../config.json";
+
+
+describe('TrackPickerDisplay', () => {
+    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(
+            <TrackPickerDisplay
+                tracks={tracks}
+                availableTracks={availableTracks}
+                availableColors={availableColors}
+                onChange={fakeOnChange}
+            />
+        );
+
+        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(
+            <TrackPickerDisplay
+                tracks={tracks}
+                availableTracks={availableTracks}
+                availableColors={availableColors}
+                onChange={fakeOnChange}
+            />
+        );
+
+        expect(queryByTestId('file-type-select-component4')).toBeFalsy();
+
+        const addButtonComponent = queryByTestId('track-add-button-component');
+        fireEvent.click(addButtonComponent);
+
+        const newTrackItem = queryByTestId('file-type-select-component4');
+
+        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(
+            <TrackPickerDisplay
+                tracks={tracks}
+                availableTracks={availableTracks}
+                availableColors={availableColors}
+                onChange={fakeOnChange}
+            />
+        );
+
+        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(
+            <TrackPickerDisplay
+                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 delete a trackitem when the delete button is pressed', async () => {
+        const fakeOnChange = jest.fn();
+        const { queryByTestId } = render(
+            <TrackPickerDisplay
+                tracks={tracks}
+                availableTracks={availableTracks}
+                availableColors={availableColors}
+                onChange={fakeOnChange}
+            />
+        );
+
+        // pressing the delete button should delete that row
+        fireEvent.click(queryByTestId('delete-button-component1'));
+        expect(queryByTestId('file-type-select-component1')).toBeFalsy();
+        expect(queryByTestId('file-select-component1')).toBeFalsy();
+
+        fireEvent.click(queryByTestId('delete-button-component3'));
+        expect(queryByTestId('file-type-select-component3')).toBeFalsy();
+        expect(queryByTestId('file-select-component3')).toBeFalsy();
+    });
+
+});