CLOSER-Cohorts/archivist

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

Summary

Maintainability
F
3 wks
Test Coverage
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'
import { Instrument, CcConditions, CcLoops, CcSequences, CcStatements, CcQuestions, QuestionItems, QuestionGrids, Variables, Topics } from '../actions'
import { Dashboard } from '../components/Dashboard'
import { get, isEmpty, isNil, uniq } from "lodash";
import { InstrumentHeading } from '../components/InstrumentHeading'
import Grid from '@material-ui/core/Grid';
import Paper from '@material-ui/core/Paper';

import { makeStyles } from '@material-ui/core/styles';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import Collapse from '@material-ui/core/Collapse';
import DoneIcon from '@material-ui/icons/Done';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import Chip from '@material-ui/core/Chip';
import Autocomplete from '@material-ui/lab/Autocomplete';
import TextField from '@material-ui/core/TextField';
import FormControl from '@material-ui/core/FormControl';
import InputLabel from '@material-ui/core/InputLabel';
import Select from '@material-ui/core/Select';
import { Alert, AlertTitle } from '@material-ui/lab';
import { Loader } from '../components/Loader'
import SyncLoader from "react-spinners/SyncLoader";
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import DescriptionIcon from '@material-ui/icons/Description';

const useStyles = makeStyles((theme) => ({
  root: {
    width: '100%',
    backgroundColor: theme.palette.background.paper
  },
  control: {
    width: '100%',
    padding: theme.spacing(2),
  },
  nested: {
    paddingLeft: theme.spacing(4),
  },
}));

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

const ObjectFinder = (instrumentId, type, id) => {
  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 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, {})
  const loops = useSelector(state => state.cc_loops);
  const cc_loops = get(loops, instrumentId, {})

  var item = {children: []}

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

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

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

  if (type === 'CcLoop') {
    item = get(cc_loops, id.toString(), {})
  }

  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(), {})
    }
  }

  return item

}

const SequenceTopicsFinder = (props) => {
  const dispatch = useDispatch()
  const { instrumentId, sequence } = props
  const questions = useSelector(state => state.cc_questions);
  const cc_questions = get(questions, instrumentId, {})

  var child_questions = sequence.children.filter((child) => { return child.type == 'CcQuestion' }).map((child) => { return get(cc_questions, child.id) })
  var topicIds = uniq(child_questions.map((question) => { return get(question, 'topic') }).filter((t) => { return t != null }).map((t) => { return t.id }))
  const resolvedTopicIds = uniq(child_questions.map((question) => { return get(question, 'resolved_topic') }).filter((t) => { return t != null }).map((t) => { return t.id }))
  topicIds = [topicIds, resolvedTopicIds].flat()

  const handleChange = (event, value, reason) => {
    child_questions.map((cc_question)=>{
      dispatch(CcQuestions.topic.set(instrumentId, cc_question.id, (reason === 'clear') ? null : value.id));
    })
  }

  if (new Set(topicIds).size > 1 || child_questions.length < 1){
    return ''
  }else{
    return (
      <TopicList topicId={get(topicIds, 0)} instrumentId={instrumentId} handleChange={handleChange} />
    )
  }
}

const StatementListItem = (props) => {
  const { type, id, instrumentId } = props
  const item = ObjectFinder(instrumentId, type, id)

  return (
    <ListItem>
      <ListItemText primary={item.literal} />
    </ListItem>
  )
}


const QuestionListItem = (props) => {
  const { type, id, instrumentId } = props
  const item = ObjectFinder(instrumentId, type, id)

  if (isNil(item.question)) {
    return ''
  }

  if (item.question_type === 'QuestionGrid') {
    return <QuestionGridListItem item={item} instrumentId={instrumentId} />
  } else {
    return <QuestionItemListItem item={item} instrumentId={instrumentId} />
  }
}

