SU-SWS/react_paragraphs

View on GitHub
js/src/Contexts/WidgetManager.js

Summary

Maintainability
A
0 mins
Test Coverage
import React, {Component} from "react";
import {v4 as uuidv4} from 'uuid';
import {UrlFix} from "../utils/UrlFix";

export const WidgetContext = React.createContext({});

export class WidgetManager extends Component {

  functions = {
    removeRowItem: this.removeRowItem.bind(this),
    onBeforeCapture: this.onBeforeCapture.bind(this),
    onDragEnd: this.onDragEnd.bind(this),
    addRow: this.addRow.bind(this),
    removeRow: this.removeRow.bind(this),
    setItemWidth: this.setItemWidth.bind(this),
    onAdminTitleChange: this.onAdminTitleChange.bind(this),
    addToolToBottom: this.addToolToBottom.bind(this),
    onDragStart: this.onDragStart.bind(this),
    getToolInformation: this.getToolInformation.bind(this),
    getEntityForm: this.getEntityForm.bind(this),
    updateRowEntity: this.updateRowEntity.bind(this),
    updateRowItemEntity: this.updateRowItemEntity.bind(this),
    updateEntityBehaviors: this.updateEntityBehaviors.bind(this),
    loadRow: this.loadRow.bind(this),
    loadRowItem: this.loadRowItem.bind(this)
  };

  apiUrls = {
    formApi: UrlFix('/api/react-paragraphs/{entity_type_id}/{bundle}'),
    entityApi: UrlFix('/entity/{entity_type_id}/{entity_id}'),
  };

  constructor(props) {
    super(props);

    // If the form was submitted and rebuilt, we will want to use the existing
    // form data to re-build the UI.
    const savedCookie = this.getCookie(this.props.fieldId);
    const originalItems = savedCookie.length >= 1 ? savedCookie : this.props.items;

    let rows = {};
    let rowOrder = [];

    originalItems.map((row, rowIndex) => {
      const rowId = 'row-' + rowIndex;
      rowOrder[rowIndex] = rowId;
      rows[rowId] = {
        id: rowId,
        items: {},
        itemsOrder: [],
        isDropDisabled: true,
        entity: row.row.entity,
        target_id: row.row.target_id,
        loadedEntity: false,
      }

      row.rowItems.map((rowItem, itemIndex) => {
        const itemId = 'item-' + rowItem.target_id;
        rows[rowId].itemsOrder[itemIndex] = itemId;
        rows[rowId].items[itemId] = {
          id: itemId,
          target_id: rowItem.target_id,
          index: itemIndex,
          width: rowItem.settings.width,
          admin_title: rowItem.settings.admin_title,
          entity: rowItem.entity,
          loadedEntity: false,
        };
      })
    })

    if (rowOrder.length === 0) {
      rowOrder.push('row-0');
      rows['row-0'] = {
        id: 'row-0',
        items: {},
        itemsOrder: [],
        isDropDisabled: true,
        entity: {"type": [{"target_id": this.props.rowBundle}]},
        target_id: null
      }
    }

    this.state = {
      rowCount: rowOrder.length,
      rows: rows,
      rowOrder: rowOrder.filter(rowId => rowId != null),
      loadedItems: 0,
      cachedForms: {}
    };
    this.componentDidUpdate();
  }

  getCookie() {
    const formItemsField = document.getElementById(this.props.inputId);
    const cname = formItemsField.getAttribute('data-cookie-id');

    var name = cname + "=";
    var decodedCookie = decodeURIComponent(document.cookie);
    var ca = decodedCookie.split(';');
    for(var i = 0; i < ca.length; i++) {
      var c = ca[i];
      while (c.charAt(0) == ' ') {
        c = c.substring(1);
      }
      if (c.indexOf(name) == 0) {
        return JSON.parse(c.substring(name.length, c.length));
      }
    }
    return "";
  }

