CLOSER-Cohorts/archivist

View on GitHub
react/src/pages/InstrumentConstructBuild.js

Summary

Maintainability
F
1 wk
Test Coverage
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'
import { Instrument, CcConditions, CcLoops, CcSequences, CcStatements, CcQuestions, QuestionItems, QuestionGrids, ResponseUnits, InstrumentTree } from '../actions'
import { Dashboard } from '../components/Dashboard'
import { MoveConstructSelect } from '../components/MoveConstructSelect'
import { CcConditionForm } from '../components/CcConditionForm'
import { CcQuestionForm } from '../components/CcQuestionForm'
import { CcStatementForm } from '../components/CcStatementForm'
import { CcSequenceForm } from '../components/CcSequenceForm'
import { CcLoopForm } from '../components/CcLoopForm'
import { Loader } from '../components/Loader'
import { ObjectColour } from '../support/ObjectColour'
import { ConstructTypeFromObjectType } from '../support/ConstructTypeFromObjectType'
import { get, isEmpty, isNil } from "lodash";
import Grid from '@material-ui/core/Grid';
import Paper from '@material-ui/core/Paper';

import { makeStyles } from '@material-ui/core/styles';
import AddIcon from '@material-ui/icons/Add';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ExpandLessIcon from '@material-ui/icons/ExpandLess';
import HighlightOffIcon from '@material-ui/icons/HighlightOff';


import {
  Button,
  ButtonGroup,
  Divider
} from '@material-ui/core';


import SortableTree, { addNodeUnderParent, removeNodeAtPath, getFlatDataFromTree, changeNodeAtPath, toggleExpandedForAll } from '@nosferatu500/react-sortable-tree';
import '@nosferatu500/react-sortable-tree/style.css'; // This only needs to be imported once in your app

const TreeNode = (instrumentId, type, id, objectFinder, expanded = false) => {
  var item = objectFinder.find(type, id)

  var children;

  if (item.type === "condition") {
    children = get(item, 'children', [])
    var fchildren = get(item, 'fchildren', [])

    var trueAndFalse = [
      { title: `${item.label} True`, expanded: expanded, conditionId: item.id, type: 'conditionTrue', children: children.map(child => TreeNode(instrumentId, child.type, child.id, objectFinder)) },
      { title: `${item.label} Else`, expanded: expanded, conditionId: item.id, type: 'conditionFalse', children: fchildren.map(child => TreeNode(instrumentId, child.type, child.id, objectFinder)) },
    ]
    return { ...item, ...{ title: `${item.label}`, expanded: expanded, type: item.type, children: trueAndFalse } }
  } else {
    children = get(item, 'children', [])

    return { ...item, ...{ title: `${item.label}`, expanded: expanded, type: item.type, children: children.map(child => TreeNode(instrumentId, child.type, child.id, objectFinder)) } }
  }
}

const TreeNodeFormatter = (instrumentId, item) => {

  if (item.type === "condition" && isEmpty(item.children)) {
    var tchildren = get(item, 'children', [])
    var fchildren = get(item, 'fchildren', [])

    const childrenNodes = [
      { title: `${item.label} True`, expanded: false, conditionId: item.id, type: 'conditionTrue', children: tchildren.map(child => TreeNodeFormatter(instrumentId, child)) },
      { title: `${item.label} Else`, expanded: false, conditionId: item.id, type: 'conditionFalse', children: fchildren.map(child => TreeNodeFormatter(instrumentId, child)) },
    ]
    return { ...item, ...{ title: `${item.label}`, expanded: true, type: item.type, children: childrenNodes } }
  }else{
    return { ...item, ...{ title: `${item.label}`, expanded: true, type: item.type, children: item.children } }
  }
}

