Katello/katello

View on GitHub
webpack/scenes/ContentViews/Details/Filters/CVErrataIDFilterContent.js

Summary

Maintainability
F
1 wk
Test Coverage
import React, { useState, useEffect, useCallback } from 'react';
import useDeepCompareEffect from 'use-deep-compare-effect';
import PropTypes from 'prop-types';
import { shallowEqual, useSelector, useDispatch } from 'react-redux';
import { capitalize, omit, isEqual } from 'lodash';
import { TableVariant } from '@patternfly/react-table';
import {
  Tabs, Tab, TabTitleText, Split, SplitItem, Select, SelectVariant,
  SelectOption, Button, Dropdown, DropdownItem, KebabToggle, Flex, FlexItem,
  Bullseye, DatePicker, ChipGroup, Chip, Text,
} from '@patternfly/react-core';
import { STATUS } from 'foremanReact/constants';
import { translate as __ } from 'foremanReact/common/I18n';

import onSelect from '../../../../components/Table/helpers';
import TableWrapper from '../../../../components/Table/TableWrapper';
import {
  selectCVFilterErratumID,
  selectCVFilterErratumIDStatus,
  selectCVFilterErratumIDError,
  selectCVFilters, selectCVFilterDetails, selectCVFiltersStatus,
} from '../ContentViewDetailSelectors';
import getContentViewDetails, {
  addCVFilterRule, removeCVFilterRule, getCVFilterErrata,
  deleteContentViewFilterRules, addContentViewFilterRules,
} from '../ContentViewDetailActions';
import AddedStatusLabel from '../../../../components/AddedStatusLabel';
import ErratumTypeLabel from '../../../../components/ErratumTypeLabel';
import AffectedRepositoryTable from './AffectedRepositories/AffectedRepositoryTable';
import { ADDED, ALL_STATUSES, NOT_ADDED, ERRATA_TYPES } from '../../ContentViewsConstants';
import SelectableDropdown from '../../../../components/SelectableDropdown/SelectableDropdown';
import { dateFormat, dateParse } from './CVErrataDateFilterContent';
import { hasPermission } from '../../helpers';

