Source

stories/ConfigView/ConfigView.jsx


/** @module ConfigView */
// Third party libraries
import React, { useContext, createContext } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';

import { SectionTop } from '../Section';
import { FIELD_TYPES, STATIC_TYPES } from '../../constants';

// MUI imports
import {
  Typography, Card, CardContent,
  TableContainer, Table, TableHead, TableRow, TableCell, TableBody, Paper, Divider, styled
} from '@mui/material';
import Grid from '@mui/material/Unstable_Grid2';
import { Box } from '@mui/system';

//create a context for the application data
export const ViewContext = createContext();

/**
 * @function ConfigView
 * @param {object} props
 * @param {array} props.sections - array of sections to render
 * @param {object} [props.dynamicComponents] - object of dynamic components to render
 * @param {boolean} [props.isLoading] - boolean to indicate if the view is loading
 * @param {object} [props.options] - options object
 * @param {React.ReactElement} [props.options.fieldValueComponent] - component to use for field values. Use to completely control the rendering of field values
 * @param {React.ReactElement} [props.options.linkComponent] - component to use for links. If fieldValueComponent this property will be ignored
 * @returns {React.ReactElement} - returns rendered view
 */
export const ConfigView = ({ sections, dynamicComponents, isLoading, options }) => {
  // add dynamic components to the context
  const context = {
    dynamicComponents,
    isLoading,
    options
  };

  return (
    <ViewContext.Provider value={context}>
      {sections?.map((section, index) => {
        return (
          <ViewSection key={index} section={section} index={index} />
        );
      })}
    </ViewContext.Provider>
  );
};

ConfigView.propTypes = {
  sections: PropTypes.array.isRequired,
  dynamicComponents: PropTypes.object,
  isLoading: PropTypes.bool,
  options: PropTypes.object,
};

export default ConfigView;

/**
 * @function ViewSection
 * @param {object} props
 * @param {object} props.section - field object to render
 * @param {number} props.index - index of the field
 * @returns {React.ReactElement} - returns rendered section
 * @description renders a view section
 */
export const ViewSection = ({ section, index, ...props }) => {
  const { areas, name, columns, description } = section;
  const { isLoading } = useContext(ViewContext);
  const RenderFields = columns ? ViewColumns : ViewRows;
  const sx = {
    position: 'relative',
  };

  if (index) {
    sx.marginTop = '16px';
  }

  return (
    <Card sx={sx}>
      {isLoading &&
        (<Box
          sx={{
            position: 'absolute',
            top: '50%',
            left: '50%',
            transform: 'translate(-50%, -50%)',
          }}
        >
          {index === 0 && <Typography variant="sectionHeader">Loading Grant Application...</Typography>}
        </Box>)
      }
      <SectionTop
        title={name}
        description={description}
      />
      <CardContent className={isLoading ? 'loading' : 'loaded'}>
        <RenderFields areas={areas} />
      </CardContent>
    </Card>
  );
};

ViewSection.propTypes = {
  section: PropTypes.object.isRequired,
  index: PropTypes.number.isRequired,
};

/**
 * @function ViewColumns
 * @param {object} props
 * @param {object} props.areas - field object to render
 * @returns {React.ReactElement} - returns rendered columnss
 */
export const ViewColumns = ({ areas }) => {
  const colSize = Math.max(12 / areas.length, 3);
  return (
    <Grid container spacing={2} xs={12}>
      {areas.map((col, colIndex) => (
        <Grid key={colIndex} xs={Math.max(colSize, 12)} sm={Math.max(colSize, 6)} md={colSize}>
          {col.map((field, fIndex) => (
            <ViewField key={fIndex} field={field} />
          ))}
        </Grid>
      ))}
    </Grid>
  );
};

ViewColumns.propTypes = {
  areas: PropTypes.array.isRequired
};

/**
 * @function ViewRows
 * @param {object} props
 * @param {object} props.areas - field object to render
 * @returns {React.ReactElement} - returns rendered rows
 */
export const ViewRows = ({ areas }) => {
  return (
    <>
      {
        areas.map((row, rowIndex) => {
          let colSize = 12 / row.length;
          return (
            <Grid key={rowIndex} xs={12} container spacing={2}>
              {row.map((field, fIndex) => {
                if (row.length === 1 && field.singleColumnSize) {
                  colSize = parseInt(field.singleColumnSize) || colSize;
                }
                return (
                  <Grid key={fIndex} xs={Math.max(colSize, 12)} sm={Math.max(colSize, 6)} md={colSize}>
                    <ViewField field={field} />
                  </Grid>
                );
              })}
            </Grid>
          );
        })
      }
    </>
  );
};

ViewRows.propTypes = {
  areas: PropTypes.array.isRequired
};

/**
 * @function FieldValue
 * @param {object} props
 * @param {object} props.field - field object to render
 * @returns {React.ReactElement} - returns rendered field
 * @description Render the value of a field
 */
export const FieldValue = ({field}) => {
  const { options } =  useContext(ViewContext);
  // Default to the LinkValue component if no custom component is passed in
  const LinkComponent = options?.linkComponent || LinkValue;
  const isEmpty = field.empty === field.value;
  const className = `${field.id}-value`;
  if (!isEmpty && (field.type === FIELD_TYPES.LINK || (field.renderAsLinks && field.value))) {
    const links = Array.isArray(field.value) ? field.value : [field.value];
    return (
      <>
        {links.map((link, index) => (
          <LinkComponent key={index} field={field} link={link} index={index} className={className} />
        ))}
      </>
    );
  }
  return <Typography variant="detailItem" className={className}>{field.value}</Typography>;
};

FieldValue.propTypes = {
  field: PropTypes.object.isRequired
};