const Tree = (props) => {
  const { topSequence, instrumentId, dispatch, onNodeSelect } = props
  const objectFinder = ObjectFinder(instrumentId);
  const [treeData, setTreeData] = useState([TreeNode(instrumentId, 'CcSequence', topSequence.id, objectFinder, true)]);
  const [selectedNode, setSelectedNode] = useState({});

  //  const [expanded, setExpanded] = useState(true);
  const classes = useStyles();

  const [searchString, setSearchString] = useState();
  const [searchFocusIndex, setSearchFocusIndex] = useState();
  const [searchFoundCount, setSearchFoundCount] = useState();

  // Case insensitive search of `node.title`
  const customSearchMethod = ({ node, searchQuery }) =>
    searchQuery &&
    node.title.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1;

  const selectPrevMatch = () =>
    setSearchFocusIndex(
      searchFocusIndex !== null
        ? (searchFoundCount + searchFocusIndex - 1) % searchFoundCount
        : searchFoundCount - 1
    );

  const selectNextMatch = () =>
    setSearchFocusIndex(
      searchFocusIndex !== null
        ? (searchFocusIndex + 1) % searchFoundCount
        : 0,
    );

  const getNodeKey = ({ treeIndex }) => treeIndex;

  const updateNode = ({ node, path }) => {
    var data = changeNodeAtPath({
      treeData: treeData,
      path,
      getNodeKey,
      newNode: TreeNodeFormatter(instrumentId, node)
    })

    setTreeData(data)
    reorderConstructs(data)
  }

  const deleteNode = ({ path }) => {
    onNodeSelect({})
    setTreeData(removeNodeAtPath({
      treeData: treeData,
      path,
      getNodeKey,
    }));
  }

  const canHaveChildren = (node) => {
    return (node.type === 'sequence' || node.type === 'loop' || node.type === 'conditionTrue' || node.type === 'conditionFalse')
  }

  const canDrop = ({ node, nextParent, prevPath, nextPath }) => {
    if (!isNil(nextParent) && canHaveChildren(nextParent)) {
      return true;
    }

    return false;
  };

  const toggleExpand = (expanded) => {
    setTreeData(toggleExpandedForAll({
      treeData: treeData,
      expanded: expanded
    }));
  }

  const moveableNodesArray = (data) => {
    return getFlatDataFromTree({
      treeData: data,
      getNodeKey: ({ node }) => { return { id: node.id, type: node.type } }, // This ensures your "id" properties are exported in the path
      ignoreCollapsed: false, // Makes sure you traverse every node in the tree, not just the visible ones
    }).map(({ node, path }) => {
      if (!canHaveChildren(node)) {
        return null
      }
      if (['conditionTrue', 'conditionFalse'].includes(node.type)) {
        return null
      }
      return {
        id: node.id,
        type: node.type,
        title: node.title,
        path: path
      }
    }).filter(el => el != null);
  }

  dispatch(InstrumentTree.create(instrumentId, moveableNodesArray(treeData)));

  const orderArray = (data) => {
    return getFlatDataFromTree({
      treeData: data,
      getNodeKey: ({ node }) => { return { id: node.id, type: node.type, children: node.children.map(child => `type ${child.type} id ${child.id}`) } }, // This ensures your "id" properties are exported in the path
      ignoreCollapsed: false, // Makes sure you traverse every node in the tree, not just the visible ones
    }).map(({ node, path }) => {
      if (['conditionTrue', 'conditionFalse'].includes(node.type)) {
        return null
      }
      let parent = path[path.length - 2]
      let branch = (parent !== undefined && parent.type === 'conditionFalse') ? 1 : 0
      let position = (parent !== undefined) ? parent.children.indexOf(`type ${node.type} id ${node.id}`) + 1 : node.position
      if (parent !== undefined && ['conditionTrue', 'conditionFalse'].includes(parent.type)) {
        parent = path[path.length - 3]
      }
      const data = {
        id: node.id,
        type: node.type,
        position: position,
        branch: branch,
        // // The last entry in the path is this node's key
        // // The second to last entry (accessed here) is the parent node's key
        parent: (parent !== undefined) ? { id: parent.id, type: parent.type } : {},
      }

      return data
    }).filter(el => el != null);
  }

  const reorderConstructs = (data) => {
    dispatch(Instrument.reorderConstructs(instrumentId, orderArray(data)));
  }

  const generateButtons = (node, path) => {
    var buttons = []
    if (canHaveChildren(node)) {
      const newNode = {
        title: `Click to select construct type`,
        children: [],
        type: undefined
      }
      buttons.push(
        <button
          onClick={(event) => {
            setTreeData(addNodeUnderParent({
              treeData: treeData,
              parentKey: path[path.length - 1],
              expandParent: true,
              getNodeKey,
              newNode: newNode
            }).treeData)
            event.stopPropagation()
            setSelectedNode({ node: newNode, path: path, callback: ({ node, path }) => { updateNode({ node, path }); setSelectedNode(null) }, deleteCallback: ({ path }) => { deleteNode({ path }) } });
          }}
        >
          <AddIcon />
        </button>
      )
    }
    return buttons;
  }
  const treeHeight = getFlatDataFromTree({ treeData: treeData, getNodeKey: ({ node }) => { return { id: node.id, type: node.type } }, ignoreCollapsed: true }).length * 75

  return (
    <div style={{ height: treeHeight }}>

      <Divider className={classes.divider} />
      <Grid container spacing={3}>
        <Grid item xs={6}>
          <MoveConstructSelect treeData={treeData} onChange={newTreeData => { setTreeData(newTreeData); reorderConstructs(newTreeData) }} />
        </Grid>
        <Grid item xs={6}>
          <ButtonGroup color="primary" aria-label="outlined primary button group">
            <Button onClick={() => { toggleExpand(true) }} startIcon={<ExpandMoreIcon />}>Expand All</Button>
            <Button onClick={() => { toggleExpand(false) }} startIcon={<ExpandLessIcon />}>Collapse All</Button>
          </ButtonGroup>
        </Grid>
      </Grid>

      <SortableTree
        treeData={treeData}
        onChange={newTreeData => { setTreeData(newTreeData); reorderConstructs(newTreeData) }}
        canNodeHaveChildren={node => canHaveChildren(node)}
        canDrop={canDrop}
        canDrag={({ node }) => {
          return !['conditionTrue', 'conditionFalse'].includes(node.type)
        }}
        searchMethod={customSearchMethod}
        searchQuery={searchString}
        searchFocusOffset={searchFocusIndex}
        searchFinishCallback={(matches) => {
          setSearchFoundCount(matches.length)
          setSearchFocusIndex(matches.length > 0 ? searchFocusIndex % matches.length : 0)
        }}
        generateNodeProps={({ node, path }) => {
          const boxShadow = (node === selectedNode || node.type == 'sequence') ? `0px 0px 15px 3px  #${ObjectColour(node.type)}` : ''

          return (
            {
              style: {
                boxShadow: boxShadow,
              },
              onClick: () => {
                onNodeSelect({ node: node, path: path, callback: ({ node, path }) => { updateNode({ node, path }); setSelectedNode(null) }, deleteCallback: ({ path }) => { deleteNode({ path }) } });
                setSelectedNode(node);
              },
              buttons: generateButtons(node, path),
              className: `${node.type}:${node.id}`
            }
          )
        }}
      />
    </div>
  );
}

