ManageIQ/manageiq-ui-classic

View on GitHub
app/javascript/components/gtl-view.jsx

Summary

Maintainability
D
1 day
Test Coverage
/* eslint-disable no-nested-ternary */
/* eslint-disable react/prop-types */
/* eslint-disable no-console */
import React, { useEffect, useReducer } from 'react';
import PropTypes from 'prop-types';
import assign from 'lodash/assign';
import { http } from '../http_api';
import { StaticGTLView } from './data-tables/gtl';
import NoRecordsFound from './no-records-found';

const generateParamsFromSettings = (settings) => {
  const params = {};
  if (settings) {
    assign(params, settings.current && { page: settings.current });
    assign(params, settings.perpage && { ppsetting: settings.perpage });
    assign(params, settings.sort_header_text && { sort_choice: settings.sort_header_text });
    // eslint-disable-next-line eqeqeq
    assign(params, settings.sort_dir && { is_ascending: settings.sort_dir == 'ASC' });
  }
  return params;
};

const generateConfig = (
  modelName, // ?: string,  # string with name of model (either association or current model).
  activeTree, // ?: string, # string with active tree.
  parentId, // ?: string,   # ID of parent item.
  isExplorer, // ?: string,
  settings, // ?: any,
  records, // ?: any,
  additionalOptions, // ?: any) {
) => {
  const config = {};

  // name of currently selected model.
  assign(config, modelName && { model_name: modelName, model: modelName });
  // create active tree object, activeTree --  name of currently selected tree.
  assign(config, activeTree && { active_tree: activeTree });
  // parentId of currently selected models ID.
  assign(config, parentId && parentId !== null && { parent_id: parentId, model_id: parentId });
  assign(config, isExplorer && isExplorer !== null && { explorer: isExplorer });
  assign(config, generateParamsFromSettings(settings));
  // array of record IDs
  assign(
    config,
    records && records !== null && {
      'records[]': records,
      records,
    },
  );
  assign(
    config,
    additionalOptions && additionalOptions !== null && { additional_options: additionalOptions },
  );
  return config;
};

const getData = (
  dispatch,
  modelName, // ?: string,
  activeTree, // ?: string,
  parentId, // ?: string,
  isExplorer, // ?: string,
  settings, // ?: any,
  records, // ?: any,
  additionalOptions, // ?: any): ng.IPromise<IRowsColsResponse> {
  namedScope,
) => {
  dispatch({ type: 'isLoading', isLoading: true });
  if (additionalOptions.persistentNamedScope) {
    additionalOptions.named_scope = additionalOptions.persistentNamedScope;
  }
  http.post( // FIXME: window
    `/${ManageIQ.controller}/report_data`,
    generateConfig(
      modelName,
      activeTree,
      parentId,
      isExplorer,
      settings,
      records,
      additionalOptions,
    ),
  ).then((responseData) => {
    dispatch({
      type: 'dataLoaded',
      head: responseData.data.head,
      rows: responseData.data.rows,
      settings: responseData.settings,
      messages: responseData.messages,
    });
  });
};

const TREES_WITHOUT_PARENT = ['pxe', 'ops'];
const TREE_TABS_WITHOUT_PARENT = ['action_tree', 'alert_tree', 'schedules_tree'];
const USE_TREE_ID = ['automation_manager'];

const isAllowedParent = (activeTree) =>
  !TREES_WITHOUT_PARENT.includes(ManageIQ.controller)
    && !TREE_TABS_WITHOUT_PARENT.includes(activeTree);

const constructSuffixForTreeUrl = (showUrl, activeTree, item) => {
  let itemId = _.isString(showUrl) && showUrl.indexOf('xx-') !== -1 ? `_-${item.id}` : `-${item.id}`;
  if (item.parent_id && item.parent_id[item.parent_id.length - 1] !== '-') {
    itemId = `${item.parent_id}_${item.tree_id}`;
  } else if (isAllowedParent(activeTree)) {
    itemId = (USE_TREE_ID.includes(ManageIQ.controller)) ? '' : '_';
    itemId += item.tree_id;
  }
  return itemId;
};

