ui/src/modules/automations/components/forms/AutomationForm.tsx

Summary

Maintainability
F
6 days
Test Coverage
import { __, Alert } from 'modules/common/utils';
import { jsPlumb } from 'jsplumb';
import jquery from 'jquery';
import RTG from 'react-transition-group';
import Wrapper from 'modules/layout/components/Wrapper';
import React from 'react';
import Form from 'modules/common/components/form/Form';
import { IAction, IAutomation, ITrigger, IAutomationNote } from '../../types';
import {
  Container,
  CenterFlexRow,
  BackButton,
  Title,
  RightDrawerContainer,
  AutomationFormContainer,
  ScrolledContent,
  BackIcon,
  CenterBar,
  ToggleWrapper,
  ZoomActions,
  ZoomIcon,
  ActionBarButtonsWrapper
} from '../../styles';
import { FormControl } from 'modules/common/components/form';
import { BarItems, HeightedWrapper } from 'modules/layout/styles';
import Button from 'modules/common/components/Button';
import TriggerForm from '../../containers/forms/triggers/TriggerForm';
import ActionsForm from '../../containers/forms/actions/ActionsForm';
import TriggerDetailForm from './triggers/TriggerDetailForm';
import {
  createInitialConnections,
  connection,
  deleteConnection,
  sourceEndpoint,
  targetEndpoint,
  connectorPaintStyle,
  connectorHoverStyle,
  hoverPaintStyle,
  yesEndPoint,
  noEndPoint,
  getTriggerType
} from 'modules/automations/utils';
import ActionDetailForm from './actions/ActionDetailForm';
import Icon from 'modules/common/components/Icon';
import PageContent from 'modules/layout/components/PageContent';
import { Link } from 'react-router-dom';
import { Tabs, TabTitle } from 'modules/common/components/tabs';
import Toggle from 'modules/common/components/Toggle';
import Modal from 'react-bootstrap/Modal';
import NoteFormContainer from 'modules/automations/containers/forms/NoteForm';
import TemplateForm from '../../containers/forms/TemplateForm';
import Histories from 'modules/automations/components/histories/Wrapper';
import Confirmation from 'modules/automations/containers/forms/Confirmation';
import { TRIGGER_TYPES } from 'modules/automations/constants';

const plumb: any = jsPlumb;
let instance;

type Props = {
  automation: IAutomation;
  automationNotes?: IAutomationNote[];
  save: (params: any) => void;
  saveLoading: boolean;
  id: string;
  history: any;
  queryParams: any;
};

type State = {
  name: string;
  currentTab: string;
  activeId: string;
  showDrawer: boolean;
  showTrigger: boolean;
  showAction: boolean;
  isActionTab: boolean;
  isActive: boolean;
  showNoteForm: boolean;
  editNoteForm?: boolean;
  showTemplateForm: boolean;
  actions: IAction[];
  triggers: ITrigger[];
  activeTrigger: ITrigger;
  activeAction: IAction;
  selectedContentId?: string;
  isZoomable: boolean;
  zoomStep: number;
  zoom: number;
  percentage: number;
  automationNotes: IAutomationNote[];
};

class AutomationForm extends React.Component<Props, State> {
  private wrapperRef;
  private setZoom;

  constructor(props) {
    super(props);

    const { automation, automationNotes = [] } = this.props;

    this.state = {
      name: automation.name,
      actions: JSON.parse(JSON.stringify(automation.actions || [])),
      triggers: JSON.parse(JSON.stringify(automation.triggers || [])),
      activeTrigger: {} as ITrigger,
      activeId: '',
      currentTab: 'triggers',
      isActionTab: true,
      isActive: automation.status === 'active',
      showNoteForm: false,
      showTemplateForm: false,
      showTrigger: false,
      showDrawer: false,
      showAction: false,
      isZoomable: false,
      zoomStep: 0.025,
      zoom: 1,
      percentage: 100,
      activeAction: {} as IAction,
      automationNotes
    };
  }

  setWrapperRef = node => {
    this.wrapperRef = node;
  };

  componentDidMount() {
    this.connectInstance();

    document.addEventListener('click', this.handleClickOutside, true);
  }

