efcsydney/efcsydney-roster

View on GitHub
client/src/modules/admin/services/Popup.js

Summary

Maintainability
F
3 days
Test Coverage
/**
 * Example Usage
 *
 * <Popup
 *     data={selectedData}
 *     mode={mode}
 *     isLoading={isLoading}
 *     onSave={this.handlePopupSave}
 *     onClose={this.handlePopupClose}/>
 */
import _ from 'lodash';
import React, { Component } from 'react';
import { PropTypes } from 'prop-types';
import {
  Form,
  FormGroup,
  FormRow,
  LoadingIndicator,
  Modal,
  StateButton,
  Input,
  DraggableItem,
  DragDropZone
} from 'components';
import styled from 'styled-components';
import dotProp, { set } from 'dot-prop-immutable';
import IconMinusCircle from 'react-icons/lib/fa/minus-circle';
import Select from 'react-select';
import IconBar from 'react-icons/lib/fa/bars';
import { withResource } from 'resource';
import 'react-select/dist/react-select.css';

const LANGUAGE_OPTIONS = [
  { value: 'en-AU', label: 'English' },
  { value: 'zh-TW', label: '繁體中文' }
];
const FREQUENCY_OPTIONS = [
  { value: 'Sunday', label: 'Sunday' },
  { value: 'Monday', label: 'Monday' },
  { value: 'Tuesday', label: 'Tuesday' },
  { value: 'Wednesday', label: 'Wednesday' },
  { value: 'Thursday', label: 'Thursday' },
  { value: 'Friday', label: 'Friday' },
  { value: 'Saturday', label: 'Saturday' },
  { value: 'Everyday', label: 'Everyday' }
];

_.mixin({
  move: (array, fromIndex, toIndex) => {
    array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]);
    return array;
  }
});

class Popup extends Component {
  static propTypes = {
    data: PropTypes.object,
    mode: PropTypes.oneOf(['new', 'edit']).isRequired,
    isLoading: PropTypes.bool,
    hasCompleted: PropTypes.bool,
    hasInitialized: PropTypes.bool,
    onClose: PropTypes.func,
    onSave: PropTypes.func
  };
  static defaultProps = {
    data: {},
    isLoading: false,
    hasCompleted: false,
    hasInitialized: false,
    onClose: () => {},
    onSave: () => {}
  };
  constructor(props) {
    super(props);

    const { data } = props;

    if (props.mode === 'new') {
      this.state = { data: { positions: [{ name: '', order: 1 }] } };
    } else {
      this.state = { data };
    }
  }
  componentDidUpdate(prevProps, prevState) {
    const { data: prevData } = prevState;
    const { data, hasInitialized, hasCompleted, mode, onClose } = this.props;
    const isNew = mode === 'new';

    if (!isNew) {
      const isReceivingInitData = hasInitialized && _.isEmpty(prevData);
      if (isReceivingInitData) {
        this.setState({ data });
      }
    }

    if (hasCompleted) {
      setTimeout(onClose, 500);
    }
  }
  handleChange = change => {
    let data = _.clone(this.state.data);

    _.forOwn(change, (change, key) => {
      data = set(data, key, change);
    });

    this.setState({ data });
  };
  handleNameChange = e => {
    const value = _.get(e, 'target.value', '').trim();
    const regExp = /^[a-z-]+$/gm;
    if (!_.isEmpty(value) && _.isNull(value.match(regExp))) {
      return;
    }

    this.handleChange({ name: value });
  };
  handleSwitch = (fromIndex, toIndex) => {
    let positions = _.get(this.state, 'data.positions', []);
    positions = _.move(positions, fromIndex, toIndex);
    positions = positions.map((position, i) => {
      return {
        ...position,
        order: i + 1
      };
    });
    this.handleChange({
      positions
    });
  };
  handlePositionAdd = () => {
    let {
      data,
      data: { positions }
    } = this.state;

    data = set(data, `positions.${positions.length}`, {
      name: '',
      order: positions.length + 1
    });

    this.setState({ data });
  };
  handlePositionDelete = offset => {
    let { data } = this.state;
    data = dotProp.delete(data, `positions.${offset}`);

    this.setState({ data });
  };
  handleSubmit = e => {
    const { onSave } = this.props;
    const {
      data,
      data: { positions }
    } = this.state;

    e.preventDefault();

    onSave({
      ...data,
      positions: _.filter(
        positions,
        position => !_.isEmpty(position.name.trim())
      )
    });
  };
  renderForm() {
    const { data } = this.state;
    const { isSaving, hasCompleted } = this.props;
    const name = _.get(data, 'name', '');
    const locale = _.get(data, 'locale', '');
    const frequency = _.get(data, 'frequency', '');
    const label = _.get(data, 'label', '');
    const footnoteLabel = _.get(data, 'footnoteLabel', '');
    const positions = _.get(data, 'positions', []);
    const { mode } = this.props;
    const isNew = mode === 'new';
    const hasLeastOnePosition = _.some(positions, p => p.name.length > 0);
    const isButtonEnabled =
      name &&
      frequency &&
      locale &&
      label &&
      footnoteLabel &&
      hasLeastOnePosition;
    const buttonKind =
      (isSaving && 'loading') || (hasCompleted && 'success') || 'default';
    const sortedPositions = _.orderBy(positions, 'order', 'asc');

    return (
      <Form onSubmit={this.handleSubmit}>
        <FormGroup label="Service Title" isRequired={true}>
          <StyledInput
            data-hj-whitelist
            type="text"
            value={label}
            maxLength={30}
            placeholder="e.g. English Service 中文堂"
            onChange={e => this.handleChange({ label: e.target.value })}
          />
        </FormGroup>
        <FormGroup
          label="URL Path"
          isRequired={isNew}
          helpText={`English and dash (-) only. Maximum 10 characters. <br/>https://roster.efcsydney.org/#/index/${name ||
            '(path)'}`}>
          {isNew && (
            <StyledInput
              data-hj-whitelist
              value={name}
              maxLength={10}
              placeholder="e.g. english"
              onChange={this.handleNameChange}
            />
          )}
          {!isNew && name}
        </FormGroup>
        <FormGroup label="Frequency" isRequired={isNew}>
          {isNew && (
            <StyledSelect
              value={frequency}
              clearable={false}
              options={FREQUENCY_OPTIONS}
              placeholder="e.g. Sunday"
              onChange={e =>
                this.handleChange({
                  frequency: e.value
                })
              }
            />
          )}
          {!isNew && frequency}
        </FormGroup>
        <FormGroup
          label="Language"
          isRequired={isNew}
          helpText="Preferred language for this service">
          <StyledSelect
            value={locale}
            clearable={false}
            options={LANGUAGE_OPTIONS}
            placeholder="e.g. English (Australia)"
            onChange={e =>
              this.handleChange({
                locale: e.value
              })
            }
          />
        </FormGroup>
        <FormGroup
          label="Description Label"
          isRequired={true}
          helpText="Label for the first header column on the Quarter View">
          <StyledInput
            data-hj-whitelist
            type="text"
            value={footnoteLabel}
            maxLength={30}
            placeholder="e.g. Occassion"
            onChange={e => this.handleChange({ footnoteLabel: e.target.value })}
          />
        </FormGroup>
        <FormGroup
          label="Positions"
          align="top"
          isRequired={true}
          helpText="Drag and drop to re-order positions">
          <DragDropZone>
            <PositionList>
              {sortedPositions.map(({ id, name, order }, i) => (
                <StyledDraggableItem
                  key={id || i}
                  value={order}
                  no={i}
                  onSwitchPosition={(sourceNo, targetNo) =>
                    this.handleSwitch(sourceNo, targetNo)
                  }>
                  <IconDrag />
                  <Input
                    data-hj-whitelist
                    type="text"
                    value={name}
                    onChange={e =>
                      this.handleChange({
                        [`positions.${i}.name`]: e.target.value
                      })
                    }
                  />
                  {!id && (
                    <IconDelete
                      onClick={this.handlePositionDelete.bind(this, i)}
                    />
                  )}
                </StyledDraggableItem>
              ))}
              <PositionItem>
                <AddPositionLink onClick={this.handlePositionAdd}>
                  Add New Position
                </AddPositionLink>
              </PositionItem>
            </PositionList>
          </DragDropZone>
        </FormGroup>
        <FormRow align="center">
          <StateButton
            kind={buttonKind}
            type="submit"
            disabled={!isButtonEnabled}>
            Save
          </StateButton>
        </FormRow>
      </Form>
    );
  }
  renderLoading() {
    return <LoadingIndicator active={true} height="200px" />;
  }
  render() {
    const { isLoading, mode, onClose } = this.props;
    const title = mode === 'new' ? 'Create Service' : 'Edit Service';

    return (
      <Modal isOpen={true} title={title} onClose={onClose}>
        {isLoading ? this.renderLoading() : this.renderForm()}
      </Modal>
    );
  }
}

