From 7621a0b290c3ae953d5b28682b43f3e3c36e4cc0 Mon Sep 17 00:00:00 2001 From: gillespi314 <73313222+gillespi314@users.noreply.github.com> Date: Mon, 13 Jun 2022 18:20:57 -0500 Subject: [PATCH] Show count of visible results after a column is filtered on live query page (#6199) --- changes/issue-636-filter-live-query-count | 1 + cypress/integration/all/app/queryflow.spec.ts | 4 +- .../TableContainer/DataTable/DataTable.tsx | 8 + .../TableContainer/TableContainer.tsx | 20 +- .../components/QueryResults/_styles.scss | 8 - .../components/QueryResults/QueryResults.tsx | 237 ++++++++---------- .../components/QueryResults/_styles.scss | 25 +- 7 files changed, 137 insertions(+), 166 deletions(-) create mode 100644 changes/issue-636-filter-live-query-count diff --git a/changes/issue-636-filter-live-query-count b/changes/issue-636-filter-live-query-count new file mode 100644 index 0000000000..99d657081e --- /dev/null +++ b/changes/issue-636-filter-live-query-count @@ -0,0 +1 @@ +* Show count of visible results after a column is filtered on live query page \ No newline at end of file diff --git a/cypress/integration/all/app/queryflow.spec.ts b/cypress/integration/all/app/queryflow.spec.ts index a66196c4ff..39674aa1e1 100644 --- a/cypress/integration/all/app/queryflow.spec.ts +++ b/cypress/integration/all/app/queryflow.spec.ts @@ -62,13 +62,13 @@ describe("Query flow (seeded)", () => { cy.findByText(/run/i).click(); // Ensures live query runs cy.wait(10000); // eslint-disable-line cypress/no-unnecessary-waiting - cy.getAttached(".query-results__results-table-header").within(() => { + cy.getAttached(".table-container").within(() => { cy.findByText(/show query/i).click(); }); cy.getAttached(".show-query-modal").within(() => { cy.findByText(/done/i).click(); }); - cy.getAttached(".query-results__results-table-header").within(() => { + cy.getAttached(".table-container").within(() => { const formattedTime = format(new Date(), "MM-dd-yy hh-mm-ss"); cy.findByText(/export results/i).click(); const filename = `Query Results (${formattedTime}).csv`; diff --git a/frontend/components/TableContainer/DataTable/DataTable.tsx b/frontend/components/TableContainer/DataTable/DataTable.tsx index d6017ef8b2..c99aee2a6d 100644 --- a/frontend/components/TableContainer/DataTable/DataTable.tsx +++ b/frontend/components/TableContainer/DataTable/DataTable.tsx @@ -61,6 +61,7 @@ interface IDataTableProps { onResultsCountChange?: (value: number) => void; renderFooter?: () => JSX.Element | null; renderPagination?: () => JSX.Element | null; + setExportRows?: (rows: Row[]) => void; } const CLIENT_SIDE_DEFAULT_PAGE_SIZE = 20; @@ -97,6 +98,7 @@ const DataTable = ({ onResultsCountChange, renderFooter, renderPagination, + setExportRows, }: IDataTableProps): JSX.Element => { const { resetSelectedRows } = useContext(TableContext); const { isOnlyObserver } = useContext(AppContext); @@ -167,6 +169,7 @@ const DataTable = ({ }), [] ), + autoResetFilters: false, // Expands the enumerated `sortTypes` for react-table // (see https://github.com/tannerlinsley/react-table/blob/master/src/sortTypes.js) // with custom `sortTypes` defined for this `useTable` instance @@ -221,9 +224,14 @@ const DataTable = ({ value, })); !!allFilters.length && setAllFilters(allFilters); + setExportRows && setExportRows(rows); } }, [tableFilters]); + useEffect(() => { + setExportRows && setExportRows(rows); + }, [tableState.filters]); + // Listen for changes to filters if clientSideFilter is enabled const setDebouncedClientFilter = useDebouncedCallback( diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx index 59d7dbe6e5..d68e6839f0 100644 --- a/frontend/components/TableContainer/TableContainer.tsx +++ b/frontend/components/TableContainer/TableContainer.tsx @@ -62,6 +62,8 @@ interface ITableContainerProps { selectedDropdownFilter?: string; isClientSidePagination?: boolean; isClientSideFilter?: boolean; + isMultiColumnFilter?: boolean; // isMultiColumnFilter is used to preserve the table headers + // in lieu of displaying the empty component when client-side filtering yields zero results highlightOnHover?: boolean; pageSize?: number; onActionButtonClick?: () => void; @@ -73,6 +75,7 @@ interface ITableContainerProps { filters?: Record; renderCount?: () => JSX.Element | null; renderFooter?: () => JSX.Element | null; + setExportRows?: (rows: Row[]) => void; } const baseClass = "table-container"; @@ -116,6 +119,7 @@ const TableContainer = ({ searchToolTipText, isClientSidePagination, isClientSideFilter, + isMultiColumnFilter, highlightOnHover, pageSize = DEFAULT_PAGE_SIZE, selectedDropdownFilter, @@ -128,6 +132,7 @@ const TableContainer = ({ onSelectSingleRow, renderCount, renderFooter, + setExportRows, }: ITableContainerProps): JSX.Element => { const [searchQuery, setSearchQuery] = useState(""); const [sortHeader, setSortHeader] = useState(defaultSortHeader || ""); @@ -210,7 +215,7 @@ const TableContainer = ({ } else if (typeof clientFilterCount === "number") { return clientFilterCount; } - return data.length; + return data?.length || 0; }, [filteredCount, clientFilterCount, data]); const renderPagination = useCallback(() => { @@ -269,7 +274,9 @@ const TableContainer = ({ {renderCount()} )} - {!renderCount && data && displayCount() && !disableCount ? ( + {!renderCount && + !disableCount && + (isMultiColumnFilter || displayCount()) ? (
{/* No entities for this result. */} - {(!isLoading && data.length === 0) || - (searchQuery.length && data.length === 0) ? ( + {(!isLoading && data.length === 0 && !isMultiColumnFilter) || + (searchQuery.length && data.length === 0 && !isMultiColumnFilter) ? ( <> {pageIndex !== 0 && ( @@ -365,12 +372,12 @@ const TableContainer = ({ <> {/* TODO: Fix this hacky solution to clientside search being 0 rendering emptycomponent but no longer accesses rows.length because DataTable is not rendered */} - {clientFilterCount === 0 && ( + {clientFilterCount === 0 && !isMultiColumnFilter && ( )}
diff --git a/frontend/pages/policies/PolicyPage/components/QueryResults/_styles.scss b/frontend/pages/policies/PolicyPage/components/QueryResults/_styles.scss index f88b9b6ba6..ec231b38d5 100644 --- a/frontend/pages/policies/PolicyPage/components/QueryResults/_styles.scss +++ b/frontend/pages/policies/PolicyPage/components/QueryResults/_styles.scss @@ -49,14 +49,6 @@ } } - .table-container { - margin-top: 0; - - &__header { - display: none; - } - } - .data-table__wrapper { overflow-x: scroll; } diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx b/frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx index de6a45d26f..52ab639b3c 100644 --- a/frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx +++ b/frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx @@ -1,9 +1,9 @@ import React, { useState, useEffect } from "react"; +import { Row } from "react-table"; import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; import classnames from "classnames"; import { format } from "date-fns"; import FileSaver from "file-saver"; -import { filter, get } from "lodash"; import convertToCSV from "utilities/convert_to_csv"; import { ICampaign } from "interfaces/campaign"; @@ -41,6 +41,32 @@ const NAV_TITLES = { ERRORS: "Errors", }; +const reorderCSVFields = (fields: string[]) => { + const result = fields.filter((field) => field !== "host_hostname"); + result.unshift("host_hostname"); + + return result; +}; + +const generateExportCSVFile = (rows: Row[], filename: string) => { + return new global.window.File( + [ + convertToCSV( + rows.map((r) => r.original), + reorderCSVFields + ), + ], + filename, + { + type: "text/csv", + } + ); +}; + +const generateExportFilename = (descriptor: string) => { + return `${descriptor} (${format(new Date(), "MM-dd-yy hh-mm-ss")}).csv`; +}; + const QueryResults = ({ campaign, isQueryFinished, @@ -52,24 +78,16 @@ const QueryResults = ({ }: IQueryResultsProps): JSX.Element => { const { hosts_count: hostsCount, query_results: queryResults, errors } = campaign || {}; - - const totalRowsCount = get(campaign, ["query_results", "length"], 0); + const percentResponded = + targetsTotalCount > 0 + ? Math.round((hostsCount.total / targetsTotalCount) * 100) + : 0; const [pageTitle, setPageTitle] = useState(PAGE_TITLES.RUNNING); const [navTabIndex, setNavTabIndex] = useState(0); - const [ - targetsRespondedPercent, - setTargetsRespondedPercent, - ] = useState(0); const [showQueryModal, setShowQueryModal] = useState(false); - - useEffect(() => { - const calculatePercent = - targetsTotalCount > 0 - ? Math.round((campaign.hosts_count.total / targetsTotalCount) * 100) - : 0; - setTargetsRespondedPercent(calculatePercent); - }, [campaign]); + const [filteredResults, setFilteredResults] = useState([]); + const [filteredErrors, setFilteredErrors] = useState([]); useEffect(() => { if (isQueryFinished) { @@ -82,43 +100,23 @@ const QueryResults = ({ const onExportQueryResults = (evt: React.MouseEvent) => { evt.preventDefault(); - if (queryResults) { - const csv = convertToCSV(queryResults, (fields: string[]) => { - const result = filter(fields, (f) => f !== "host_hostname"); - result.unshift("host_hostname"); - - return result; - }); - - const formattedTime = format(new Date(), "MM-dd-yy hh-mm-ss"); - const filename = `${CSV_QUERY_TITLE} (${formattedTime}).csv`; - const file = new global.window.File([csv], filename, { - type: "text/csv", - }); - - FileSaver.saveAs(file); - } + FileSaver.saveAs( + generateExportCSVFile( + filteredResults, + generateExportFilename(CSV_QUERY_TITLE) + ) + ); }; const onExportErrorsResults = (evt: React.MouseEvent) => { evt.preventDefault(); - if (errors) { - const csv = convertToCSV(errors, (fields: string[]) => { - const result = filter(fields, (f) => f !== "host_hostname"); - result.unshift("host_hostname"); - - return result; - }); - - const formattedTime = format(new Date(), "MM-dd-yy hh-mm-ss"); - const filename = `${CSV_QUERY_TITLE} Errors (${formattedTime}).csv`; - const file = new global.window.File([csv], filename, { - type: "text/csv", - }); - - FileSaver.saveAs(file); - } + FileSaver.saveAs( + generateExportCSVFile( + filteredErrors, + generateExportFilename(`${CSV_QUERY_TITLE} Errors`) + ) + ); }; const onShowQueryModal = () => { @@ -143,27 +141,66 @@ const QueryResults = ({ ); }; - const renderTable = (tableData: unknown[]) => { + const renderTableButtons = (tableType: "results" | "errors") => { return ( - +
+ + +
); }; - const renderResultsTable = () => { - const emptyResults = !queryResults || !queryResults.length; - const hasNoResultsYet = !isQueryFinished && emptyResults; + const renderTable = ( + tableData: unknown[], + tableType: "errors" | "results" + ) => { + return ( +
+ renderTableButtons(tableType)} + setExportRows={ + tableType === "errors" ? setFilteredErrors : setFilteredResults + } + /> +
+ ); + }; + + const renderResultsTab = () => { + const hasNoResultsYet = !isQueryFinished && !queryResults?.length; const finishedWithNoResults = - isQueryFinished && (!hostsCount.successful || emptyResults); + isQueryFinished && (!queryResults?.length || !hostsCount.successful); if (hasNoResultsYet) { return ; @@ -173,74 +210,10 @@ const QueryResults = ({ return renderNoResults(); } - return ( -
-
- - {totalRowsCount} result{totalRowsCount !== 1 && "s"} - -
- - -
-
- {renderTable(queryResults)} -
- ); + return renderTable(queryResults, "results"); }; - const renderErrorsTable = () => { - return ( -
-
- {errors && ( - - {errors.length} error{errors.length !== 1 && "s"} - - )} -
- - -
-
-
- {renderTable(errors)} -
-
- ); - }; + const renderErrorsTab = () => renderTable(errors, "errors"); const renderFinishedButtons = () => (
@@ -286,7 +259,7 @@ const QueryResults = ({

{pageTitle}

{targetsTotalCount} hosts targeted  ( - {targetsRespondedPercent}%  + {percentResponded}%  return results, errors, or
no results`} @@ -310,8 +283,8 @@ const QueryResults = ({ - {renderResultsTable()} - {renderErrorsTable()} + {renderResultsTab()} + {renderErrorsTab()} {showQueryModal && } diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss b/frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss index a460eb1331..949df4a5e1 100644 --- a/frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss +++ b/frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss @@ -35,6 +35,10 @@ } } + &__results-cta { + display: flex; + } + &__results-cta > *:not(:last-child), &__errors-cta > *:not(:last-child) { margin-right: $pad-medium; @@ -55,12 +59,6 @@ } } - .table-container { - &__header { - display: none; - } - } - .data-table__wrapper { overflow-x: scroll; } @@ -85,17 +83,8 @@ } } - &__results-table-header, - &__errors-table-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: $pad-xlarge; - } - - &__results-count, - &__error-count { - font-size: $x-small; - font-weight: $bold; + &__results-table-container, + &__error-table-container { + margin-top: 32px; } }