const useStyles = makeStyles((theme) => ({
  root: {
    width: '100%',
    backgroundColor: theme.palette.background.paper
  },
  control: {
    width: '100%',
    padding: theme.spacing(2),
  },
  main: {
    'min-height': '1200px'
  },
  side: {
    position: 'absolute',
    width: '50%',
    right: '5px'
  },
  nested: {
    paddingLeft: theme.spacing(4),
  },
  paper: {
    boxShadow: `5px 5px 10px 5px  #${ObjectColour('default')}`
  },
  statement: {
    boxShadow: `2px 2px 7px 2px  #${ObjectColour('statement')}`,
    'margin-bottom': '10px'
  },
  sequence: {
    boxShadow: `2px 2px 7px 2px  #${ObjectColour('sequence')}`,
    'margin-bottom': '10px'
  },
  question: {
    boxShadow: `2px 2px 7px 2px  #${ObjectColour('question')}`,
    'margin-bottom': '10px'
  },
  loop: {
    boxShadow: `2px 2px 7px 2px  #${ObjectColour('loop')}`,
    'margin-bottom': '10px'
  },
  condition: {
    boxShadow: `2px 2px 7px 2px  #${ObjectColour('condition')}`,
    'margin-bottom': '10px'
  },
  divider: {
    margin: '25px'
  }
}));