  componentDidUpdate(prevProps, prevState) {
    const { isActionTab } = this.state;

    if (isActionTab && isActionTab !== prevState.isActionTab) {
      this.connectInstance();
    }

    this.setZoom = (zoom, instanceZoom, transformOrigin, el) => {
      transformOrigin = transformOrigin || [0.5, 0.5];
      instanceZoom = instanceZoom || jsPlumb;
      el = el || instanceZoom.getContainer();

      const p = ['webkit', 'moz', 'ms', 'o'];
      const s = 'scale(' + zoom + ')';
      const oString =
        transformOrigin[0] * 100 + '% ' + transformOrigin[1] * 100 + '%';

      // tslint:disable-next-line:prefer-for-of
      for (let i = 0; i < p.length; i++) {
        el.style[p[i] + 'Transform'] = s;
        el.style[p[i] + 'TransformOrigin'] = oString;
      }

      el.style.transform = s;
      el.style.transformOrigin = oString;

      instanceZoom.setZoom(zoom);
    };

    if (
      (prevProps.automationNotes || []).length !==
      (this.props.automationNotes || []).length
    ) {
      this.setState({ automationNotes: this.props.automationNotes || [] });
    }
  }

  componentWillUnmount() {
    document.removeEventListener('click', this.handleClickOutside, true);
  }

  connectInstance = () => {
    instance = plumb.getInstance({
      DragOptions: { cursor: 'pointer', zIndex: 2000 },
      PaintStyle: connectorPaintStyle,
      HoverPaintStyle: connectorHoverStyle,
      EndpointStyle: { radius: 10 },
      EndpointHoverStyle: hoverPaintStyle,
      Container: 'canvas'
    });

    const { triggers, actions } = this.state;

    instance.bind('ready', () => {
      instance.bind('connection', info => {
        this.onConnection(info);
      });

      instance.bind('connectionDetached', info => {
        this.onDettachConnection(info);
      });

      for (const action of actions) {
        this.renderControl('action', action, this.onClickAction);
      }

      for (const trigger of triggers) {
        this.renderControl('trigger', trigger, this.onClickTrigger);
      }

      // create connections ===================
      createInitialConnections(triggers, actions, instance);

      // delete connections ===================
      deleteConnection(instance);
    });

    // hover action control ===================
    jquery('#canvas .control').hover(event => {
      event.preventDefault();

      jquery(`div#${event.currentTarget.id}`).toggleClass('show-action-menu');

      this.setState({ activeId: event.currentTarget.id });
    });

    // delete control ===================
    jquery('#canvas').on('click', '.delete-control', event => {
      event.preventDefault();

      const item = event.currentTarget.id;
      const splitItem = item.split('-');
      const type = splitItem[0];

      instance.remove(item);

      if (type === 'action') {
        return this.setState({
          actions: actions.filter(action => action.id !== splitItem[1])
        });
      }

      if (type === 'trigger') {
        return this.setState({
          triggers: triggers.filter(trigger => trigger.id !== splitItem[1])
        });
      }
    });

    // add note ===================
    jquery('#canvas').on('click', '.add-note', event => {
      event.preventDefault();

      this.handleNoteModal();
    });
  };

  handleSubmit = () => {
    const { name, isActive, triggers, actions } = this.state;
    const { automation, save } = this.props;

    if (!name || name === 'Your automation title') {
      return Alert.error('Enter an Automation title');
    }

    const generateValues = () => {
      const finalValues = {
        _id: automation._id,
        name,
        status: isActive ? 'active' : 'draft',
        triggers: triggers.map(t => ({
          id: t.id,
          type: t.type,
          config: t.config,
          icon: t.icon,
          label: t.label,
          description: t.description,
          actionId: t.actionId,
          style: jquery(`#trigger-${t.id}`).attr('style')
        })),
        actions: actions.map(a => ({
          id: a.id,
          type: a.type,
          nextActionId: a.nextActionId,
          config: a.config,
          icon: a.icon,
          label: a.label,
          description: a.description,
          style: jquery(`#action-${a.id}`).attr('style')
        }))
      };

      return finalValues;
    };

    return save(generateValues());
  };