/**
 * @function LinkValue
 * @param {object} props
 * @param {object} props.field - field object to render
 * @param {object | string} props.link - link object to render
 * @param {string} props.link.id - id of the link
 * @param {string} props.link.label - label of the link
 * @param {string} [props.link.name] - name of the link (fallback if label is not present)
 * @param {number} props.index - index of the link
 * @returns {React.ReactElement} - returns rendered link
 * @description Render the value of a link
 */
export const LinkValue = ({field, link, index}) => {
  const isEmpty = field.empty === field.value;

  if (isEmpty) {
    return <Typography variant="detailItem">{field.empty}</Typography>;
  }

  const isLink = field.type === FIELD_TYPES.LINK;
  const external = isLink && link.startsWith('http');

  const theUrl = isLink ? link : field.linkFormat.replace('{id}', link?.id);
  const label = isLink ? link : link?.label ?? link?.name;

  return (
    <React.Fragment>
      {index > 0 && <Typography variant="detailItemSeparator">,</Typography>}
      <Typography variant="detailItem">
        <Link to={theUrl} target={external ? '_blank' : '_self'}>{label}</Link>
      </Typography>
    </React.Fragment>
  );
};

LinkValue.propTypes = {
  field: PropTypes.object.isRequired,
  link: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
  index: PropTypes.number.isRequired
};


/**
 * @function ViewField
 * @param {object} props
 * @param {object} props.field - field object to render
 * @param {string} props.field.type - type of field to render
 * @param {string} [props.field.label] - text to render
 * @returns {React.ReactElement} - returns rendered field
 */
export const ViewField = ({ field }) => {
  const { dynamicComponents, options } = useContext(ViewContext);

  if (field.static) {
    const baseClass = field.className || '';
    switch (field.type) {
      case STATIC_TYPES.DIVIDER: {
        return <Divider className={baseClass}/>;
      }
      case STATIC_TYPES.COMPONENT: {
        const Component = dynamicComponents[field.component];
        if (!Component) {
          console.warn(`Component ${field.component} not found. Rendering null.`);
          return null;
        }
        return <Component className={baseClass} {...field.componentProps} />;
      }
      case STATIC_TYPES.IMAGE: {
        return <img src={field.src} alt={field.alt} className={baseClass}/>;
      }
      case STATIC_TYPES.TEXT: {
        const props = field.variant ? { variant: field.variant } : {};
        return <Typography className={baseClass} {...props}>{field.text}</Typography>;
      }
      case STATIC_TYPES.HEADER: {
        return <Typography variant={field.variant || 'sectionHeader'} className={'label' + (field.className ? ` ${field.className}` : '')}>{field.text}</Typography>;
      }
      default: {
        return null;
      }
    }
  }

  if (field.type === FIELD_TYPES.CLUSTER) {
    return <ClusterTable field={field} />;
  }

  // Default to the FieldValue component if no custom component is passed in
  const ValueComponent = options?.fieldValueComponent || FieldValue;
  return (
    <div className={`${field.id}-field` + (field.className ? ` ${field.className}` : '')}>
      <Typography variant="detailItem" className={`label ${field.id}-label`}>{field.label}: </Typography>
      <ValueComponent field={field} />
    </div>
  );
};

ViewField.propTypes = {
  field: PropTypes.object.isRequired
};

const StyledTableRow = styled(TableRow)(({ theme }) => ({
  '&:nth-of-type(odd)': {
    backgroundColor: theme.configView.clusterField.alternateRowColor,
  },
  // hide last border
  '&:last-child td, &:last-child th': {
    border: 0,
  },
}));


/**
 * @function ClusterTable
 * @param {object} props
 * @param {object} props.field - field object to render
 * @param {object} props.field.value - value of the field
 * @param {object} props.field.value.headers - headers of the table
 * @param {object} props.field.value.rows - rows of the table
 * @param {object} [props.field.label] - label of the field
 * @returns {React.ReactElement} the cluster table
 */
export const ClusterTable = ({ field }) => {
  // Fun fact you can destructure a string in javascript without a runtime error ...but it will give undefined
  const { headers, rows } = field?.value || {};
  if (!headers || !rows) {
    return null;
  }

  return (
    <>
      {(field.label && field.label !== '') &&
        <Typography variant="sectionHeader" className={`${field.id}-label`}>{field.label}</Typography>
      }
      <TableContainer component={Paper} className={`cluster-table-${field.id}` + (field.className ? ` ${field.className}` : '')}>
        <Table sx={{ minWidth: 650 }} aria-label="simple table">
          <TableHead sx={{ background: (theme) => theme.configView.clusterField.headerColor }}>
            <TableRow>
              {headers.map((header, hI) => {
                return (
                  <TableCell key={hI} align={hI === 0 ? 'left' : 'right'} sx={{
                    color: (theme) => theme.configView.clusterField.headerTextColor,
                    fontWeight: 'bold',
                  }}>{header}</TableCell>
                );
              })}
            </TableRow>
          </TableHead>
          <TableBody>
            {rows.map((row, rI) => (
              <StyledTableRow
                key={`${row.name}-${rI}`}
                sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
              >
                {row.map((cell, cI) => (
                  <React.Fragment key={cI}>
                    {cI === 0 &&
                      <TableCell key={cI} component="th" scope="row">{cell}</TableCell>
                    }
                    {cI !== 0 &&
                      <TableCell key={cI} align="right">{cell}</TableCell>
                    }
                  </React.Fragment>
                ))}
              </StyledTableRow>
            ))}
          </TableBody>
        </Table>
      </TableContainer>
    </>
  );
};

ClusterTable.propTypes = {
  field: PropTypes.object.isRequired
};