  /**
   * Trigger form updated event for drupal confirm leave module.
   *
   * We trigger this at various steps to make this more specific about when to
   * trigger the update. We don't want to do this in componentDidUpdate because
   * if the user just loads the page we don't need to trigger the event until
   * actual changes have been made.
   */
  triggerFormUpdated() {
    const formItemsField = document.getElementById(this.props.inputId);
    jQuery(formItemsField).closest('.form-item').trigger('formUpdated');
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    const formItemsField = document.getElementById(this.props.inputId);
    if (formItemsField) {
      const returnValue = {
        cookieId: formItemsField.getAttribute('data-cookie-id'),
        rows: {...this.state.rows},
        rowOrder: [...this.state.rowOrder],
      };
      formItemsField.value = encodeURIComponent(JSON.stringify(returnValue));
    }
  }

  /**
   * Get the object related tot he give tool.
   *
   * @param toolId
   *   Tool machine name.
   *
   * @returns {*}
   */
  getToolInformation(toolId) {
    return this.props.tools.find(tool => tool.id === toolId);
  }

  /**
   * After the user changes the admin label on an item, save it in state.
   *
   * @param itemId
   *   Item identifier or uuid.
   * @param title
   *   New admin label.
   */
  onAdminTitleChange(itemId, title) {
    const newState = {...this.state};
    newState.rowOrder.map(rowId => {
      if (typeof newState.rows[rowId].items[itemId] !== 'undefined') {
        newState.rows[rowId].items[itemId].admin_title = title;
      }
    });
    this.setState(newState);
    this.triggerFormUpdated();
  }

  /**
   * Remove the given item from a row.
   *
   * @param itemId
   *   Row identifier.
   */
  removeRowItem(itemId) {
    const rowId = this.state.rowOrder.find(rowId => this.state.rows[rowId].itemsOrder.includes(itemId));
    const newState = {...this.state};

    delete newState.rows[rowId].items[itemId];
    newState.rows[rowId].itemsOrder.splice(newState.rows[rowId].itemsOrder.indexOf(itemId), 1);
    this.resetRowItemWidths(rowId, newState);
    this.setState(newState);
    this.triggerFormUpdated();
  }

  /**
   * Add the given tool to the bottom with an empty row.
   *
   * @param item_name
   *   Tool machine name.
   * @param e
   *   Event.
   */
  addToolToBottom(item_name, e) {
    const rowOrder = this.state.rowOrder.filter(item => item != null);

    let lastRow = rowOrder[rowOrder.length - 1];

    if (this.state.rows[lastRow].itemsOrder.length > 0) {
      this.addRow(() => {
        const simulated_drag = {
          draggableId: item_name,
          destination: {
            index: 0,
            droppableId: this.state.rowOrder[this.state.rowOrder.length - 1]
          }
        };

        this.moveNewItemIntoRow(simulated_drag);
      });
    }
    else {
      const simulated_drag = {
        draggableId: item_name,
        destination: {
          index: 0,
          droppableId: this.state.rowOrder[this.state.rowOrder.length - 1]
        }
      };

      this.moveNewItemIntoRow(simulated_drag);
    }
    this.triggerFormUpdated();
  };

  /**
   * Add a new row to the bottom.
   *
   * @param callback
   *   Callable function for after the state updates.
   */
  addRow(callback) {
    const newState = {...this.state};
    newState.rowCount++;
    const newRowId = 'row-' + (newState.rowCount);
    newState.rowOrder.push(newRowId);

    newState.rows[newRowId] = {
      id: newRowId,
      items: {},
      itemsOrder: [],
      isDropDisabled: true,
      entity: {"type": [{"target_id": this.props.rowBundle}]},
      target_id: null
    };
    if (typeof callback === 'function') {
      this.setState(newState, callback);
    }
    else {
      this.setState(newState);
    }
    this.triggerFormUpdated();
  }

  /**
   * Remove an entire row.
   *
   * @param rowId
   *   Item identifier or uuid.
   */
  removeRow(rowId) {
    const newState = {...this.state};
    delete newState.rows[rowId];
    newState.rowOrder.splice(newState.rowOrder.findIndex(k => k === rowId), 1);

    if (newState.rowOrder.length === 0) {
      newState.rowOrder.push('row-' + newState.rowCount);
      newState.rows['row-' + newState.rowCount] = {
        id: 'row-' + newState.rowCount,
        items: {},
        itemsOrder: [],
        isDropDisabled: true
      }
    }

    this.setState(newState);
    this.triggerFormUpdated();
  }