  handleNoteModal = (item?) => {
    this.setState({
      showNoteForm: !this.state.showNoteForm,
      editNoteForm: item ? true : false
    });
  };

  handleTemplateModal = () => {
    this.setState({ showTemplateForm: !this.state.showTemplateForm });
  };

  switchActionbarTab = type => {
    this.setState({ isActionTab: type === 'action' ? true : false });
  };

  onToggle = e => {
    const isActive = e.target.checked;

    this.setState({ isActive });

    const { save, automation } = this.props;

    if (automation) {
      save({ _id: automation._id, status: isActive ? 'active' : 'draft' });
    }
  };

  onAddActionConfig = config => {
    const { activeAction } = this.state;

    activeAction.config = config;
    this.setState({ activeAction });
  };

  doZoom = (step: number, inRange: boolean) => {
    const { isZoomable, zoom } = this.state;

    if (inRange) {
      this.setState({ zoom: zoom + step });
      this.setZoom(zoom, jsPlumb, null, jquery('#canvas')[0]);

      if (isZoomable) {
        this.setState({ zoom: zoom + step });
        setTimeout(() => this.doZoom(step, inRange), 100);
      }
    }
  };

  onZoom = (type: string) => {
    const { zoomStep, zoom, percentage } = this.state;

    this.setState({ isZoomable: true }, () => {
      let step = 0 - zoomStep;
      const max = zoom <= 1;
      const min = zoom >= 0.399;

      if (type === 'zoomIn') {
        step = +zoomStep;

        this.doZoom(step, max);
        this.setState({ percentage: max ? percentage + 10 : 100 });
      }

      if (type === 'zoomOut') {
        this.doZoom(step, min);
        this.setState({ percentage: min ? percentage - 10 : 0 });
      }
    });
  };

  onClickTrigger = (trigger: ITrigger) => {
    const config = trigger && trigger.config;
    const selectedContentId = config && config.contentId;

    this.setState({
      showTrigger: true,
      showDrawer: true,
      showAction: false,
      currentTab: 'triggers',
      selectedContentId,
      activeTrigger: trigger ? trigger : ({} as ITrigger)
    });
  };

  onClickAction = (action: IAction) => {
    this.setState({
      showAction: true,
      showDrawer: true,
      showTrigger: false,
      currentTab: 'actions',
      activeAction: action ? action : ({} as IAction)
    });
  };

  onConnection = info => {
    const { triggers, actions } = this.state;

    connection(triggers, actions, info, info.targetId.replace('action-', ''));

    this.setState({ triggers, actions });
  };

  onDettachConnection = info => {
    const { triggers, actions } = this.state;

    connection(triggers, actions, info, undefined);

    this.setState({ triggers, actions });
  };

  handleClickOutside = event => {
    if (
      this.wrapperRef &&
      !this.wrapperRef.contains(event.target) &&
      this.state.isActionTab
    ) {
      this.setState({ showDrawer: false });
    }
  };

  toggleDrawer = (type: string) => {
    this.setState({ showDrawer: !this.state.showDrawer, currentTab: type });
  };

  getNewId = (checkIds: string[]) => {
    let newId = Math.random()
      .toString(36)
      .slice(-8);

    if (checkIds.includes(newId)) {
      newId = this.getNewId(checkIds);
    }

    return newId;
  };

  addTrigger = (data: ITrigger, triggerId?: string, config?: any) => {
    const { triggers, activeTrigger } = this.state;

    let trigger: any = {
      ...data,
      id: this.getNewId(triggers.map(t => t.id))
    };
    const triggerIndex = triggers.findIndex(t => t.id === triggerId);

    if (triggerId && activeTrigger.id === triggerId) {
      trigger = activeTrigger;
    }

    trigger.config = { ...trigger.config, ...config };

    if (triggerIndex !== -1) {
      triggers[triggerIndex] = trigger;
    } else {
      triggers.push(trigger);
    }

    this.setState({ triggers, activeTrigger: trigger }, () => {
      if (!triggerId) {
        this.renderControl('trigger', trigger, this.onClickTrigger);
      }
    });
  };