const QuestionItemListItem = (props) => {
  const {item, instrumentId} = props
  const classes = useStyles();

  const title = (isEmpty(item.question)) ? item.label : item.question.literal

  const topic = get(item, 'topic', {id: null})
  const topicId = get(topic, 'id', null)

  const variableTopic = get(item, 'variable_topic', {id: null})

  const status = ObjectStatus(item.id, 'CcQuestion')

  var errorMessage = null;

  if(status.error){
    errorMessage = status.errorMessage
  }else if(item.errors){
    errorMessage = item.errors
  }

  return (
      <ListItem>
      <Paper className={classes.control}>
      <Grid container spacing={3}>
          { !isEmpty(errorMessage) && (
            <div className={classes.root}>
              <Alert severity="error">
                <AlertTitle>Error</AlertTitle>
                {errorMessage}
              </Alert>
            </div>
          )}
          <Grid item xs={12}>
            <Chip label={item.label} color="primary"></Chip>
            {!isEmpty(status) && !isNil(status.saving) && (
              <Chip label="Saving" color="secondary">              <SyncLoader /></Chip>
            )}
            {!isEmpty(status) && !isNil(status.saved) && (
              <Chip label="Saved" color="success" deleteIcon={<DoneIcon />}></Chip>
            )}
            <ListItemText primary={title} />
          </Grid>
          <Grid item xs={6}>
            <VariableList variables={item.variables} instrumentId={instrumentId} ccQuestionId={item.id} topicId={topicId || get(variableTopic, 'id', null)} />
          </Grid>
          <Grid item xs={6}>
            <TopicList topicId={topicId} instrumentId={instrumentId} ccQuestionId={item.id} />
            {(isNil(get(topic, 'id')) && !isNil(variableTopic)) && (!isNil(get(variableTopic, 'name'))) && (
              <em>Resolved topic from variables - {get(variableTopic, 'name')}</em>
            )}
          </Grid>
        </Grid>
      </Paper>
      </ListItem>
  )
}