  /**
   * Before dragging an tool, create a new row if the last one is full.
   *
   * @link https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/responders.md#ondragstart
   */
  onBeforeCapture(item) {
    // When dragging rows, we don't want to add another row. Only when dragging
    // items around.
    if (item.draggableId.indexOf('row') === 0) {
      return;
    }
    const lastRowId = this.state.rowOrder[this.state.rowOrder.length - 1];
    let needsNewRow = false;

    // The last row is maxed out with items, a new row is needed.
    if (this.state.rows[lastRowId].itemsOrder.length > 0) {
      needsNewRow = true;
    }
    else {
      try {
        // The last row is not full of items, but the items in the row require
        // all columns for that row. We need a new row.
        needsNewRow = !this.canDropInRow(item.draggableId, lastRowId, null);
      }
      catch (e) {
        // Nothing to do here.
      }
    }

    if (needsNewRow) {
      this.addRow();
    }
  }

  /**
   * When the drag is initiated.
   *
   * @param dragItem
   *   The object info of the item being dragged.
   *
   * @link https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/responders.md#ondragstart
   */
  onDragStart(dragItem) {

    // We don't need to do anything here if the user is dragging a row.
    if (dragItem.type !== 'item') {
      return;
    }

    // Find the tool machine name if the dragging item is a tool or an existing
    // item.
    let itemBundle = dragItem.draggableId;
    if (dragItem.source.droppableId !== 'toolbox') {
      const item = this.state.rows[dragItem.source.droppableId].items[itemBundle];
      itemBundle = item.entity.type[0].target_id;
    }

    // Go through all the rows and mark them as disabled if they are full.
    const newState = {...this.state};
    this.state.rowOrder.map(rowId => {
      newState.rows[rowId].isDropDisabled = !this.canDropInRow(itemBundle, rowId, dragItem.source.droppableId);
    });
    this.setState(newState);
  }

  /**
   * Find out if the given tool can be dropped in the destination row.
   *
   * @param itemBundle
   * @param destinationRow
   * @param sourceRow
   * @returns {boolean}
   */
  canDropInRow(itemBundle, destinationRow, sourceRow) {
    // Dragging within the same row is always allowed.
    if (destinationRow === sourceRow) {
      return true;
    }
    // The destination row is already full of items.
    if (this.state.rows[destinationRow].itemsOrder.length >= this.props.maxItemsPerRow) {
      return false;
    }

    // Find out how many columns are required as minimums of the existing items.
    // If the minimum required columns is full, mark the row as full.
    const requiredColumns = this.getToolInformation(itemBundle).minWidth;
    let columnsTaken = 0;
    this.state.rows[destinationRow].itemsOrder.map(itemId => {
      const rowItemBundle = this.state.rows[destinationRow].items[itemId].entity.type[0].target_id;

      columnsTaken += this.getRequiredMinColumns(rowItemBundle);
    });
    return (12 - columnsTaken) >= requiredColumns;
  }

  /**
   * Get the number of columns required for the current tool.
   *
   * @param toolId
   * @returns {number}
   */
  getRequiredMinColumns(toolId) {
    return parseInt(this.getToolInformation(toolId).minWidth);
  }

  /**
   * When an item or row if finished dragging, rebuild the state.
   *
   * @param result
   */
  onDragEnd(result) {
    // The item wasn't dragged anywhere, bail.
    if (!result.destination) {
      return;
    }

    // When an item was dragged from the toolbox, create a new item and add it.
    if (result.source.droppableId === 'toolbox') {
      return this.moveNewItemIntoRow(result);
    }

    // When a item was dragged in the same row, or a row was dragged to reorder.
    if (result.source.droppableId === result.destination.droppableId) {
      if (result.type === 'item') {
        return this.moveItemWithinRow(result);
      }
      return this.moveRow(result);
    }

    // An item was dragged into a new row.
    this.moveItemToNewRow(result);
  }

  /**
   * Rows were sorted.
   *
   * @param result
   */
  moveRow(result) {
    const rowOrder = [...this.state.rowOrder];
    rowOrder.splice(result.source.index, 1);
    rowOrder.splice(result.destination.index, 0, result.draggableId);
    this.setState({rowOrder: rowOrder});
  }