  addAction = (data: IAction, actionId?: string, config?: any) => {
    const { actions } = this.state;

    let action: any = { ...data, id: this.getNewId(actions.map(a => a.id)) };

    let actionIndex = -1;

    if (actionId) {
      actionIndex = actions.findIndex(a => a.id === actionId);

      if (actionIndex !== -1) {
        action = actions[actionIndex];
      }
    }

    action.config = { ...action.config, ...config };

    if (actionIndex !== -1) {
      actions[actionIndex] = action;
    } else {
      actions.push(action);
    }

    this.setState({ actions, activeAction: action }, () => {
      if (!actionId) {
        this.renderControl('action', action, this.onClickAction);
      }
    });
  };

  onNameChange = (e: React.FormEvent<HTMLElement>) => {
    const value = (e.currentTarget as HTMLButtonElement).value;
    this.setState({ name: value });
  };

  onClickNote = activeId => {
    this.setState({ activeId }, () => {
      this.handleNoteModal(activeId);
    });
  };

  checkNote = (activeId: string) => {
    const item = activeId.split('-');
    const type = item[0];

    return (this.state.automationNotes || []).filter(note => {
      if (type === 'trigger' && note.triggerId !== item[1]) {
        return null;
      }

      if (type === 'action' && note.actionId !== item[1]) {
        return null;
      }

      return note;
    });
  };

  renderNotes(key: string) {
    const noteCount = (this.checkNote(key) || []).length;

    if (noteCount === 0) {
      return ``;
    }

    return `
      <div class="note-badge note-badge-${key}" title=${__(
      'Notes'
    )} id="${key}">
        <i class="icon-notes"></i>
      </div>
    `;
  }

  renderCount(item: ITrigger | IAction) {
    if (item.count && TRIGGER_TYPES.includes(item.type)) {
      return `(${item.count})`;
    }

    return '';
  }

  renderControl = (key: string, item: ITrigger | IAction, onClick: any) => {
    const idElm = `${key}-${item.id}`;

    jquery('#canvas').append(`
      <div class="${key} control" id="${idElm}" style="${item.style}">
        <div class="trigger-header">
          <div class='custom-menu'>
            <div>
              <i class="icon-notes add-note" title=${__('Write Note')}></i>
              <i class="icon-trash-alt delete-control" id="${idElm}" title=${__(
      'Delete control'
    )}></i>
            </div>
          </div>
          <div>
            <i class="icon-${item.icon}"></i>
            ${item.label} ${this.renderCount(item)}
          </div>
        </div>
        <p>${item.description}</p>
        ${this.renderNotes(idElm)}

      </div>
    `);

    jquery('#canvas').on('dblclick', `#${idElm}`, event => {
      event.preventDefault();

      onClick(item);
    });

    jquery('#canvas').on('click', `.note-badge-${idElm}`, event => {
      event.preventDefault();

      this.onClickNote(event.currentTarget.id);
    });

    if (key === 'trigger') {
      instance.addEndpoint(idElm, sourceEndpoint, {
        anchor: [1, 0.5]
      });

      if (instance.getSelector(`#${idElm}`).length > 0) {
        instance.draggable(instance.getSelector(`#${idElm}`));
      }
    }

    if (key === 'action') {
      if (item.type === 'if') {
        instance.addEndpoint(idElm, targetEndpoint, {
          anchor: ['Left']
        });

        instance.addEndpoint(idElm, yesEndPoint);
        instance.addEndpoint(idElm, noEndPoint);
      } else {
        instance.addEndpoint(idElm, targetEndpoint, {
          anchor: ['Left']
        });

        instance.addEndpoint(idElm, sourceEndpoint, {
          anchor: ['Right']
        });
      }

      instance.draggable(instance.getSelector(`#${idElm}`));
    }
  };

  renderButtons() {
    if (!this.state.isActionTab) {
      return null;
    }

    return (
      <>
        <Button
          btnStyle="primary"
          size="small"
          icon="plus-circle"
          onClick={this.toggleDrawer.bind(this, 'triggers')}
        >
          Add a Trigger
        </Button>
        <Button
          btnStyle="primary"
          size="small"
          icon="plus-circle"
          onClick={this.toggleDrawer.bind(this, 'actions')}
        >
          Add an Action
        </Button>
      </>
    );
  }