const isCurrentControllerOrPolicies = (url) => {
  const splitUrl = url.split('/');
  return splitUrl && (splitUrl[1] === ManageIQ.controller || splitUrl[2] === 'policies');
};

const EXPAND_TREES = ['policy_tree', 'reports_tree'];
const activateNodeSilently = (itemId) => {
  const treeId = angular.element('.collapse.in miq-tree-view').attr('name');
  if (!EXPAND_TREES.includes(treeId)) {
    miqTreeExpandRecursive(treeId, itemId);
  }
};

const setRowActive = (rows, item) => {
  const newRows = rows.map((row) => ({
    ...row,
    selected: (row.id === item.id),
  }));

  window.sendDataWithRx({ rowSelect: item });
  ManageIQ.gridChecks = [item.id];

  return newRows;
};

const broadcastSelectedItem = (item) => {
  sendDataWithRx({ rowSelect: item });

  if (!window.ManageIQ) {
    return;
  }

  const { ManageIQ } = window;
  const index = ManageIQ.gridChecks.indexOf(item.id);
  if (item.checked) {
    if (index === -1) {
      ManageIQ.gridChecks.push(item.id);
    }
  } else if (index !== -1) {
    ManageIQ.gridChecks.splice(index, 1);
  }
};

const reduceSelectedItem = (state, item, isSelected) => {
  const selectedItem = {
    ...item,
    selected: isSelected,
    checked: isSelected,
  };
  broadcastSelectedItem(selectedItem);
  return {
    ...state,
    rows: state.rows.map((i) => (i.id !== selectedItem.id ? i : selectedItem)),
  };
};

const unSelectAll = (state) => {
  ManageIQ.gridChecks = [];
  if (!state.rows) {
    return state;
  }

  let newState = state;
  state.rows.forEach((item) => {
    newState = reduceSelectedItem(newState, item, false);
  });

  return newState;
};

const gtlReducer = (state, action) => {
  switch (action.type) {
    case 'dataLoaded':
      if (state && state.additionalOptions && state.additionalOptions.checkboxes_clicked
        && state.additionalOptions.checkboxes_clicked.length === 0) {
        // Making selected checkboxes array empty when those rows are  deleted or on compare/drift cancel
        ManageIQ.gridChecks = [];
      }
      return {
        ...state,
        isLoading: false,
        head: action.head,
        rows: action.rows,
        settings: action.settings,
        messages: action.messages,
      };
    case 'setActiveRow':
      // action.item ... the row to become active
      return {
        ...state,
        rows: setRowActive(state.rows, action.item),
      };
    case 'setScope':
      // action.namedScope ... new named scrop name
      return {
        ...state,
        namedScope: action.namedScope,
      };
    case 'itemSelected':
      // action.item       ... selected item object
      // action.isSelected ... selection status
      return reduceSelectedItem(state, action.item, action.isSelected);
    case 'isLoading':
      return {
        ...state,
        isLoading: action.isLoading,
      };
    case 'unSelectAll':
      return unSelectAll(state);
    default:
      return state;
  }
};

/* eslint no-unused-vars: 'off' */
/* eslint no-undef: 'off' */
const subscribeToSubject = (dispatch) =>
  listenToRx(
    (event) => {
      if (event.toolbarEvent && (event.toolbarEvent === 'itemClicked')) {
        // TODO
        this.setExtraClasses();
      }

      if (event.type === 'gtlSetOneRowActive') {
        dispatch({ type: 'setActiveRow', item: event.item });
      }

      if (event.type === 'gtlUnselectAll') {
        dispatch({ type: 'unSelectAll' });
      }

      if (event.type === 'setScope') {
        dispatch({ type: 'setScope', namedScope: event.namedScope });
      }
    },
    (err) => console.error('GTL RxJs Error: ', err),
    () => console.debug('GTL RxJs subject completed, no more events to catch.'),
  );

const initialState = {
  cols: [],
  rows: [],
  isLoading: true,
  settings: {
    current: 1,
    is_ascending: true,
    perpage: 20,
    sort_col: 0,
    sort_dir: 'ASC',
  },
};