  /**
   * An item was moved within its own row.
   *
   * @param result
   */
  moveItemWithinRow(result) {
    const newState = {...this.state};
    const itemsOrder = newState.rows[result.source.droppableId].itemsOrder;
    itemsOrder.splice(result.source.index, 1);
    itemsOrder.splice(result.destination.index, 0, result.draggableId);
    this.setState(newState);
  }

  // todo: Improve these two methods!
  /**
   * An item was moved into a new row.
   *
   * @param result
   */
  moveItemToNewRow(result) {
    const newState = {...this.state};
    newState.rows[result.source.droppableId].itemsOrder.splice(result.source.index, 1);
    newState.rows[result.destination.droppableId].itemsOrder.splice(result.destination.index, 0, result.draggableId);

    newState.rows[result.destination.droppableId].items[result.draggableId] = newState.rows[result.source.droppableId].items[result.draggableId];
    delete newState.rows[result.source.droppableId].items[result.draggableId];

    this.resetRowItemWidths(result.destination.droppableId, newState);
    this.resetRowItemWidths(result.source.droppableId, newState);
    this.setState(newState);
  }

  // todo: Improve these two methods!
  /**
   * An item from the toolbox was dragged into a row.
   *
   * @param result
   */
  moveNewItemIntoRow(result) {
    const newItem = this.createNewRowItem(result.draggableId, result.destination.index, 12);

    const newState = {...this.state};
    newState.rows[result.destination.droppableId].itemsOrder.splice(newItem.index, 0, newItem.id);
    newState.rows[result.destination.droppableId].items[newItem.id] = newItem;

    this.resetRowItemWidths(result.destination.droppableId, newState);
    // After settings the state, the children will re-render. We can then
    // remove the indicator that the item is new.
    this.setState(newState, () => {
      const newState = {...this.state};
      delete newState.rows[result.destination.droppableId].items[newItem.id].isNew;
      this.setState(newState);
    });
  }

  resetRowItemWidths(rowId, state) {
    const equalWidths = 12 / state.rows[rowId].itemsOrder.length;

    let totalColumns = 0;
    state.rows[rowId].itemsOrder.map(itemId => {
      const rowItemBundle = state.rows[rowId].items[itemId].entity.type[0].target_id;
      state.rows[rowId].items[itemId].width = Math.max(equalWidths, this.getRequiredMinColumns(rowItemBundle));
      totalColumns += state.rows[rowId].items[itemId].width;
    });

    while (totalColumns > 12) {
      state.rows[rowId].itemsOrder.map(itemId => {
        const rowItemBundle = state.rows[rowId].items[itemId].entity.type[0].target_id;

        if (state.rows[rowId].items[itemId].width > this.getRequiredMinColumns(rowItemBundle)) {
          state.rows[rowId].items[itemId].width--;
          totalColumns--;
        }
      })
    }

  }

  /**
   * Create a subbed out item.
   *
   * @param machine_name
   * @param index
   * @param width
   * @returns {{width: *, index: *, id: string}}
   */
  createNewRowItem(machine_name, index, width) {
    const uuid = uuidv4();
    return {
      id: "new-" + uuid,
      index: index,
      width: width,
      admin_title: this.getToolInformation(machine_name).label,
      isNew: true,
      entity: {
        type: [{target_id: machine_name}],
      }
    }
  }

  /**
   * Set the width of the item based on column widths.
   *
   * @param itemId
   *   Item identifier or uuid.
   * @param newWidth
   *   New number of columns to save.
   */
  setItemWidth(itemId, newWidth) {
    const newState = {...this.state};
    newState.rowOrder.map(rowId => {
      if (typeof newState.rows[rowId].items[itemId] !== 'undefined') {
        newState.rows[rowId].items[itemId].width = newWidth;
      }
    });
    this.setState(newState);
  }

  /**
   * Call the API to fetch the entity form data.
   *
   * @param entityType
   *   Entity type machine name.
   * @param bundle
   *   Entity bundle machine name.
   *
   * @returns {*}
   *   The structured entity form object.
   */
  getEntityForm(entityType, bundle) {
    const url = this.apiUrls.formApi.replace('{entity_type_id}', entityType).replace('{bundle}', bundle);
    if (typeof this.state.cachedForms[`${entityType}-${bundle}`] !== 'undefined') {
      return this.state.cachedForms[`${entityType}-${bundle}`];
    }

    fetch(url)
      .then(response => response.json())
      .then(jsonData => this.setState(prevState => ({
        ...prevState,
        cachedForms: {
          ...prevState.cachedForms,
          [`${entityType}-${bundle}`]: jsonData
        }
      })))
      .catch(e => console.error(e));
  }