  rendeRightActionBar() {
    const { isActive } = this.state;

    return (
      <BarItems>
        <ToggleWrapper>
          <span className={isActive ? 'active' : ''}>{__('Inactive')}</span>
          <Toggle defaultChecked={isActive} onChange={this.onToggle} />
          <span className={!isActive ? 'active' : ''}>{__('Active')}</span>
        </ToggleWrapper>
        <ActionBarButtonsWrapper>
          {this.renderButtons()}
          {
            <Button
              btnStyle="primary"
              size="small"
              icon={'check-circle'}
              onClick={this.handleTemplateModal}
            >
              Save as a template
            </Button>
          }
          <Button
            btnStyle="success"
            size="small"
            icon={'check-circle'}
            onClick={this.handleSubmit}
          >
            {__('Save')}
          </Button>
        </ActionBarButtonsWrapper>
      </BarItems>
    );
  }

  renderLeftActionBar() {
    const { isActionTab, name } = this.state;

    return (
      <CenterFlexRow>
        <Link to={`/automations`}>
          <BackButton>
            <Icon icon="angle-left" size={20} />
          </BackButton>
        </Link>
        <Title>
          <FormControl
            name="name"
            value={name}
            onChange={this.onNameChange}
            required={true}
            autoFocus={true}
          />
          <Icon icon="edit-alt" size={16} />
        </Title>
        <CenterBar>
          <Tabs full={true}>
            <TabTitle
              className={isActionTab ? 'active' : ''}
              onClick={this.switchActionbarTab.bind(this, 'action')}
            >
              {__('Actions')}
            </TabTitle>
            <TabTitle
              className={isActionTab ? '' : 'active'}
              onClick={this.switchActionbarTab.bind(this, 'history')}
            >
              {__('Histories')}
            </TabTitle>
          </Tabs>
        </CenterBar>
      </CenterFlexRow>
    );
  }

  renderTabContent() {
    const {
      currentTab,
      showTrigger,
      showAction,
      activeTrigger,
      activeAction,
      selectedContentId
    } = this.state;

    const onBack = () => this.setState({ showTrigger: false });
    const onBackAction = () => this.setState({ showAction: false });

    if (currentTab === 'triggers') {
      if (showTrigger && activeTrigger) {
        return (
          <>
            <BackIcon onClick={onBack}>
              <Icon icon="angle-left" size={20} /> {__('Back to triggers')}
            </BackIcon>
            <ScrolledContent>
              <TriggerDetailForm
                activeTrigger={activeTrigger}
                addConfig={this.addTrigger}
                closeModal={onBack}
                contentId={selectedContentId}
              />
            </ScrolledContent>
          </>
        );
      }

      return <TriggerForm onClickTrigger={this.onClickTrigger} />;
    }

    if (currentTab === 'actions') {
      const { actions, triggers } = this.state;

      if (showAction && activeAction) {
        return (
          <>
            <BackIcon onClick={onBackAction}>
              <Icon icon="angle-left" size={20} /> {__('Back to actions')}
            </BackIcon>
            <ActionDetailForm
              activeAction={activeAction}
              addAction={this.addAction}
              closeModal={onBackAction}
              triggerType={getTriggerType(actions, triggers, activeAction.id)}
            />
          </>
        );
      }

      return <ActionsForm onClickAction={this.onClickAction} />;
    }

    return null;
  }

  renderZoomActions() {
    return (
      <ZoomActions>
        <div className="icon-wrapper">
          <ZoomIcon
            disabled={this.state.zoom >= 1}
            onMouseDown={this.onZoom.bind(this, 'zoomIn')}
            onMouseUp={() => this.setState({ isZoomable: false })}
          >
            <Icon icon="plus" />
          </ZoomIcon>
          <ZoomIcon
            disabled={this.state.zoom <= 0.399}
            onMouseDown={this.onZoom.bind(this, 'zoomOut')}
            onMouseUp={() => this.setState({ isZoomable: false })}
          >
            <Icon icon="minus" />{' '}
          </ZoomIcon>
        </div>
        <span>{`${this.state.percentage}%`}</span>
      </ZoomActions>
    );
  }