const setPaging = (settings, start, perPage) => ({
  ...settings,
  perpage: perPage,
  startIndex: start,
  current: (start / perPage) + 1,
});

const computePagination = (settings) => ({
  page: settings.current,
  perPage: settings.perpage,
  perPageOptions: [10, 20, 50, 100, 200, 500, 1000],
});

const GtlView = ({
  flashMessages,
  additionalOptions,
  modelName,
  activeTree,
  parentId,
  isAscending,
  sortColIdx,
  isExplorer,
  records,
  hideSelect,
  showUrl,
  pages,
  noFlashDiv,
}) => {
  // const { settings, data } = props;
  const initState = {
    ...initialState,
    additionalOptions,
    // namedScope is taken from props to state and then used from state
    namedScope: additionalOptions.named_scope,
  };

  const [state, dispatch] = useReducer(gtlReducer, initState);

  useEffect(() => {
    // If id is explorer_wide, change back to explorer to ensure search bar fits on the same line of the page.
    if (document.getElementById('explorer_wide')) {
      document.getElementById('explorer_wide').setAttribute('id', 'explorer');
    }

    // eslint-disable-next-line no-unused-expressions
    if (!noFlashDiv) {
      flashMessages && flashMessages.forEach((message) => add_flash(message.message, message.level));
    }
  }, [state.namedScope]);

  useEffect(() => {
    const newAdditional = additionalOptions;
    newAdditional.persistentNamedScope = state.namedScope;
    getData(
      dispatch,
      modelName,
      activeTree,
      parentId,
      isExplorer,
      settings,
      records,
      {
        ...additionalOptions,
        named_scope: state.namedScope,
        persistentNamedScope: state.namedScope,
      },
      {
        ...additionalOptions,
        named_scope: state.namedScope,
        persistentNamedScope: state.namedScope,
      },
    );

    const subscription = subscribeToSubject(dispatch);
    return () => subscription.unsubscribe();
  }, [state.namedScope]); // fire request if namedScope is changed in state

  const {
    isLoading, head, rows,
    settings, // page settings
  } = state;

  const onItemSelect = (item, isSelected) => {
    if (typeof item === 'undefined') {
      return;
    }
    dispatch({ type: 'itemSelected', item, isSelected });
  };

  const onSelectAll = (items, target) => {
    const isSelected = target.checked;
    if (typeof items === 'undefined') {
      return;
    }
    items.map((item) => (
      dispatch({ type: 'itemSelected', item, isSelected })
    ));
    // eslint-disable-next-line consistent-return
    return false;
  };

  if (isLoading) {
    return <div className="spinner spinner-lg" />;
  }

  const onPageSet = (page) => getData(
    dispatch,
    modelName,
    activeTree,
    parentId,
    isExplorer,
    setPaging(settings, (page - 1) * settings.perpage, settings.perpage),
    records,
    additionalOptions,
  );

  const onPerPageSelect = (perPage) => getData(
    dispatch,
    modelName,
    activeTree,
    parentId,
    isExplorer,
    setPaging(settings, 0, perPage),
    records,
    additionalOptions,
  );

  /** Function execution when a page or perPage is changed in carbon pagination events. */
  const onPageChange = (page, perPage) => getData(
    dispatch,
    modelName,
    activeTree,
    parentId,
    isExplorer,
    setPaging(settings, (page - 1) * perPage, perPage),
    records,
    additionalOptions,
  );

  const setSort = (settings, headerId, isAscending) => ({
    ...settings,
    sort_dir: isAscending ? 'DESC' : 'ASC',
    is_ascending: !(!!isAscending),
    sort_col: headerId,
    sort_header_text: head.filter((column) => column.col_idx === headerId)[0].text,
  });

  const onSort = ({ headerId, isAscending }) => {
    getData(
      dispatch,
      modelName,
      activeTree,
      parentId,
      isExplorer,
      setSort(settings, headerId, isAscending),
      records,
      additionalOptions,
    );
  };

  const inEditMode = () => additionalOptions.in_a_form;
  const noCheckboxes = () => additionalOptions.no_checkboxes;
  const showPagination = () => additionalOptions.show_pagination;

  const onItemClick = (item, event) => {
    // If custom_action is specified, send and RxJS message with actionType set
    // to custom_action value.
    if (additionalOptions && additionalOptions.custom_action) {
      sendDataWithRx({
        type: 'GTL_CLICKED',
        actionType: additionalOptions.custom_action.type,
        payload: {
          item,
          action: additionalOptions.custom_action,
        },
      });
    }
    // no need to set targetUrl if custom_action is set i.e. for pre prov screen
    let targetUrl = (additionalOptions && additionalOptions.custom_action) ? undefined : showUrl;
    // Empty showUrl disables onRowClick action. Nothing to do.
    if (!showUrl) {
      return false;
    }

    // Handling of click-through on request/tasks/jobs (navigate to VM/Host/etc...)
    // URL is calculated based on item, not showUrl.
    if (item.parent_path && item.parent_id) {
      miqSparkleOn();
      window.DoNav(`${item.parent_path}/${item.parent_id}`);
      return true;
    }

    // explorer case + current controller (non nested) + policies
    if (isExplorer && targetUrl && isCurrentControllerOrPolicies(targetUrl)) {
      miqSparkleOn();
      let itemId = item.id;
      if (_.isString(targetUrl) && targetUrl.indexOf('?id=') !== -1) {
        itemId = constructSuffixForTreeUrl(showUrl, activeTree, item);
        activateNodeSilently(itemId);
      }

      // Seems to be related to Foreman configured systems unassigned config. profiles.
      // Where we need to navigate to a different URL.
      if (item.id.indexOf('unassigned') !== -1) {
        targetUrl = `/${ManageIQ.controller}/tree_select/?id=`;
      }
      miqAjax(targetUrl + itemId);
      return true;
    }

    // non-explorer case + nested case
    // targetUrl === 'true' disables clicks for tasks w/o parent_path and parent_id
    if (typeof targetUrl !== 'undefined' && targetUrl !== 'true') {
      miqSparkleOn();
      const lastChar = targetUrl[targetUrl.length - 1];
      if (lastChar !== '/' && lastChar !== '=') {
        targetUrl += '/';
      }
      window.DoNav(targetUrl + item.id);
      return true;
    }

    return false;
  };
  return (
    <div id="miq-gtl-view">
      { (rows.length === 0) ? (
        <NoRecordsFound />
      ) : (
        (isLoading) ? <div className="spinner spinner-lg" />
          : (
            <StaticGTLView
              pagination={computePagination(settings)}
              rows={rows}
              head={head}
              inEditMode={inEditMode}
              noCheckboxes={noCheckboxes}
              settings={settings}
              total={settings.items}
              onPageSet={onPageSet}
              onPerPageSelect={onPerPageSelect}
              onItemSelect={onItemSelect}
              onItemClick={onItemClick}
              onSelectAll={onSelectAll}
              onSort={(headerItem) => onSort(headerItem)}
              showPagination={showPagination}
              onPageChange={onPageChange}
            />
          )
      )}
    </div>
  );
};

GtlView.propTypes = {
  flashMessages: PropTypes.arrayOf(PropTypes.any),
  additionalOptions: PropTypes.shape({}),
  modelName: PropTypes.string,
  activeTree: PropTypes.string,
  parentId: PropTypes.string,
  sortColIdx: PropTypes.number,
  isExplorer: PropTypes.bool,
  records: PropTypes.arrayOf(PropTypes.any), // fixme
  hideSelect: PropTypes.bool,
  showUrl: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
  pages: PropTypes.shape({}), // fixme
  isAscending: PropTypes.bool,
};

GtlView.defaultProps = {
  flashMessages: null,
  additionalOptions: {},
  modelName: null,
  activeTree: null,
  parentId: null,
  sortColIdx: null,
  isExplorer: false,
  records: null,
  hideSelect: false,
  showUrl: null,
  pages: null,
  isAscending: null,
};

export default GtlView;