const ObjectStatus = (id, type) => {
  const statuses = useSelector(state => state.statuses);
  const key = type + ':' + id
  return get(statuses, key, {})
}

const ObjectFinder = (instrumentId) => {
  const sequences = useSelector(state => state.cc_sequences);
  const cc_sequences = get(sequences, instrumentId, {})
  const statements = useSelector(state => state.cc_statements);
  const cc_statements = get(statements, instrumentId, {})
  const conditions = useSelector(state => state.cc_conditions);
  const cc_conditions = get(conditions, instrumentId, {})
  const loops = useSelector(state => state.cc_loops);
  const cc_loops = get(loops, instrumentId, {})
  const questions = useSelector(state => state.cc_questions);
  const cc_questions = get(questions, instrumentId, {})
  const allQuestionItems = useSelector(state => state.questionItems);
  const questionItems = get(allQuestionItems, instrumentId, {})
  const allQuestionGrids = useSelector(state => state.questionGrids);
  const questionGrids = get(allQuestionGrids, instrumentId, {})

  var item = { children: [] }

  return {
    find: (type, id) => {
      if (type === 'CcLoop') {
        item = get(cc_loops, id.toString(), {})
        item.type = 'loop'
      }

      if (type === 'CcSequence') {
        item = get(cc_sequences, id.toString(), {})
        item.type = 'sequence'
      }

      if (type === 'CcStatement') {
        item = get(cc_statements, id.toString(), {})
        item.type = 'statement'
      }

      if (type === 'CcCondition') {
        item = get(cc_conditions, id.toString(), {})
        item.type = 'condition'
      }

      if (type === 'CcQuestion') {
        item = get(cc_questions, id.toString(), {})

        if (item.question_type === 'QuestionItem') {
          item.question = get(questionItems, item.question_id.toString(), {})
        } else if (item.question_type === 'QuestionGrid') {
          item.question = get(questionGrids, item.question_id.toString(), {})
        }
        item.type = 'question'
      }

      return item
    }
  }

}

const ConstructForm = (props) => {
  const { object, instrumentId, onNodeSelect } = props;
  const { node = {}, path, callback = (node) => { console.log('No onChange callback provided') }, deleteCallback = (node) => { console.log('No onDelete callback provided') } } = object;
  const onCreate = () => { onNodeSelect(null) }
  switch (node.type) {
    case 'question':
      return <CcQuestionForm ccQuestion={node} instrumentId={instrumentId} path={path} onChange={callback} onDelete={deleteCallback} onCreate={onCreate} />
    case 'statement':
      return <CcStatementForm ccStatement={node} instrumentId={instrumentId} path={path} onChange={callback} onDelete={deleteCallback} onCreate={onCreate} />
    case 'sequence':
      return <CcSequenceForm ccSequence={node} instrumentId={instrumentId} path={path} onChange={callback} onDelete={deleteCallback} onCreate={onCreate} />
    case 'condition':
      return <CcConditionForm ccCondition={node} instrumentId={instrumentId} path={path} onChange={callback} onDelete={deleteCallback} onCreate={onCreate} />
    case 'loop':
      return <CcLoopForm ccLoop={node} instrumentId={instrumentId} path={path} onChange={callback} onDelete={deleteCallback} onCreate={onCreate} />
    case undefined:
      return <NewConstructQuestion onNodeSelect={onNodeSelect} object={object} onChange={callback} path={path} onDelete={deleteCallback} onCreate={onCreate} />
    default:
      return ''
  }

}

