diff --git a/src/js/components/search/resultsView/ResultsView.jsx b/src/js/components/search/resultsView/ResultsView.jsx index ec92447789..97f3ee677c 100644 --- a/src/js/components/search/resultsView/ResultsView.jsx +++ b/src/js/components/search/resultsView/ResultsView.jsx @@ -3,15 +3,12 @@ * Created by Andrea Blackwell **/ -import React, { useEffect, useState } from "react"; +import React from "react"; import PropTypes from "prop-types"; -import { isCancel } from "axios"; import { useSelector } from "react-redux"; import TopFilterBarContainer from "containers/search/topFilterBar/TopFilterBarContainer"; -import SearchAwardsOperation from "models/v1/search/SearchAwardsOperation"; -import { performSpendingByAwardTabCountSearch, areFiltersEqual } from "helpers/searchHelper"; -import { performTabCountSearch } from "helpers/keywordHelper"; +import useResultsCount from "containers/search/resultsView/useResultsCount"; import NewSearchScreen from "./NewSearchScreen"; import NoDataScreen from "./NoDataScreen"; import SectionsContent from "./SectionsContent"; @@ -34,124 +31,57 @@ const ResultsView = React.memo(function ResultsView({ hash, setFilterCount }) { - const [hasResults, setHasResults] = useState(false); - const [resultContent, setResultContent] = useState(null); - const [tabData, setTabData] = useState(); - const [inFlight, setInFlight] = useState(false); - const [error, setError] = useState(false); - const filters = useSelector((state) => state.appliedFilters.filters); const spendingLevel = useSelector((state) => state.searchView.spendingLevel); - let countRequest; - - const checkForData = () => { - if (countRequest) { - countRequest.cancel(); - } - - const searchParamsTemp = new SearchAwardsOperation(); - searchParamsTemp.fromState(filters); + const { data, error } = useResultsCount(filters, spendingLevel, hash); - setInFlight(true); - setError(false); + let content = null; - if (spendingLevel === 'transactions') { - countRequest = performTabCountSearch({ - filters: searchParamsTemp.toParams(), - spending_level: spendingLevel, - auditTrail: 'Results View - Tab Counts' - }); - } - else { - // if subawards is true, newAwardsOnly cannot be true, so we remove dateType - if (spendingLevel === 'subawards') { - delete searchParamsTemp.dateType; - } + if (!error && data) { + /* eslint-disable camelcase */ + const { + contracts, direct_payments, grants, idvs, loans, other, subgrants, subcontracts + } = data.data.results; + let resCount = contracts + direct_payments + grants + idvs + loans + other; - countRequest = performSpendingByAwardTabCountSearch({ - filters: searchParamsTemp.toParams(), - spending_level: spendingLevel, - auditTrail: 'Results View - Tab Counts' - }); + if (spendingLevel === 'subawards') { + resCount = subgrants + subcontracts; } - countRequest.promise - .then((res) => { - /* eslint-disable camelcase */ - setTabData(res.data); - const { - contracts, direct_payments, grants, idvs, loans, other, subgrants, subcontracts - } = res.data.results; - let resCount = contracts + direct_payments + grants + idvs + loans + other; + const hasResults = resCount > 0; + /* eslint-enable camelcase */ - if (spendingLevel === 'subawards') { - resCount = subgrants + subcontracts; - } - /* eslint-enable camelcase */ - - if (resCount > 0) { - setHasResults(true); - } - else { - setHasResults(false); - } - - setInFlight(false); - setError(false); - }) - .catch((err) => { - if (!isCancel(err)) { - setInFlight(false); - setError(true); - console.log(err); - } - }); - }; - - useEffect(() => { - if (!areFiltersEqual(filters) || !hash) { - checkForData(); + if (!hash && noFiltersApplied) { + content = ; } - return () => { - countRequest?.cancel(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters, spendingLevel]); - useEffect(() => { - let content = null; - - if (!inFlight && !error) { - if (!hash && noFiltersApplied) { - content = ; + if (!noFiltersApplied) { + if (hasResults) { + content = ( + + ); } - - if (!noFiltersApplied) { - if (hasResults) { - content = ( - - ); - } - else { - content = ; - } + else { + content = ; } } - - setResultContent(content); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [noFiltersApplied, hasResults, inFlight, error, hash]); + } return (
- -
- {resultContent} + +
+ {content}
diff --git a/src/js/components/search/resultsView/SectionsContent.jsx b/src/js/components/search/resultsView/SectionsContent.jsx index b1e899a031..bbb8595f70 100644 --- a/src/js/components/search/resultsView/SectionsContent.jsx +++ b/src/js/components/search/resultsView/SectionsContent.jsx @@ -6,21 +6,34 @@ import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; +import Analytics from "helpers/analytics/Analytics"; import TableSection from "./table/TableSection"; import CategoriesSection from "./categories/CategoriesSection"; import TimeSection from "./time/TimeSection"; import MapSection from "./map/MapSection"; -import Analytics from "../../../helpers/analytics/Analytics"; require("pages/search/searchPage.scss"); +const logVisualizationViewEvent = (action, label) => window.setTimeout( + () => Analytics.event({ + event: 'search_visualization_type', + category: 'Advanced Search - Visualization Type', + action, + gtm: true, + label + }), 15 * 1000); + const propTypes = { tabData: PropTypes.object, hash: PropTypes.string, spendingLevel: PropTypes.string }; -const SectionsContent = (props) => { +const SectionsContent = ({ + tabData, + hash, + spendingLevel +}) => { const [observerSupported, setObserverSupported] = useState(false); const [timeHasLoaded, setTimeHasLoaded] = useState(false); const [categoriesHasLoaded, setCategoriesHasLoaded] = useState(false); @@ -30,36 +43,24 @@ const SectionsContent = (props) => { threshold: 0.1 }; - const logVisualizationViewEvent = (activeLabel) => { - window.setTimeout(() => { - Analytics.event({ - event: 'search_visualization_type', - category: 'Advanced Search - Visualization Type', - action: activeLabel, - gtm: true, - label: props.hash - }); - }, 15 * 1000); - }; - const callbackFunction = (entries) => { entries.forEach((entry) => { const section = entry.target.className; if (entry.isIntersecting) { if (section === 'awards') { - logVisualizationViewEvent("awards"); + logVisualizationViewEvent("awards", hash); } else if (section === 'time') { setTimeHasLoaded(true); - logVisualizationViewEvent("time"); + logVisualizationViewEvent("time", hash); } else if (section === 'categories') { setCategoriesHasLoaded(true); - logVisualizationViewEvent("categories"); + logVisualizationViewEvent("categories", hash); } else if (section === "map") { setMapHasLoaded(true); - logVisualizationViewEvent("map"); + logVisualizationViewEvent("map", hash); } } }); @@ -67,7 +68,6 @@ const SectionsContent = (props) => { useEffect(() => { setObserverSupported('IntersectionObserver' in window); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // eslint-disable-next-line consistent-return @@ -88,25 +88,28 @@ const SectionsContent = (props) => { return () => observer.disconnect(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [observerSupported, props.hash]); + }, [observerSupported, hash]); return ( <> - + + hash={hash} /> + hash={hash} + spendingLevel={spendingLevel} /> + hash={hash} /> ); }; diff --git a/src/js/components/search/resultsView/table/TableSection.jsx b/src/js/components/search/resultsView/table/TableSection.jsx index 4fd499020c..50ae35ca14 100644 --- a/src/js/components/search/resultsView/table/TableSection.jsx +++ b/src/js/components/search/resultsView/table/TableSection.jsx @@ -2,9 +2,9 @@ * TableSection.jsx */ -import React from "react"; +import React, { useMemo } from "react"; import PropTypes from "prop-types"; -import ResultsTableContainer from "../../../../containers/search/resultsView/ResultsTableContainer"; +import ResultsTableContainer from "containers/search/resultsView/ResultsTableContainer"; import TableDsm from "./TableDsm"; @@ -17,27 +17,19 @@ const propTypes = { const TableSection = ({ tabData, hash, spendingLevel }) => { - const sectionTitle = () => { - switch (spendingLevel) { - case 'awards': return 'Prime Award Results'; - case 'subawards': return 'Subaward Results'; - default: return 'Transaction Results'; - } - }; + const sectionTitle = spendingLevel === "awards" ? 'Prime Award Results' : 'Subaward Results'; - const wrapperProps = { - sectionTitle: sectionTitle(), - dsmContent: , - sectionName: 'table' - }; + const dsmContent = useMemo(() => , [spendingLevel]); return (
+ spendingLevel={spendingLevel} + sectionTitle={sectionTitle} + dsmContent={dsmContent} + sectionName="table" />
); }; diff --git a/src/js/containers/account/awards/AccountAwardsContainer.jsx b/src/js/containers/account/awards/AccountAwardsContainer.jsx index 6d22899372..60d8d31456 100644 --- a/src/js/containers/account/awards/AccountAwardsContainer.jsx +++ b/src/js/containers/account/awards/AccountAwardsContainer.jsx @@ -15,7 +15,7 @@ import * as SearchHelper from 'helpers/searchHelper'; import { defaultColumns, defaultSort } from 'dataMapping/search/awardTableColumns'; import AccountAwardSearchOperation from 'models/v1/account/queries/AccountAwardSearchOperation'; import ResultsTableSection from 'components/search/resultsView/table/ResultsTableSection'; -import { tableTypes, subTypes } from 'containers/search/resultsView/ResultsTableContainer'; +import { tableTypes, subTypes } from 'dataMapping/search/resultsView/table'; import { SectionHeader } from "data-transparency-ui"; const propTypes = { diff --git a/src/js/containers/search/resultsView/ResultsTableContainer.jsx b/src/js/containers/search/resultsView/ResultsTableContainer.jsx index 2763a29517..7b646bc9ce 100644 --- a/src/js/containers/search/resultsView/ResultsTableContainer.jsx +++ b/src/js/containers/search/resultsView/ResultsTableContainer.jsx @@ -3,394 +3,122 @@ * Created by Kevin Li 11/8/16 **/ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, memo } from 'react'; import { useLocation } from 'react-router'; import PropTypes from 'prop-types'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import { isCancel } from 'axios'; -import { uniqueId, intersection, throttle } from 'lodash-es'; -import SearchAwardsOperation from 'models/v1/search/SearchAwardsOperation'; +import { useDispatch, useSelector } from 'react-redux'; +import { throttle } from 'lodash-es'; + import { subAwardIdClicked } from 'redux/actions/search/searchSubAwardTableActions'; -import * as SearchHelper from 'helpers/searchHelper'; +import { measureTableHeader } from 'helpers/textMeasurement'; import Analytics from 'helpers/analytics/Analytics'; -import { awardTypeGroups, subawardTypeGroups, transactionTypeGroups } from 'dataMapping/search/awardType'; +import { tableTypes, subTypes, transactionTypes } from 'dataMapping/search/resultsView/table'; import { defaultColumns, - defaultSort, - apiFieldByTableColumnName + defaultSort } from 'dataMapping/search/awardTableColumns'; import { awardTableColumnTypes } from 'dataMapping/search/awardTableColumnTypes'; -import { measureTableHeader } from 'helpers/textMeasurement'; import ResultsTableSection from 'components/search/resultsView/table/ResultsTableSection'; -import searchActions from 'redux/actions/searchActions'; -import * as appliedFilterActions from 'redux/actions/search/appliedFilterActions'; -import SearchSectionWrapper from "../../../components/search/resultsView/SearchSectionWrapper/SearchSectionWrapper"; -import { performKeywordSearch } from "../../../helpers/keywordHelper"; +import SearchSectionWrapper from + "components/search/resultsView/SearchSectionWrapper/SearchSectionWrapper"; +import useResultsTableSearch from './useResultsTableSearch'; + +const createColumn = (col) => { + // create an object that integrates with the expected column data structure used by + // the table component + + // BODGE: Temporarily only allow descending columns + const direction = 'desc'; + const width = col.customWidth || measureTableHeader(col.displayName || col.title); + + return { + columnName: col.title, + displayName: col.displayName || col.title, + subtitle: col.subtitle || '', + width, + background: col.background || '', + defaultDirection: direction, + right: col.right || false + }; +}; + +// in the future, this will be an API call, but for now, read the local data file +// load every possible table column up front, so we don't need to deal with this when +// switching tabs +const columns = tableTypes + .concat(subTypes) + .concat(transactionTypes) + .reduce((cols, type) => { + const visibleColumns = defaultColumns(type.internal).map((data) => data.title); + const parsedColumns = defaultColumns(type.internal) + .reduce((parsedCols, data) => Object.assign({}, parsedCols, { + [data.title]: createColumn(data) + }), {}); + + return Object.assign(cols, { + [type.internal]: { + visibleOrder: visibleColumns, + data: parsedColumns + } + }); + }, {}); const propTypes = { - filters: PropTypes.object, - setAppliedFilterCompletion: PropTypes.func, - noApplied: PropTypes.bool, - subAwardIdClicked: PropTypes.func, - wrapperProps: PropTypes.object, tabData: PropTypes.object, + spendingLevel: PropTypes.string, hash: PropTypes.string, - spendingLevel: PropTypes.string + sectionTitle: PropTypes.string, + dsmContent: PropTypes.element, + sectionName: PropTypes.string }; -export const tableTypes = [ - { - label: 'Contracts', - internal: 'contracts' - }, - { - label: 'Contract IDVs', - internal: 'idvs' - }, - { - label: 'Grants', - internal: 'grants' - }, - { - label: 'Direct Payments', - internal: 'direct_payments' - }, - { - label: 'Loans', - internal: 'loans' - }, - { - label: 'Other', - internal: 'other' - } -]; - -export const subTypes = [ - { - label: 'Sub-Contracts', - internal: 'subcontracts' - }, - { - label: 'Sub-Grants', - internal: 'subgrants' - } -]; - -const transactionTypes = [ - { - label: 'Contracts', - internal: 'transaction_contracts' - }, - { - label: 'Contract IDVs', - internal: 'transaction_idvs' - }, - { - label: 'Grants', - internal: 'transaction_grants' - }, - { - label: 'Direct Payments', - internal: 'transaction_direct_payments' - }, - { - label: 'Loans', - internal: 'transaction_loans' - }, - { - label: 'Other', - internal: 'transaction_other' - } -]; -const ResultsTableContainer = (props) => { - let tabCountRequest = null; - let searchRequest = null; +// eslint-disable-next-line prefer-arrow-callback +const ResultsTableContainer = memo(function ResultsTableContainer({ + tabData, + spendingLevel, + hash, + sectionTitle, + dsmContent, + sectionName +}) { const location = useLocation(); - const [searchParams, setSearchParams] = useState(new SearchAwardsOperation()); + const dispatch = useDispatch(); + const filters = useSelector((state) => state.appliedFilters.filters); const [page, setPage] = useState(1); - const [lastPage, setLastPage] = useState(true); - const [counts, setCounts] = useState({}); - const [tableType, setTableType] = useState(); - const [columns, setColumns] = useState({}); + const [tableType, setTableType] = useState( + spendingLevel === 'subawards' ? 'subcontracts' : 'contracts' + ); const [sort, setSort] = useState({ - field: 'Award Amount', + field: spendingLevel === 'subawards' ? 'Sub-Award Amount' : 'Award Amount', direction: 'desc' }); - const [inFlight, setInFlight] = useState(false); - const [error, setError] = useState(false); - const [results, setResults] = useState([]); - const [total, setTotal] = useState(0); const [resultLimit, setResultLimit] = useState(100); - const [tableInstance, setTableInstance] = useState(`${uniqueId()}`); const [isLoadingNextPage, setLoadNextPage] = useState(false); - const [isInitialLoad, setIsInitialLoad] = useState(true); - const [spendingLevel, setSpendingLevel] = useState(props.spendingLevel); - const [isSubaward, setIsSubaward] = useState(props.spendingLevel === 'subawards'); - const [isTransactions, setIsTransactions] = useState(props.spendingLevel === 'transactions'); - const [expandableData, setExpandableData] = useState([]); - const { pathname } = useLocation(); - const isV2 = pathname === '/search'; - const showToggle = isV2 && (props.spendingLevel !== "awards"); const [isMobile, setIsMobile] = useState(false); - const [columnType, setColumnType] = useState(props.spendingLevel); - - const performSearch = throttle((newSearch = false) => { - if (searchRequest) { - // a request is currently in-flight, cancel it - searchRequest.cancel(); - } - - const tableTypeTemp = tableType; - - // get searchParams from state - const searchParamsTemp = new SearchAwardsOperation(); - searchParamsTemp.fromState(props.filters); - - // if subawards is true, newAwardsOnly cannot be true, so we remove - // dateType for this request; also has to be done for the tabCounts request - if (isSubaward && searchParamsTemp.dateType) { - delete searchParamsTemp.dateType; - } - - // generate an array of award type codes representing the current table tab we're showing - // and use a different mapping if we're showing a subaward table vs a prime award table - let groupsFromTableType = - isSubaward ? subawardTypeGroups[tableTypeTemp] : awardTypeGroups[tableTypeTemp]; - - if (isTransactions) { - groupsFromTableType = transactionTypeGroups[tableTypeTemp]; - } - - if (searchParams.awardType.length === 0) { - searchParamsTemp.awardType = groupsFromTableType; - } - else { - let intersectingTypes = intersection(groupsFromTableType, - searchParams.awardType); - if (!intersectingTypes || intersectingTypes.length === 0) { - // the filtered types and the table type do not align - // in this case, send an array of non-existent types because the endpoint requires - // an award type parameter - intersectingTypes = ['no intersection']; - } - searchParamsTemp.awardType = intersectingTypes; - } - - // indicate the request is about to start - setInFlight(true); - setError(false); - - let pageNumber = page; - - if (newSearch) { - // a new search (vs just getting more pages of an existing search) requires resetting - // the page number - pageNumber = 1; - } - - const requestFields = []; - - // Request fields for visible columns only - const columnVisibility = columns[tableTypeTemp]?.visibleOrder; - if (!columnVisibility) { - return null; - } - - columnVisibility.forEach((field) => { - if (!requestFields.includes(field) && field !== "Action Date") { - // Prevent duplicates in the list of fields to request - if (Object.keys(apiFieldByTableColumnName).includes(field)) { - requestFields.push(apiFieldByTableColumnName[field]); - } - else { - requestFields.push(field); - } - } - else if (field === "Action Date" && props.spendingLevel !== 'transactions') { - requestFields.push('Sub-Award Date'); - } - }); - - if (props.spendingLevel === 'transactions') { - requestFields.push('awarding_agency_id'); - } - else { - requestFields.push('recipient_id', 'prime_award_recipient_id'); - } - - // parse the redux search order into the API-consumable format - const searchOrder = sort; - let sortDirection = searchOrder.direction; - - if (!sortDirection) { - sortDirection = 'desc'; - } - - if (searchOrder?.field === 'Action Date' && props.spendingLevel !== 'transactions') { - searchOrder.field = 'Sub-Award Date'; - } - - const loadExpandableData = (showToggle && spendingLevel === "awards" && !isMobile); - - let params = { - filters: searchParamsTemp.toParams(), - page: pageNumber, - limit: resultLimit, - sort: "award_id", - order: sortDirection, - auditTrail: 'Results Table - Spending by award search' - }; - - // Set the params needed for download API call - if (!params.filters.award_type_codes) { - return null; - } - - if (loadExpandableData) { - if (props.spendingLevel === 'transactions') { - setColumnType('transactions'); - searchRequest = SearchHelper.performSpendingByTransactionsGrouped(params); - } - else { - setColumnType('subawards'); - searchRequest = SearchHelper.performSpendingBySubawardGrouped(params); - } - } - else { - params = { - ...params, - fields: requestFields, - spending_level: spendingLevel, - sort: searchOrder.field, - page - }; - - if (isTransactions) { - params.fields = [ - "Award ID", - "Mod", - "Recipient Name", - "Transaction Amount", - "Action Date", - "Transaction Description", - "Action Type", - "Award Type", - "Recipient UEI", - "Recipient Location", - "Primary Place of Performance", - "Awarding Agency", - "awarding_agency_id", - "recipient_id", - "Awarding Sub Agency", - "NAICS", - "PSC", - "Assistance Listing" - ]; - - searchRequest = performKeywordSearch(params); - } - else { - searchRequest = SearchHelper.performSpendingByAwardSearch(params); - } - } - - return searchRequest.promise - .then((res) => { - const newState = { - inFlight: false - }; - - const parsedResults = res.data.results.map((result) => ({ - ...result, - generated_internal_id: encodeURIComponent(result.generated_internal_id) - })); - - // don't clear records if we're appending (not the first page) - newState.tableInstance = `${uniqueId()}`; - newState.results = parsedResults; - - if (newSearch) { - setTotal(newState.results.length); - } - - // request is done - searchRequest = null; - newState.page = res.data.page_metadata.page; - newState.lastPage = !res.data.page_metadata.hasNext; - setInFlight(newState.inFlight); - setTableInstance(newState.tableInstance); - setPage(newState.page); - setLastPage(newState.lastPage); - if (loadExpandableData) { - setExpandableData(newState.results); - } - else { - setResults(newState.results); - } - - props.setAppliedFilterCompletion(true); - }) - .catch((err) => { - if (!isCancel(err)) { - setInFlight(false); - setError(true); - props.setAppliedFilterCompletion(true); - console.log(err); - } - }); - }, 400); - - const createColumn = (col) => { - // create an object that integrates with the expected column data structure used by - // the table component - - // BODGE: Temporarily only allow descending columns - const direction = 'desc'; - const width = col.customWidth || measureTableHeader(col.displayName || col.title); - - return { - columnName: col.title, - displayName: col.displayName || col.title, - subtitle: col.subtitle || '', - width, - background: col.background || '', - defaultDirection: direction, - right: col.right || false - }; - }; - - const loadColumns = () => { - // in the future, this will be an API call, but for now, read the local data file - // load every possible table column up front, so we don't need to deal with this when - // switching tabs - const columnsTemp = tableTypes.concat(subTypes).concat(transactionTypes).reduce((cols, type) => { - const visibleColumns = defaultColumns(type.internal).map((data) => data.title); - const parsedColumns = defaultColumns(type.internal).reduce((parsedCols, data) => Object.assign({}, parsedCols, { - [data.title]: createColumn(data) - }), {}); - - return Object.assign(cols, { - [type.internal]: { - visibleOrder: visibleColumns, - data: parsedColumns - } - }); - }, {}); - - setColumns(Object.assign(columns, columnsTemp)); - }; - const updateFilters = throttle(() => { - // the searchParams state var is now only used in the - // block using intersection in performSearch - const newSearch = new SearchAwardsOperation(); - newSearch.fromState(props.filters); - setSearchParams(newSearch); + const isSubaward = spendingLevel === "subawards"; + const loadExpandableData = (isSubaward && spendingLevel === "awards" && !isMobile); + const counts = tabData.results; + + const { + isLoading, + error, + results, + total, + tableInstance, + lastPage + } = useResultsTableSearch( + filters, + tableType, + spendingLevel, + resultLimit, + sort, + loadExpandableData, + page, + columns + ); - setPage(1); - performSearch(true); - }, 350); + const updateFilters = throttle(() => setPage(1), 350); const switchTab = (tab) => { const newState = { @@ -426,8 +154,7 @@ const ResultsTableContainer = (props) => { }); }; - const parseTabCounts = (data) => { - let awardCounts = data.results; + const parseTabCounts = () => { let firstAvailable = ''; let i = 0; let availableTabs = tableTypes; @@ -435,23 +162,12 @@ const ResultsTableContainer = (props) => { if (isSubaward) { availableTabs = subTypes; } - else if (isTransactions) { - availableTabs = transactionTypes; - awardCounts = { - transaction_contracts: data.results.contracts, - transaction_grants: data.results.grants, - transaction_direct_payments: data.results.direct_payments, - transaction_loans: data.results.loans, - transaction_other: data.results.other, - transaction_idvs: data.results.idvs - }; - } // Set the first available award type to the first non-zero entry in the while (firstAvailable === '' && i < availableTabs.length) { const tableTypeTemp = availableTabs[i].internal; - if (awardCounts[tableTypeTemp] > 0) { + if (counts[tableTypeTemp] > 0) { firstAvailable = tableTypeTemp; } @@ -464,56 +180,14 @@ const ResultsTableContainer = (props) => { firstAvailable = availableTabs[0].internal; } - setCounts(Object.assign({}, counts, awardCounts)); switchTab(firstAvailable); updateFilters(); }; - const pickDefaultTab = () => { - // get the award counts for the current filter set - if (tabCountRequest) { - tabCountRequest.cancel(); - } - - if (props.tabData && props.spendingLevel === spendingLevel) { - parseTabCounts(props.tabData); - return; - } - - setInFlight(true); - setError(false); - - const searchParamsTemp = new SearchAwardsOperation(); - searchParamsTemp.fromState(props.filters); - - // if subawards is true, newAwardsOnly cannot be true, so we remove dateType for this request - // also has to be done for the main request, in performSearch - if (isSubaward && searchParamsTemp.dateType) { - delete searchParamsTemp.dateType; - } - - tabCountRequest = SearchHelper.performSpendingByAwardTabCountSearch({ - filters: searchParamsTemp.toParams(), - spending_level: spendingLevel, - auditTrail: 'Award Table - Tab Counts' - }); - - tabCountRequest.promise - .then((res) => { - parseTabCounts(res.data); - }) - .catch((err) => { - if (!isCancel(err)) { - setInFlight(false); - setError(true); - console.log(err); - } - }); - }; const loadNextPage = () => { // check if request is already in-flight - if (inFlight) { + if (isLoading) { // in-flight, ignore this request return; } @@ -526,20 +200,7 @@ const ResultsTableContainer = (props) => { } }; - const updateSort = (field, direction) => { - if (field === 'Action Date' && props.spendingLevel !== 'transactions') { - setSort(Object.assign({ - field: 'Sub-Award Date', - direction - })); - } - else { - setSort(Object.assign({ - field, - direction - })); - } - }; + const updateSort = (field, direction) => setSort({ field, direction }); const awardIdClick = (id) => { Analytics.event({ @@ -559,52 +220,17 @@ const ResultsTableContainer = (props) => { label: id, gtm: true }); - props.subAwardIdClicked(true); + dispatch(subAwardIdClicked(true)); }; - let availableTypes = tableTypes; - - if (isSubaward) { - availableTypes = subTypes; - } - else if (isTransactions) { - availableTypes = transactionTypes; - } + const availableTypes = isSubaward ? subTypes : tableTypes; const tabsWithCounts = availableTypes.map((type) => ({ ...type, count: counts[type.internal], - disabled: inFlight || counts[type.internal] === 0 + disabled: counts[type.internal] === 0 })); - const initialTableLoad = () => { - loadColumns(); - if (SearchHelper.isSearchHashReady(location) && props?.tabData?.results?.length > 0) { - parseTabCounts(props.tabData); - } - else if (SearchHelper.isSearchHashReady(location)) { - pickDefaultTab(); - } - else { - pickDefaultTab(); - } - }; - - const toggleSpendingLevel = () => { - if (spendingLevel === "awards") { - // return back to original. - setSpendingLevel(props.spendingLevel); - setIsSubaward(props.spendingLevel === "subawards"); - setIsTransactions(props.spendingLevel === "transactions"); - setExpandableData([]); - return; - } - - setSpendingLevel("awards"); - setIsSubaward(false); - setIsTransactions(false); - }; - const formattedSubSort = () => { const formattedSort = sort; if (formattedSort?.field === 'Sub-Award Date') { @@ -614,50 +240,19 @@ const ResultsTableContainer = (props) => { return formattedSort; }; - useEffect(throttle(() => { - if (isInitialLoad) { - setIsInitialLoad(false); - initialTableLoad(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, 400), []); - - useEffect(throttle(() => { - if (!isInitialLoad && tableType) { - performSearch(props?.spendingLevel === "subawards"); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, 400), [tableType, sort, resultLimit, page]); - - useEffect(throttle(() => { - if (!isInitialLoad) { - if (isSubaward && !props.noApplied) { - // subaward toggle changed, update the search object - pickDefaultTab(); - } - else if (SearchHelper.isSearchHashReady(location) && location.search) { - // hash is (a) defined and (b) new - pickDefaultTab(); - } - else if (!isSubaward || !isTransactions) { - pickDefaultTab(); - } - } + const onToggleSpendingLevel = () => { + // TODO: fix this function for expandable tables + // IF switching to expandable table AND spendingLevel === "subawards" + // THEN dispatch(setSpendingLevel("awards")) + }; - return () => { - if (searchRequest) { - searchRequest.cancel(); - } - if (tabCountRequest) { - tabCountRequest.cancel(); - } - }; + useEffect(() => { + parseTabCounts(tabData); // eslint-disable-next-line react-hooks/exhaustive-deps - }, 400), [isSubaward, props.noApplied, isTransactions]); + }, [tabData]); useEffect(throttle(() => { if (isLoadingNextPage) { - performSearch(); setLoadNextPage(false); } }, 400), [isLoadingNextPage]); @@ -669,23 +264,25 @@ const ResultsTableContainer = (props) => { return ( { resultsLimit={resultLimit} setResultLimit={setResultLimit} resultsCount={counts[tableType]} - showToggle={showToggle} - expandableData={expandableData} - filters={props.filters} + showToggle + expandableData={loadExpandableData ? results : []} + filters={filters} checkMobile={(isMobileState) => setIsMobile(isMobileState)} - columnType={columnType} + columnType={spendingLevel} subColumnOptions={columns} /> ); -}; +}); ResultsTableContainer.propTypes = propTypes; - -export default connect( - (state) => ({ - filters: state.appliedFilters.filters, - noApplied: state.appliedFilters._empty, - spendingLevel: state.searchView.spendingLevel - }), - (dispatch) => bindActionCreators( - // access multiple redux actions - Object.assign( - {}, - searchActions, - appliedFilterActions, - { subAwardIdClicked } - ), - dispatch - ) -)(ResultsTableContainer); +export default ResultsTableContainer; diff --git a/src/js/containers/search/resultsView/useResultsCount.jsx b/src/js/containers/search/resultsView/useResultsCount.jsx new file mode 100644 index 0000000000..355fbafd9d --- /dev/null +++ b/src/js/containers/search/resultsView/useResultsCount.jsx @@ -0,0 +1,34 @@ +// eslint-disable-next-line no-unused-vars +import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { areFiltersEqual, performSpendingByAwardTabCountSearch } from "helpers/searchHelper"; +import SearchAwardsOperation from 'models/v1/search/SearchAwardsOperation'; + +const useResultsCount = (filters, spendingLevel, hash) => { + const filtersParamsTemp = new SearchAwardsOperation(); + + filtersParamsTemp.fromState(filters); + + // if subawards is true, newAwardsOnly cannot be true, so we remove dateType + if (spendingLevel === 'subawards') { + delete filtersParamsTemp.dateType; + } + + const filtersParams = filtersParamsTemp.toParams(); + + const { data, error } = useQuery({ + queryKey: ['performSpendingByAwardTabCountSearch', filtersParams.toString(), spendingLevel], + queryFn: () => performSpendingByAwardTabCountSearch({ + filters: filtersParams, + spending_level: spendingLevel, + auditTrail: 'Results View - Tab Counts' + }).promise, + staleTime: 60000, + refetchOnWindowFocus: false, + enabled: !areFiltersEqual(filters) || !hash + }); + + return { data, error }; +}; + +export default useResultsCount; diff --git a/src/js/containers/search/resultsView/useResultsTableSearch.jsx b/src/js/containers/search/resultsView/useResultsTableSearch.jsx new file mode 100644 index 0000000000..97215d0bf1 --- /dev/null +++ b/src/js/containers/search/resultsView/useResultsTableSearch.jsx @@ -0,0 +1,160 @@ +// eslint-disable-next-line no-unused-vars +import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { intersection, uniqueId } from 'lodash-es'; + +import { + performSpendingByAwardSearch, performSpendingBySubawardGrouped +} from "helpers/searchHelper"; +import SearchAwardsOperation from 'models/v1/search/SearchAwardsOperation'; +import { + awardTypeGroups, subawardTypeGroups +} from 'dataMapping/search/awardType'; +import { apiFieldByTableColumnName } from 'dataMapping/search/awardTableColumns'; + +const getAwardTypeGroup = (spendingLevel, tableType, awardType) => { + // generate an array of award type codes representing the current table tab we're showing + // and use a different mapping if we're showing a subaward table vs a prime award table + const awardTypeGroup = spendingLevel === "subawards" ? + subawardTypeGroups[tableType] : + awardTypeGroups[tableType]; + + if (awardType.size === 0) { + return awardTypeGroup; + } + + let intersectingTypes = intersection(awardTypeGroup, awardType); + if (!intersectingTypes || intersectingTypes.length === 0) { + // the filtered types and the table type do not align + // in this case, send an array of non-existent types because the endpoint requires + // an award type parameter + intersectingTypes = ['no intersection']; + } + return intersectingTypes; +}; + +const getFields = (tableType, columns) => { + const fields = []; + + // Request fields for visible columns only + const columnVisibility = columns[tableType]?.visibleOrder; + if (!columnVisibility) { + return null; + } + + columnVisibility.forEach((field) => { + if (!fields.includes(field) && field !== "Action Date") { + // Prevent duplicates in the list of fields to request + if (Object.keys(apiFieldByTableColumnName).includes(field)) { + fields.push(apiFieldByTableColumnName[field]); + } + else { + fields.push(field); + } + } + else if (field === "Action Date") { + fields.push('Sub-Award Date'); + } + }); + + fields.push('recipient_id', 'prime_award_recipient_id'); + + return fields; +}; + +const getSortOrder = (searchOrder, grouped) => { + // parse the redux search order into the API-consumable format + let sort = searchOrder?.field; + let order = searchOrder?.direction; + + if (!order) order = 'desc'; + + if (sort === 'Action Date') sort = 'Sub-Award Date'; + + if (grouped) sort = 'award_id'; + + return { sort, order }; +}; + +const useResultsTableSearch = ( + searchFilters, tableType, spendingLevel, limit, searchOrder, grouped, page, columns +) => { + const fields = getFields(tableType, columns); + + const { sort, order } = getSortOrder(searchOrder, grouped); + + const filtersTemp = new SearchAwardsOperation(); + + // get initial searchParams from state + filtersTemp.fromState(searchFilters); + + // if subawards is true, newAwardsOnly cannot be true, so we remove + // dateType for this request; also has to be done for the tabCounts request + if (spendingLevel === "subaward" && filtersTemp.dateType) { + delete filtersTemp.dateType; + } + + filtersTemp.awardType = getAwardTypeGroup( + spendingLevel, tableType, searchFilters.awardType + ); + + const filters = filtersTemp.toParams(); + + const params = { + auditTrail: 'Results Table - Spending by award search', + filters, + limit, + order, + page, + sort + }; + + const { + isPending, error, data + } = useQuery({ + queryKey: [ + grouped ? 'performSpendingBySubawardGrouped' : 'performSpendingByAwardSearch', + limit, + page, + sort, + order, + spendingLevel, + grouped, + tableType, + searchFilters + ], + queryFn: () => { + if (grouped) { + return performSpendingBySubawardGrouped(params).promise; + } + + return performSpendingByAwardSearch({ + ...params, + fields, + spending_level: spendingLevel + }).promise; + }, + staleTime: 60000, + refetchOnWindowFocus: false, + enabled: !!filtersTemp.awardType + }); + + const results = data?.data ? + data?.data.results.map((result) => ({ + ...result, + generated_internal_id: encodeURIComponent(result.generated_internal_id) + })) : + []; + + return { + isLoading: isPending, + error, + results, + total: results?.length, + tableInstance: uniqueId(), + page: data?.data?.page_metadata.page, + lastPage: !data?.data?.page_metadata.hasNext + }; +}; + +export default useResultsTableSearch; diff --git a/src/js/dataMapping/search/resultsView/table.js b/src/js/dataMapping/search/resultsView/table.js new file mode 100644 index 0000000000..faede6cb57 --- /dev/null +++ b/src/js/dataMapping/search/resultsView/table.js @@ -0,0 +1,64 @@ +export const tableTypes = [ + { + label: 'Contracts', + internal: 'contracts' + }, + { + label: 'Contract IDVs', + internal: 'idvs' + }, + { + label: 'Grants', + internal: 'grants' + }, + { + label: 'Direct Payments', + internal: 'direct_payments' + }, + { + label: 'Loans', + internal: 'loans' + }, + { + label: 'Other', + internal: 'other' + } +]; + +export const subTypes = [ + { + label: 'Sub-Contracts', + internal: 'subcontracts' + }, + { + label: 'Sub-Grants', + internal: 'subgrants' + } +]; + +export const transactionTypes = [ + { + label: 'Contracts', + internal: 'transaction_contracts' + }, + { + label: 'Contract IDVs', + internal: 'transaction_idvs' + }, + { + label: 'Grants', + internal: 'transaction_grants' + }, + { + label: 'Direct Payments', + internal: 'transaction_direct_payments' + }, + { + label: 'Loans', + internal: 'transaction_loans' + }, + { + label: 'Other', + internal: 'transaction_other' + } +];