export default withResource('services', (resource, state, ownProps) => {
  const selectedId = _.get(ownProps, 'data.id', 'creating');
  const data = _.get(resource, ['data', selectedId], {});

  const modifyStatus = _.get(resource, 'status.modify', {});
  const createStatus = _.get(resource, 'status.create', {});
  const isSaving =
    modifyStatus.loadingIds[selectedId] || createStatus.isLoading;
  const hasCompleted =
    modifyStatus.completedIds[selectedId] ||
    !_.isEmpty(createStatus.completedIds);

  const retrieveStatus = _.get(resource, 'status.retrieve', {});
  const hasInitialized = retrieveStatus.hasInitialized && !_.isEmpty(data);

  return {
    data,
    isSaving,
    hasCompleted,
    hasInitialized
  };
})(Popup);

const PositionItem = styled.li`
  align-items: center;
  display: flex;
  margin-bottom: 4px;
  &:last-child {
    margin-bottom: 0;
  }
`;
const PositionList = styled.ol`
  background: #eee;
  border-radius: 4px;
  padding: 5px;
`;
const AddPositionLink = styled.a`
  cursor: pointer;
  display: block;
  font-size: 12px;
  text-align: right;
  width: 100%;
`;
const IconDelete = styled(IconMinusCircle)`
  color: #a00;
  font-size: 20px;
  margin-left: 4px;
`;
const StyledInput = styled(Input)`
  width: 195px;
`;
const StyledSelect = styled(Select)`
  font-size: 13px;
  font-family: system-ui;
  width: 195px;
`;
const IconDrag = styled(IconBar)`
  cursor: move;
  margin-right: 5px;
`;
const StyledDraggableItem = styled(DraggableItem)`
  display: flex;
  align-items: center;
  margin-bottom: 2px;
  &[aria-grabbed='true'] {
    opacity: 0.5;
  }
  &[aria-dropeffect='move'] {
    background: #c1c1c1;
  }
`;