const NewConstructQuestion = (props) => {
  const { object, onNodeSelect, onDelete, path, onChange } = props;

  const classes = useStyles();

  return (
    <Paper style={{ padding: 16 }} className={classes.paper}>
      <h3>Select construct type</h3>
      <Button
        type="button"
        variant="outlined"
        className={classes.question}
        onClick={() => {
          var node = { ...object.node, ...{ type: 'question' } }
          onNodeSelect({ ...object, ...{ node: node } })
        }}
      >
        Question
      </Button>
      <br />
      <Button
        type="button"
        variant="outlined"
        className={classes.condition}
        onClick={() => {
          var node = { ...object.node, ...{ type: 'condition' } }
          onNodeSelect({ ...object, ...{ node: node, callback: onChange } })
        }}
      >
        Condition
      </Button>
      <br />
      <Button
        type="button"
        variant="outlined"
        className={classes.loop}
        onClick={() => {
          var node = { ...object.node, ...{ type: 'loop' } }
          onNodeSelect({ ...object, ...{ node: node } })
        }}
      >
        Loop
      </Button>
      <br />
      <Button
        type="button"
        variant="outlined"
        className={classes.sequence}
        onClick={() => {
          var node = { ...object.node, ...{ type: 'sequence' } }
          onNodeSelect({ ...object, ...{ node: node } })
        }}
      >
        Sequence
      </Button>
      <br />
      <Button
        type="button"
        variant="outlined"
        className={classes.statement}
        onClick={() => {
          var node = { ...object.node, ...{ type: 'statement' } }
          onNodeSelect({ ...object, ...{ node: node } })
        }}
      >
        Statement
      </Button>
      <br />
      <Button
        type="button"
        variant="outlined"
        onClick={() => {
          onDelete({ path })
        }}
      >
        Delete
      </Button>
    </Paper>
  )

}

const InstrumentConstructBuild = (props) => {

  const classes = useStyles();

  const dispatch = useDispatch()
  const instrumentId = get(props, "match.params.instrument_id", "")
  const instrument = useSelector(state => get(state.instruments, instrumentId));
  const sequences = useSelector(state => state.cc_sequences);
  const cc_sequences = get(sequences, instrumentId, null)

  const [selectedNode, setSelectedNode] = useState({});
  const [dataLoaded, setDataLoaded] = useState(false);

  useEffect(() => {
    Promise.all([
      dispatch(Instrument.show(instrumentId)),
      dispatch(CcSequences.all(instrumentId)),
      dispatch(CcStatements.all(instrumentId)),
      dispatch(CcConditions.all(instrumentId)),
      dispatch(CcLoops.all(instrumentId)),
      dispatch(CcQuestions.all(instrumentId)),
      dispatch(QuestionItems.all(instrumentId)),
      dispatch(QuestionGrids.all(instrumentId)),
      dispatch(ResponseUnits.all(instrumentId))
    ]).then(() => {
      setDataLoaded(true)
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const handleCloseForm = () => {
    dispatch({ type: 'CLEAR', payload: { id: selectedNode.node.id, type: ConstructTypeFromObjectType(selectedNode.node.type) } })
    setSelectedNode(null)
  }

  const sequence = (isEmpty(cc_sequences) || isNil(cc_sequences)) ? undefined : Object.values(cc_sequences).find(element => element.top == true)

  return (
    <div style={{ height: 500, width: '100%' }}>
      <Dashboard title={'Build'} instrumentId={instrumentId}>
        <h1>{get(instrument, 'label')}</h1>
        {!dataLoaded
          ? <Loader />
          : (
            <Grid container spacing={3} className={classes.main}>
              <Grid item xs={(isEmpty(selectedNode)) ? 12 : 12}>
                <Tree topSequence={sequence.children[0]} instrumentId={instrumentId} onNodeSelect={setSelectedNode} dispatch={dispatch} />
              </Grid>
              {!isEmpty(selectedNode) && (
                <Grid item xs={4} className={classes.side}>
                  <ConstructForm object={selectedNode} instrumentId={instrumentId} onNodeSelect={setSelectedNode} />
                  <HighlightOffIcon style={{ position: 'absolute', right: '30px', top: '20px' }} onClick={handleCloseForm}>Close</HighlightOffIcon>
                </Grid>
              )}
            </Grid>
          )
        }
      </Dashboard>
    </div>
  );
}

export default InstrumentConstructBuild;