const CVErrataIDFilterContent = ({
  cvId, filterId, showAffectedRepos, setShowAffectedRepos, details,
}) => {
  const dispatch = useDispatch();
  const { results: filterResults } =
    useSelector(state => selectCVFilters(state, cvId), shallowEqual);
  const response = useSelector(state =>
    selectCVFilterErratumID(state, cvId, filterId), shallowEqual);
  const status = useSelector(state =>
    selectCVFilterErratumIDStatus(state, cvId, filterId), shallowEqual);
  const filterLoad = useSelector(state =>
    selectCVFiltersStatus(state, cvId), shallowEqual);
  const error = useSelector(state =>
    selectCVFilterErratumIDError(state, cvId, filterId), shallowEqual);
  const filterDetails = useSelector(state =>
    selectCVFilterDetails(state, cvId, filterId), shallowEqual);
  const { repositories = [] } = filterDetails;
  const [rows, setRows] = useState([]);
  const [searchQuery, updateSearchQuery] = useState('');
  const [activeTabKey, setActiveTabKey] = useState(0);
  const filterLoaded = filterLoad === 'RESOLVED';
  const loading = status === STATUS.PENDING;
  const [bulkActionOpen, setBulkActionOpen] = useState(false);
  const toggleBulkAction = () => setBulkActionOpen(prevState => !prevState);
  const hasAddedSelected = rows.some(({ selected, added }) => selected && added);
  const hasNotAddedSelected = rows.some(({ selected, added }) => selected && !added);
  const [statusSelected, setStatusSelected] = useState(ALL_STATUSES);
  const [typeSelectOpen, setTypeSelectOpen] = useState(false);
  const [selectedTypes, setSelectedTypes] = useState(ERRATA_TYPES);
  const [startDate, setStartDate] = useState('');
  const [endDate, setEndDate] = useState('');
  const activeFilters = [statusSelected, selectedTypes, startDate, endDate];
  const defaultFilters = [ALL_STATUSES, ERRATA_TYPES, '', ''];
  const [apiStartDate, setApiStartDate] = useState('');
  const [apiEndDate, setApiEndDate] = useState('');
  const [dateType, setDateType] = useState('issued');
  const [dateTypeSelectOpen, setDateTypeSelectOpen] = useState(false);
  const [startEntry, setStartEntry] = useState(false);
  const [endEntry, setEndEntry] = useState(false);

  const metadata = omit(response, ['results']);
  const { permissions } = details;
  const columnHeaders = [
    __('Errata ID'),
    __('Type'),
    __('Issued'),
    __('Updated'),
    __('Severity'),
    __('Synopsis'),
    __('Status'),
  ];

  const buildRows = useCallback((results) => {
    const newRows = [];
    const filterRules = filterResults.find(({ id }) => id === Number(filterId))?.rules || [];
    results.forEach((errata) => {
      const {
        id,
        errata_id: errataId,
        type,
        issued,
        updated,
        severity,
        title,
        filter_ids: filterIds,
        ...rest
      } = errata;

      const added = filterIds.includes(parseInt(filterId, 10));

      const cells = [
        { title: errataId },
        { title: <ErratumTypeLabel type={type} /> },
        { title: issued },
        { title: updated },
        { title: severity || 'N/A' },
        { title },
        { title: <AddedStatusLabel added={added} /> },
      ];


      newRows.push({
        cells,
        erratumId: errataId,
        erratumRuleId: filterRules?.find(({ errata_id: filterErrataId }) =>
          filterErrataId === errataId)?.id,
        added,
        ...rest,
        errataId,
      });
    });

    return newRows.sort(({ added: addedA }, { added: addedB }) => {
      if (addedA === addedB) return 0;
      return addedA ? -1 : 1;
    });
  }, [filterResults, filterId]);

  const bulkAdd = () => {
    setBulkActionOpen(false);
    const addData = rows.filter(({ selected, added }) =>
      selected && !added).map(({ erratumId }) => ({ errata_ids: [erratumId] })); // eslint-disable-line max-len
    dispatch(addContentViewFilterRules(filterId, addData, () =>
      dispatch(getContentViewDetails(cvId))));
  };

  const bulkRemove = () => {
    setBulkActionOpen(false);
    const erratumRuleIds =
      rows.filter(({ selected, added }) =>
        selected && added).map(({ erratumRuleId }) => erratumRuleId);
    dispatch(deleteContentViewFilterRules(filterId, erratumRuleIds, () =>
      dispatch(getContentViewDetails(cvId))));
  };

  useEffect(() => {
    if (!repositories.length && showAffectedRepos) {
      setActiveTabKey(1);
    } else {
      setActiveTabKey(0);
    }
  }, [showAffectedRepos, repositories.length]);

  useDeepCompareEffect(() => {
    const { results } = response;

    if (!loading && results && filterLoaded) {
      const newRows = buildRows(results);
      setRows(newRows);
    }
  }, [response, loading, filterLoaded, buildRows]);

  const actionResolver = ({ added }) => [
    {
      title: __('Add'),
      isDisabled: added,
      onClick: (_event, _rowId, { erratumId }) => {
        dispatch(addCVFilterRule(filterId, { errata_ids: [erratumId] }, () =>
          dispatch(getContentViewDetails(cvId))));
      },
    },
    {
      title: __('Remove'),
      isDisabled: !added,
      onClick: (_event, _rowId, { erratumRuleId }) => {
        dispatch(removeCVFilterRule(filterId, erratumRuleId, () =>
          dispatch(getContentViewDetails(cvId))));
      },
    },
  ];

  const validAPIDate = (date) => {
    if (!date || date === '') return true;
    const split = date.split('/');
    if (split.length !== 3) {
      return false;
    }
    const [month, day, year] = split;
    return month && month.length === 2 && day && day.length === 2 && year && year.length === 4;
  };

  const singleSelection = selection => (selectedTypes.length === 1
    && selectedTypes.includes(selection));

  const onTypeSelect = (selection) => {
    if (selectedTypes.includes(selection)) {
      if (selectedTypes.length === 1) return;
      setSelectedTypes(selectedTypes.filter(e => e !== selection));
    } else setSelectedTypes([...selectedTypes, selection]);
    setTypeSelectOpen(false);
  };

  const setValidStartDate = (e, value) => {
    setStartDate(value);
    if (validAPIDate(value)) setApiStartDate(value);
  };

  const setValidEndDate = (e, value) => {
    setEndDate(value);
    if (validAPIDate(value)) setApiEndDate(value);
  };

  const getCVFilterErrataWithOptions = useCallback((params = {}) => {
    let apiParams = { ...params, types: selectedTypes };
    if (dateType) apiParams = { ...apiParams, date_type: dateType };
    if (apiStartDate) apiParams = { ...apiParams, start_date: apiStartDate };
    if (apiEndDate) apiParams = { ...apiParams, end_date: apiEndDate };
    return getCVFilterErrata(cvId, filterId, apiParams, statusSelected);
  }, [cvId, filterId, statusSelected, selectedTypes, dateType, apiStartDate, apiEndDate]);

  const resetFilters = () => {
    setValidStartDate('');
    setValidEndDate('');
    setSelectedTypes(ERRATA_TYPES);
    setDateType('issued');
    setStatusSelected(ALL_STATUSES);
  };

  const resetFiltersDisabled =
    startDate === '' &&
    endDate === '' &&
    isEqual(selectedTypes, ERRATA_TYPES) &&
    dateType === 'issued' &&
    statusSelected === ALL_STATUSES;

  const emptyContentTitle = __('No errata filter rules yet');
  const emptyContentBody = __('No errata to add yet');
  const emptySearchTitle = __('No matching filter rules found.');
  const emptySearchBody = __('Try changing your search settings.');

  const invalidDateFormat = __('Enter a valid date: MM/DD/YYYY');

  return (
    <Tabs
      className="margin-0-24"
      ouiaId="errata-filter-tabs"
      activeKey={activeTabKey}
      onSelect={(_event, eventKey) => setActiveTabKey(eventKey)}
    >
      <Tab
        ouiaId="errata-filter-table-tab"
        eventKey={0}
        title={<TabTitleText>{__('Errata')}</TabTitleText>}
      >
        <div className="margin-24-0">
          <TableWrapper
            {...{
              rows,
              metadata,
              emptyContentTitle,
              emptyContentBody,
              emptySearchTitle,
              emptySearchBody,
              searchQuery,
              updateSearchQuery,
              error,
              status,
              activeFilters,
              defaultFilters,
              resetFilters,
            }}
            ouiaId="content-view-errata-by-id-filter-table"
            actionResolver={hasPermission(permissions, 'edit_content_views') ? actionResolver : null}
            onSelect={hasPermission(permissions, 'edit_content_views') ? onSelect(rows, setRows) : null}
            cells={columnHeaders}
            variant={TableVariant.compact}
            autocompleteEndpoint="/katello/api/v2/errata"
            autocompleteQueryParams={{ filterid: filterId }}
            bookmarkController="katello_content_view_erratum_filter_rules"
            additionalListeners={[statusSelected, selectedTypes.length,
              dateType, apiStartDate, apiEndDate]}
            fetchItems={useCallback(params =>
              getCVFilterErrataWithOptions(params), [getCVFilterErrataWithOptions])}
            actionButtons={hasPermission(permissions, 'edit_content_views') &&
                status === STATUS.RESOLVED && rows.length !== 0 &&
                <Split hasGutter>
                  <SplitItem data-testid="allAddedNotAdded">
                    <SelectableDropdown
                      items={[ALL_STATUSES, ADDED, NOT_ADDED]}
                      title=""
                      selected={statusSelected}
                      setSelected={setStatusSelected}
                      placeholderText={__('Status')}
                      aria-label="status_selector"
                      ouiaId="status-selector"
                    />
                  </SplitItem>
                  <SplitItem>
                    <Select
                      aria-label="errata_type_selector"
                      ouiaId="errata_type_selector"
                      variant={SelectVariant.checkbox}
                      onToggle={setTypeSelectOpen}
                      onSelect={(_event, selection) => onTypeSelect(selection)}
                      selections={selectedTypes}
                      isOpen={typeSelectOpen}
                      placeholderText={__('Errata type')}
                      isCheckboxSelectionBadgeHidden
                    >
                      <SelectOption aria-label="security_selection" isDisabled={singleSelection('security')} key="security" value="security">
                        <p style={{ marginTop: '4px' }}>
                          {__('Security')}
                        </p>
                      </SelectOption>
                      <SelectOption isDisabled={singleSelection('enhancement')} key="enhancement" value="enhancement">
                        <p style={{ marginTop: '4px' }}>
                          {__('Enhancement')}
                        </p>
                      </SelectOption>
                      <SelectOption isDisabled={singleSelection('bugfix')} key="bugfix" value="bugfix">
                        <p style={{ marginTop: '4px' }}>
                          {__('Bugfix')}
                        </p>
                      </SelectOption>
                    </Select>
                  </SplitItem>
                  {hasPermission(permissions, 'edit_content_views') &&
                  <SplitItem>
                    <Button ouiaId="add-errata-id-button" isDisabled={!hasNotAddedSelected} onClick={bulkAdd} variant="primary" aria-label="add_filter_rule">
                      {__('Add errata')}
                    </Button>
                  </SplitItem>
                }
                  {hasPermission(permissions, 'edit_content_views') &&
                  <SplitItem>
                    <Dropdown
                      toggle={<KebabToggle aria-label="bulk_actions" onToggle={toggleBulkAction} />}
                      isOpen={bulkActionOpen}
                      ouiaId="cv-errata-id-bulk-action-dropdown"
                      isPlain
                      dropdownItems={[
                        <DropdownItem ouiaId="bulk-remove" aria-label="bulk_remove" key="bulk_remove" isDisabled={!hasAddedSelected} component="button" onClick={bulkRemove}>
                          {__('Remove')}
                        </DropdownItem>]
                      }
                    />
                  </SplitItem>
                }
                </Split>
            }
            nodesBelowSearch={status === STATUS.RESOLVED && rows.length !== 0 &&
              <>
                <Flex>
                  <FlexItem span={2} spacer={{ default: 'spacerNone' }}>
                    <Select
                      selections={dateType}
                      onSelect={(_event, selection) => {
                        setDateType(selection);
                        setDateTypeSelectOpen(false);
                      }}
                      isOpen={dateTypeSelectOpen}
                      onToggle={setDateTypeSelectOpen}
                      id="date_type_selector"
                      name="date_type_selector"
                      ouiaId="date_type_selector"
                      aria-label="date_type_selector"
                    >
                      <SelectOption key="issued" value="issued">{__('Issued from')}</SelectOption>
                      <SelectOption key="updated" value="updated">{__('Updated from')}</SelectOption>
                    </Select>
                  </FlexItem>
                  <FlexItem span={2} spacer={{ default: 'spacerNone' }}>
                    <Bullseye
                      onFocus={() => setStartEntry(true)}
                      onBlur={() => setStartEntry(false)}
                    >
                      <DatePicker
                        aria-label="start_date_input"
                        invalidFormatText={invalidDateFormat}
                        value={startDate}
                        dateFormat={dateFormat}
                        onChange={setValidStartDate}
                        dateParse={dateParse}
                        placeholder={startEntry ? 'MM/DD/YYYY' : __('Start date')}
                      />
                    </Bullseye>
                  </FlexItem>
                  <FlexItem spacer={{ default: 'spacerNone' }}>
                    <Bullseye style={{ padding: '0 5px' }}>
                      <Text ouiaId="to-text">{__('to')}</Text>
                    </Bullseye>
                  </FlexItem>
                  <FlexItem span={2}>
                    <Bullseye
                      onFocus={() => setEndEntry(true)}
                      onBlur={() => setEndEntry(false)}
                    >
                      <DatePicker
                        aria-label="end_date_input"
                        value={endDate}
                        invalidFormatText={invalidDateFormat}
                        dateFormat={dateFormat}
                        onChange={setValidEndDate}
                        dateParse={dateParse}
                        placeholder={endEntry ? 'MM/DD/YYYY' : __('End date')}
                      />
                    </Bullseye>
                  </FlexItem>
                </Flex>
                <Flex>
                  <FlexItem>
                    <ChipGroup ouiaId="status-chipgroup" categoryName={__('Status')}>
                      <Chip ouiaId="status" key="status" onClick={() => setStatusSelected(ALL_STATUSES)} isReadOnly={statusSelected === ALL_STATUSES}>
                        {statusSelected}
                      </Chip>
                    </ChipGroup>
                  </FlexItem>
                  <FlexItem>
                    <ChipGroup ouiaId="issued-chipgroup" categoryName={dateType === 'issued' ? __('Issued from') : __('Updated from')}>
                      <Chip ouiaId="startDate" key="startDate" onClick={() => setValidStartDate('')} isReadOnly={startDate === ''}>
                        {startDate || __('ANY')}
                      </Chip>
                      {__('to')}
                      <Chip ouiaId="endDate" key="endDate" onClick={() => setValidEndDate('')} isReadOnly={endDate === ''}>
                        {endDate || __('ANY')}
                      </Chip>
                    </ChipGroup>
                  </FlexItem>
                  <FlexItem>
                    <ChipGroup ouiaId="type-chipgroup" categoryName={__('Type')}>
                      {selectedTypes.map(type => (
                        <Chip
                          ouiaId={`chip-${type}`}
                          key={type}
                          onClick={() => onTypeSelect(type)}
                          isReadOnly={singleSelection(type)}
                        >
                          {capitalize(type)}
                        </Chip>
                      ))}
                    </ChipGroup>
                  </FlexItem>
                  <FlexItem>
                    <Button ouiaId="errata-reset-filters-button" isDisabled={resetFiltersDisabled} variant="link" onClick={resetFilters} isInline>
                      {__('Reset filters')}
                    </Button>
                  </FlexItem>
                </Flex>
              </>
            }
          />
        </div>
      </Tab>
      {(repositories.length || showAffectedRepos) &&
        <Tab
          ouiaId="affected-repos-tab"
          eventKey={1}
          title={<TabTitleText>{__('Affected repositories')}</TabTitleText>}
        >
          <div className="margin-24-0">
            <AffectedRepositoryTable cvId={cvId} filterId={filterId} repoType="yum" setShowAffectedRepos={setShowAffectedRepos} details={details} />
          </div>
        </Tab>
      }
    </Tabs>
  );
};

CVErrataIDFilterContent.propTypes = {
  cvId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
  filterId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
  showAffectedRepos: PropTypes.bool.isRequired,
  setShowAffectedRepos: PropTypes.func.isRequired,
  details: PropTypes.shape({
    permissions: PropTypes.shape({}),
  }).isRequired,
};

export default CVErrataIDFilterContent;