  /**
   * Update the fieldable row with new field values.
   *
   * @param rowId
   *   Row identifier.
   * @param fieldName
   *   Field machine name.
   * @param newValues
   *   New values for the field.
   */
  updateRowEntity(rowId, fieldName, newValues) {
    const newState = {...this.state}
    newState.rows[rowId].entity[fieldName] = newValues;
    this.setState(newState);
    this.triggerFormUpdated();
  }

  /**
   * Update the fieldable row item within a row with new field values.
   *
   * @param item
   *   The old item object.
   * @param fieldName
   *   Field machine name.
   * @param newValues
   *   New values for the field.
   */
  updateRowItemEntity(item, fieldName, newValues) {
    const newRows = {...this.state.rows};

    this.state.rowOrder.map(rowId => {
      if (typeof newRows[rowId].items[item.id] !== 'undefined') {

        newRows[rowId].items[item.id].entity[fieldName] = newValues;
        this.setState({rows: newRows});
        return;
      }
    });

    this.triggerFormUpdated();
  }

  updateEntityBehaviors(item, itemId, type, behaviorKey, fieldName, newValues) {
    const behaviorValues = typeof item.entity.behavior_settings === 'undefined' ? [{value: {}}] : [...item.entity.behavior_settings];
    if (behaviorValues[0].value.length === 0) {
      behaviorValues[0].value = {};
    }

    if (typeof behaviorValues[0].value[behaviorKey] === 'undefined') {
      behaviorValues[0].value[behaviorKey] = {};
    }
    behaviorValues[0].value[behaviorKey][fieldName] = newValues

    switch (type) {
      case 'paragraph':
        return this.updateRowItemEntity(item, 'behavior_settings', behaviorValues);

      default:
        return this.updateRowEntity(itemId, 'behavior_settings', behaviorValues);
    }
  }

  /**
   * Call the API and get the information about the row entity.
   *
   * @param rowId
   *   Row identifier.
   */
  loadRow(rowId) {
    this.loadEntity('paragraph_row', this.state.rows[rowId].target_id)
      .then(entityData => {
        const newState = {...this.state}
        this.state.rows[rowId].entity = entityData;
        delete this.state.rows[rowId].loadedEntity;
        this.setState(newState);
      });
  }

  /**
   * Call the API and get the information about the paragraph entity.
   *
   * @param itemId
   *   Item identifier or uuid.
   */
  loadRowItem(itemId) {
    const rowId = this.state.rowOrder.find(rowId => this.state.rows[rowId].itemsOrder.find(rowItemId => this.state.rows[rowId].items[rowItemId].target_id === itemId));
    const rowItemId = this.state.rows[rowId].itemsOrder.find(rowItemId => this.state.rows[rowId].items[rowItemId].target_id === itemId)

    const promise = this.loadEntity('paragraph', itemId);
    promise.then(entityData => {
      const newState = {...this.state}
      newState.rows[rowId].items[rowItemId].entity = entityData;
      delete newState.rows[rowId].items[rowItemId].loadedEntity;
      this.setState(newState);
    });
  }

  /**
   * Call the API and fetch the entity data.
   *
   * @param entityType
   *   Entity type machine name.
   * @param entityId
   *   Entity id.
   *
   * @returns {Promise<Response>}
   *   Promise of the entity object.
   */
  loadEntity(entityType, entityId) {
    return fetch(this.apiUrls.entityApi.replace('{entity_type_id}', entityType).replace('{entity_id}', entityId))
      .then(response => response.json());
  }

  /**
   * Renders the context widget.
   */
  render() {
    return (
      <WidgetContext.Provider
        value={{
          props: this.props,
          state: this.state,
          apiUrls: this.apiUrls,
          tools: this.props.tools,
          ...this.functions
        }}>
        {this.props.children}
      </WidgetContext.Provider>
    )

  }

}