const QuestionGridListItem = (props) => {
  const { item, instrumentId } = props
  const classes = useStyles();

  const title = (isEmpty(item.question)) ? item.label : item.question.literal

  const topic = get(item, 'topic', { id: null })
  const topicId = get(topic, 'id', null)

  const variableTopic = get(item, 'variable_topic', { id: null })

  const status = ObjectStatus(item.id, 'CcQuestion')

  var errorMessage = null;

  if (status.error) {
    errorMessage = status.errorMessage
  } else if (item.errors) {
    errorMessage = item.errors
  }

  return (
    <ListItem>
      <Paper className={classes.control}>
        <Grid container spacing={3}>
          {!isEmpty(errorMessage) && (
            <div className={classes.root}>
              <Alert severity="error">
                <AlertTitle>Error</AlertTitle>
                {errorMessage}
              </Alert>
            </div>
          )}
          <Grid item xs={12}>
            <Chip label={item.label} color="primary"></Chip>
            {!isEmpty(status) && !isNil(status.saving) && (
              <Chip label="Saving" color="secondary">              <SyncLoader /></Chip>
            )}
            {!isEmpty(status) && !isNil(status.saved) && (
              <Chip label="Saved" color="success" deleteIcon={<DoneIcon />}></Chip>
            )}
            <ListItemText primary={title} />
          </Grid>
          <Grid item xs={12}>
            <Table size="small">
              <TableHead>
                <TableRow>
                  <TableCell><strong>{item.question.pretty_corner_label}</strong></TableCell>
                  {item.question.cols.map((header) => (
                    <TableCell><strong>{header.label}</strong></TableCell>
                  ))}
                </TableRow>
              </TableHead>
              <TableBody>
                {item.question.rows && item.question.rows.map((row, y) => (
                  <TableRow key={row.label}>
                    <TableCell><strong>{row.label}</strong></TableCell>
                    {item.question.cols.map((header, x) => (
                      <TableCell>
                        <VariableList variables={item.variables.filter((variable) => { return variable.y == y + 1 && variable.x == x + 1 })} instrumentId={instrumentId} ccQuestionId={item.id} x={x + 1} y={y + 1} topicId={topicId || get(variableTopic, 'id', null)} />
                      </TableCell>
                    ))}
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          </Grid>
          <Grid item xs={6}>
            <VariableList variables={item.variables.filter((variable) => { return (variable.y == 0 && variable.x == 0) || (variable.y == undefined && variable.x == undefined) })} instrumentId={instrumentId} ccQuestionId={item.id} x={0} y={0} topicId={topicId || get(variableTopic, 'id', null)} label={'Map whole grid to variables'} />
          </Grid>
          <Grid item xs={6}>
            <TopicList topicId={topicId} instrumentId={instrumentId} ccQuestionId={item.id} />
            {(isNil(get(topic, 'id')) && !isNil(variableTopic)) && (!isNil(get(variableTopic, 'name'))) && (
              <em>Resolved topic from variables - {get(variableTopic, 'name')}</em>
            )}
          </Grid>
        </Grid>
      </Paper>
    </ListItem>
  )
}

const TopicList = (props) => {
  const dispatch = useDispatch()
  const {topicId, instrumentId, ccQuestionId=undefined} = props
  const { handleChange=(event, value, reason) => {
    dispatch(CcQuestions.topic.set(instrumentId, ccQuestionId, (reason === 'clear') ? null : value.id));
  }} = props

  const topics = useSelector(state => state.topics);
  const topicOptions = [{id: null, name: '', level: 1}].concat(Object.values(get(topics,'flattened', {})));
  const classes = makeStyles((theme) => ({
    root: {
      flexGrow: 1,
    },
    paper: {
      padding: theme.spacing(2),
      textAlign: 'center',
      color: theme.palette.text.secondary,
    },
  }));

  if (isEmpty(topics) || isEmpty(topics.flattened)){
    return 'Fetching topics'
  }else if(isNil(topicId)){
    return (
          <div>
            <span></span>
            <Autocomplete
              onChange={handleChange}
              options={topicOptions}
              renderInput={params => (
                <TextField {...params} label="Topic" variant="outlined" />
              )}
              getOptionLabel={option => (option.level === 1) ? option.name : '--' + option.name}
            />
          </div>
    )
  }else{
    return (
          <div>
            <Autocomplete
              onChange={handleChange}
              options={topicOptions}
              renderInput={params => (
                <TextField {...params} label="Topic" variant="outlined" />
              )}
              value={Object.values(topics.flattened).find(topic => { return topic.id == topicId })}
              getOptionLabel={option => (option.level === 1) ? option.name : '--' + option.name}
            />
          </div>
    )
  }
}

const VariableList = (props) => {
  const {variables, instrumentId, ccQuestionId, x, y, topicId, label='Variables'} = props

  const dispatch = useDispatch()

  const allVariables = useSelector(state => state.variables);
  var variableOptions = Object.values(get(allVariables, instrumentId, {}))
  if(!isEmpty(topicId)) {
    variableOptions = variableOptions.filter(opt => {
      return (
        get(opt.topic, 'id') == topicId ||
        get(opt.resolved_topic, 'id') == topicId ||
        (get(opt.topic, 'id') === 0 && get(opt.resolved_topic, 'id') === 0) || (opt.topic === null && opt.resolved_topic === null)
      )
    })
  }

  const handleAddVariable = (newVariables) => {
    dispatch(CcQuestions.variables.add(instrumentId, ccQuestionId, newVariables, x, y));
  }

  const handleRemoveVariable = (oldVariables) => {
    dispatch(CcQuestions.variables.remove(instrumentId, ccQuestionId, oldVariables, x, y));
  }

  var difference = []

  const handleChange = (event, value, reason) => {
    switch (reason) {
      case 'select-option':
        difference = value.filter(x => !variables.includes(x));
        if(!isEmpty(difference)){
          return handleAddVariable(difference.map((variable) => { return variable.name }).join(','))
        };
        break;
      case 'remove-option':
        difference = variables.filter(x => !value.includes(x));
        if(!isEmpty(difference)){
          return handleRemoveVariable(difference.map((variable) => { return variable.id }).join(','))
        };
        break;
      default:
        return null;
    }
  }

  if(isEmpty(variables)){
    return (
      <div>
         <Autocomplete
          multiple
          id="tags-outlined"
          options={variableOptions}
          getOptionLabel={(option) => option.name}
          onChange={handleChange}
          value={[]}
          filterSelectedOptions
          renderInput={(params) => (
            <TextField
              {...params}
              variant="outlined"
              label={label}
              placeholder="Add variable"
            />
          )}
        />
      </div>
    )
  }else{
    return (
      <div>
         <Autocomplete
          multiple
          id="tags-outlined"
          options={Object.values(variableOptions)}
          getOptionLabel={(option) => option.name}
          onChange={handleChange}
          value={variables}
          getOptionSelected= {(option, value) => (
            option.id === value.id
          )}
          filterSelectedOptions
          renderInput={(params) => (
            <TextField
              {...params}
              variant="outlined"
              label={label}
              placeholder="Add variable"
            />
          )}
        />
      </div>
    )
  }
}

const ConditionItem = (props) => {
  const { instrumentId } = props;
  var {title} = props;
  const classes = useStyles();
  const [open, setOpen] = React.useState(true);

  const handleClick = () => {
    setOpen(!open);
  };

  var item = ObjectFinder(instrumentId, props.type, props.id)

  title = get(item, 'literal', props.title)

  return (
    <List
      component="nav"
      aria-labelledby="nested-list-subheader"
      className={classes.root}
    >
      <ListItem button onClick={handleClick}>
        <ListItemText primary={title} />
          {open ? <ExpandLess /> : <ExpandMore />}
      </ListItem>
      <Collapse in={open} timeout="auto" unmountOnExit>
        <ListItem>
          <ListItemText primary={'True'} />
        </ListItem>
        {!isEmpty(item.children) && (
          <List component="div" disablePadding>
            {item.children.map((child) => (
              <ListItem button className={classes.nested}>
                {(function() {
                  switch (child.type) {
                    case 'CcSequence':
                      return <SequenceItem instrumentId={instrumentId} id={child.id} type={child.type} title={child.type} children={get(child,'children',[])} />;
                    case 'CcQuestion':
                      return <QuestionListItem instrumentId={instrumentId} id={child.id} type={child.type} />
                    case 'CcCondition':
                      return <ConditionItem instrumentId={instrumentId} id={child.id} type={child.type} />
                    case 'CcLoop':
                      return <LoopItem instrumentId={instrumentId} id={child.id} type={child.type} />
                    case 'CcStatement':
                      return <StatementListItem instrumentId={instrumentId} id={child.id} type={child.type} />
                    default:
                      return null;
                  }
                })()}
              </ListItem>
            ))}
          </List>
        )}
        <ListItem>
          <ListItemText primary={'False'} />
        </ListItem>
        {!isEmpty(item.fchildren) && (
          <List component="div" disablePadding>
            {item.fchildren.map((child) => (
              <ListItem button className={classes.nested}>
                {(function () {
                  switch (child.type) {
                    case 'CcSequence':
                      return <SequenceItem instrumentId={instrumentId} id={child.id} type={child.type} title={child.type} children={get(child, 'children', [])} />;
                    case 'CcQuestion':
                      return <QuestionListItem instrumentId={instrumentId} id={child.id} type={child.type} />
                    case 'CcCondition':
                      return <ConditionItem instrumentId={instrumentId} id={child.id} type={child.type} />
                    case 'CcLoop':
                      return <LoopItem instrumentId={instrumentId} id={child.id} type={child.type} />
                    case 'CcStatement':
                      return <StatementListItem instrumentId={instrumentId} id={child.id} type={child.type} />
                    default:
                      return null;
                  }
                })()}
              </ListItem>
            ))}
          </List>
        )}
      </Collapse>
    </List>
  );
}

const LoopItem = (props) => {
  const { type, id, instrumentId, title } = props
  const classes = useStyles();
  const [open, setOpen] = React.useState(true);

  const handleClick = () => {
    setOpen(!open);
  };

  var item = ObjectFinder(instrumentId, type, id)

  var loop_description = `${item.loop_var} from ${item.start_val} while`

  if (item.end_val) {
    loop_description += ` ${item.loop_var} <= ${item.end_val}`
  }

  if (item.loop_while) {
    loop_description += ` ${(item.end_val) ? '&& ' : ''}${item.loop_while}`
  }

  if (isNil(item.end_val) && isNil(item.loop_while)) {
    loop_description += ` []`
  }

  return (
    <List
      component="nav"
      aria-labelledby="nested-list-subheader"
      className={classes.root}
    >
      <ListItem button onClick={handleClick}>
        <ListItemText primary={loop_description} />
        {open ? <ExpandLess /> : <ExpandMore />}
      </ListItem>
      {!isEmpty(item.children) && (
        <Collapse in={open} timeout="auto" unmountOnExit>
          <List component="div" disablePadding>
            {item.children.map((child) => (
              (function () {
                switch (child.type) {
                  case 'CcSequence':
                    return (
                      <ListItem className={classes.nested}>
                        <SequenceItem instrumentId={instrumentId} id={child.id} type={child.type} title={child.type} children={get(child, 'children', [])} />
                      </ListItem>)
                  case 'CcQuestion':
                    return (
                      <ListItem className={classes.nested}>
                        <QuestionListItem instrumentId={instrumentId} id={child.id} type={child.type} />
                      </ListItem>)
                  case 'CcCondition':
                    return (
                      <ListItem className={classes.nested}>
                        <ConditionItem instrumentId={instrumentId} id={child.id} type={child.type} children={get(child, 'children', [])} />
                      </ListItem>)
                  case 'CcLoop':
                    return (
                      <ListItem className={classes.nested}>
                        <LoopItem instrumentId={instrumentId} id={child.id} type={child.type} />
                      </ListItem>)
                  case 'CcStatement':
                    return (
                      <ListItem button className={classes.nested}>
                        <StatementListItem instrumentId={instrumentId} id={child.id} type={child.type} />
                      </ListItem>)
                  default:
                    return null;
                }
              })()
            ))}
          </List>
        </Collapse>
      )}
    </List>
  );
}

const SequenceItem = (props) => {
  const { instrumentId } = props;
  var {title} = props;
  const classes = useStyles();
  const [open, setOpen] = React.useState(true);

  const handleClick = () => {
    setOpen(!open);
  };

  var item = ObjectFinder(instrumentId, props.type, props.id)

  title = get(item, 'label', props.title)

  return (
    <Paper className={classes.control}>
      <List
        component="nav"
        aria-labelledby="nested-list-subheader"
        className={classes.root}
      >
        <ListItem button >
          <Grid item xs={6}>
            <ListItemText primary={title} />
          </Grid>
          <Grid item xs={5}>
            <SequenceTopicsFinder instrumentId={instrumentId} sequence={item} />
          </Grid>
          <Grid item xs={1}>
            {open ? <ExpandLess onClick={handleClick} /> : <ExpandMore onClick={handleClick} />}
          </Grid>
        </ListItem>
        {!isEmpty(item.children) && (
          <Collapse in={open} timeout="auto" unmountOnExit>
            <List component="div" disablePadding>
              {item.children.map((child) => (
                  (function() {
                    switch (child.type) {
                      case 'CcSequence':
                        return (
                            <ListItem button className={classes.nested}>
                              <SequenceItem instrumentId={instrumentId} id={child.id} type={child.type} title={child.type} children={get(child,'children',[])} />
                            </ListItem>)
                      case 'CcQuestion':
                        return (
                            <ListItem button className={classes.nested}>
                              <QuestionListItem instrumentId={instrumentId} id={child.id} type={child.type} />
                            </ListItem>)
                      case 'CcCondition':
                        return (
                            <ListItem button className={classes.nested}>
                              <ConditionItem instrumentId={instrumentId} id={child.id} type={child.type} />
                            </ListItem>)
                      case 'CcLoop':
                        return (
                          <ListItem button className={classes.nested}>
                            <LoopItem instrumentId={instrumentId} id={child.id} type={child.type} />
                          </ListItem>)
                      case 'CcStatement':
                        return (
                          <ListItem button className={classes.nested}>
                            <StatementListItem instrumentId={instrumentId} id={child.id} type={child.type} />
                          </ListItem>)
                      default:
                        return null;
                    }
                  })()
              ))}
            </List>
          </Collapse>
        )}
      </List>
    </Paper>
  );
}

const InstrumentMap = (props) => {

  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, {})

  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(Variables.all(instrumentId)),
      dispatch(Topics.all())
    ]).then(() => {
      setDataLoaded(true)
    });

    // eslint-disable-next-line react-hooks/exhaustive-deps
  },[]);

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

  return (
    <div style={{ height: 500, width: '100%' }}>
      <Dashboard title={'Maps'} instrumentId={instrumentId}>
        <InstrumentHeading instrument={instrument} mode={'map'} />
        <Grid container spacing={3}>
          <Grid item xs={10}></Grid>
          <Grid item xs={2}>
            <a href={`${process.env.REACT_APP_API_HOST}/instruments/${instrumentId}/all_mappings.txt?token=${window.localStorage.getItem('jwt')}`}>
              <Chip icon={<DescriptionIcon />} variant="outlined" color="primary" label={'Download File'}></Chip>
            </a>
          </Grid>
        </Grid>
        {!dataLoaded
        ? <Loader />
        : <SequenceItem instrumentId={instrumentId} type={'CcSequence'} id={sequence.children[0].id} title={sequence.children[0].label} children={sequence.children[0].children}/>
      }
      </Dashboard>
    </div>
  );
}

export default InstrumentMap;