  renderContent() {
    const { triggers, actions } = this.state;

    if (triggers.length === 0 && actions.length === 0) {
      return (
        <Container>
          <div
            className="trigger scratch"
            onClick={this.toggleDrawer.bind(this, 'triggers')}
          >
            <Icon icon="file-plus" size={25} />
            <p>{__('How do you want to trigger this automation')}?</p>
          </div>
        </Container>
      );
    }

    const { automation } = this.props;

    if (!this.state.isActionTab) {
      if (!automation) {
        return <div />;
      }

      return <Histories automation={automation} />;
    }

    return (
      <Container>
        {this.renderZoomActions()}
        <div id="canvas" />
      </Container>
    );
  }

  renderConfirmation() {
    const { id, queryParams, history, saveLoading, automation } = this.props;
    const { triggers, actions, name } = this.state;

    if (saveLoading) {
      return null;
    }

    const when = queryParams.isCreate
      ? !!id
      : JSON.stringify(triggers) !==
          JSON.stringify(automation.triggers || []) ||
        JSON.stringify(actions) !== JSON.stringify(automation.actions || []) ||
        automation.name !== this.state.name;

    return (
      <Confirmation
        when={when}
        id={id}
        name={name}
        save={this.handleSubmit}
        history={history}
        queryParams={queryParams}
      />
    );
  }

  renderNoteModal() {
    const { showNoteForm, editNoteForm, activeId } = this.state;

    if (!showNoteForm) {
      return null;
    }

    const { automation } = this.props;

    return (
      <Modal
        enforceFocus={false}
        show={showNoteForm}
        onHide={this.handleNoteModal}
        animation={false}
      >
        <Modal.Body>
          <Form
            renderContent={formProps => (
              <NoteFormContainer
                formProps={formProps}
                automationId={automation ? automation._id : ''}
                isEdit={editNoteForm}
                itemId={activeId}
                notes={this.checkNote(activeId) || []}
                closeModal={this.handleNoteModal}
              />
            )}
          />
        </Modal.Body>
      </Modal>
    );
  }

  renderTemplateModal() {
    const { showTemplateForm } = this.state;
    const { automation } = this.props;

    if (!showTemplateForm || !automation) {
      return null;
    }

    return (
      <Modal
        enforceFocus={false}
        show={showTemplateForm}
        onHide={this.handleTemplateModal}
        animation={false}
      >
        <Modal.Body>
          <Form
            renderContent={formProps => (
              <TemplateForm
                formProps={formProps}
                closeModal={this.handleTemplateModal}
                id={automation._id}
                name={automation.name}
              />
            )}
          />
        </Modal.Body>
      </Modal>
    );
  }

  render() {
    const { automation } = this.props;

    return (
      <>
        {this.renderConfirmation()}
        <HeightedWrapper>
          <AutomationFormContainer>
            <Wrapper.Header
              title={`${(automation && automation.name) || 'Automation'}`}
              breadcrumb={[
                { title: __('Automations'), link: '/automations' },
                { title: `${(automation && automation.name) || ''}` }
              ]}
            />
            <PageContent
              actionBar={
                <Wrapper.ActionBar
                  left={this.renderLeftActionBar()}
                  right={this.rendeRightActionBar()}
                />
              }
              transparent={false}
            >
              {this.renderContent()}
            </PageContent>
          </AutomationFormContainer>

          <div ref={this.setWrapperRef}>
            <RTG.CSSTransition
              in={this.state.showDrawer}
              timeout={300}
              classNames="slide-in-right"
              unmountOnExit={true}
            >
              <RightDrawerContainer>
                {this.renderTabContent()}
              </RightDrawerContainer>
            </RTG.CSSTransition>
          </div>

          {this.renderNoteModal()}
          {this.renderTemplateModal()}
        </HeightedWrapper>
      </>
    );
  }
}

export default AutomationForm;