Show count of visible results after a column is filtered on live query page (#6199)

This commit is contained in:
gillespi314
2022-06-13 18:20:57 -05:00
committed by GitHub
parent 12d915531e
commit 7621a0b290
7 changed files with 137 additions and 166 deletions
@@ -0,0 +1 @@
* Show count of visible results after a column is filtered on live query page
@@ -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`;
@@ -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(
@@ -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<string, string | number | boolean>;
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()}
</div>
)}
{!renderCount && data && displayCount() && !disableCount ? (
{!renderCount &&
!disableCount &&
(isMultiColumnFilter || displayCount()) ? (
<div
className={`${baseClass}__results-count ${
stackControls ? "stack-table-controls" : ""
@@ -344,8 +351,8 @@ const TableContainer = ({
</div>
<div className={`${baseClass}__data-table-block`}>
{/* 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) ? (
<>
<EmptyComponent pageIndex={pageIndex} />
{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 && (
<EmptyComponent pageIndex={pageIndex} />
)}
<div
className={
isClientSideFilter
isClientSideFilter && !isMultiColumnFilter
? `client-result-count-${clientFilterCount}`
: ""
}
@@ -407,6 +414,7 @@ const TableContainer = ({
selectedDropdownFilter={selectedDropdownFilter}
renderFooter={renderFooter}
renderPagination={renderPagination}
setExportRows={setExportRows}
/>
</div>
</>
@@ -49,14 +49,6 @@
}
}
.table-container {
margin-top: 0;
&__header {
display: none;
}
}
.data-table__wrapper {
overflow-x: scroll;
}
@@ -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<string>(PAGE_TITLES.RUNNING);
const [navTabIndex, setNavTabIndex] = useState(0);
const [
targetsRespondedPercent,
setTargetsRespondedPercent,
] = useState<number>(0);
const [showQueryModal, setShowQueryModal] = useState<boolean>(false);
useEffect(() => {
const calculatePercent =
targetsTotalCount > 0
? Math.round((campaign.hosts_count.total / targetsTotalCount) * 100)
: 0;
setTargetsRespondedPercent(calculatePercent);
}, [campaign]);
const [filteredResults, setFilteredResults] = useState<Row[]>([]);
const [filteredErrors, setFilteredErrors] = useState<Row[]>([]);
useEffect(() => {
if (isQueryFinished) {
@@ -82,43 +100,23 @@ const QueryResults = ({
const onExportQueryResults = (evt: React.MouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 (
<TableContainer
columns={resultsTableHeaders(tableData || [])}
data={tableData || []}
emptyComponent={renderNoResults}
isLoading={false}
disableCount
isClientSidePagination
showMarkAllPages={false}
isAllPagesSelected={false}
resultsTitle={"hosts"}
/>
<div className={`${baseClass}__results-cta`}>
<Button
className={`${baseClass}__show-query-btn`}
onClick={onShowQueryModal}
variant="text-link"
>
<>
Show query <img alt="Show query" src={EyeIcon} />
</>
</Button>
<Button
className={`${baseClass}__export-btn`}
onClick={
tableType === "errors"
? onExportErrorsResults
: onExportQueryResults
}
variant="text-link"
>
<>
{`Export ${tableType}`}{" "}
<img alt={`Export ${tableType}`} src={DownloadIcon} />
</>
</Button>
</div>
);
};
const renderResultsTable = () => {
const emptyResults = !queryResults || !queryResults.length;
const hasNoResultsYet = !isQueryFinished && emptyResults;
const renderTable = (
tableData: unknown[],
tableType: "errors" | "results"
) => {
return (
<div className={`${baseClass}__results-table-container`}>
<TableContainer
columns={resultsTableHeaders(tableData || [])}
data={tableData || []}
emptyComponent={renderNoResults}
isLoading={false}
isClientSidePagination
isClientSideFilter
isMultiColumnFilter
showMarkAllPages={false}
isAllPagesSelected={false}
resultsTitle={tableType === "results" ? "hosts" : tableType}
customControl={() => renderTableButtons(tableType)}
setExportRows={
tableType === "errors" ? setFilteredErrors : setFilteredResults
}
/>
</div>
);
};
const renderResultsTab = () => {
const hasNoResultsYet = !isQueryFinished && !queryResults?.length;
const finishedWithNoResults =
isQueryFinished && (!hostsCount.successful || emptyResults);
isQueryFinished && (!queryResults?.length || !hostsCount.successful);
if (hasNoResultsYet) {
return <Spinner />;
@@ -173,74 +210,10 @@ const QueryResults = ({
return renderNoResults();
}
return (
<div className={`${baseClass}__results-table-container`}>
<div className={`${baseClass}__results-table-header`}>
<span className={`${baseClass}__results-count`}>
{totalRowsCount} result{totalRowsCount !== 1 && "s"}
</span>
<div className={`${baseClass}__results-cta`}>
<Button
className={`${baseClass}__show-query-btn`}
onClick={onShowQueryModal}
variant="text-link"
>
<>
Show query <img alt="Show query" src={EyeIcon} />
</>
</Button>
<Button
className={`${baseClass}__export-btn`}
onClick={onExportQueryResults}
variant="text-link"
>
<>
Export results <img alt="Export results" src={DownloadIcon} />
</>
</Button>
</div>
</div>
{renderTable(queryResults)}
</div>
);
return renderTable(queryResults, "results");
};
const renderErrorsTable = () => {
return (
<div className={`${baseClass}__error-table-container`}>
<div className={`${baseClass}__errors-table-header`}>
{errors && (
<span className={`${baseClass}__error-count`}>
{errors.length} error{errors.length !== 1 && "s"}
</span>
)}
<div className={`${baseClass}__errors-cta`}>
<Button
className={`${baseClass}__show-query-btn`}
onClick={onShowQueryModal}
variant="text-link"
>
<>
Show query <img alt="Show query" src={EyeIcon} />
</>
</Button>
<Button
className={`${baseClass}__export-btn`}
onClick={onExportErrorsResults}
variant="text-link"
>
<>
Export errors <img alt="" src={DownloadIcon} />
</>
</Button>
</div>
</div>
<div className={`${baseClass}__error-table-wrapper`}>
{renderTable(errors)}
</div>
</div>
);
};
const renderErrorsTab = () => renderTable(errors, "errors");
const renderFinishedButtons = () => (
<div className={`${baseClass}__btn-wrapper`}>
@@ -286,7 +259,7 @@ const QueryResults = ({
<h1>{pageTitle}</h1>
<div className={`${baseClass}__text-wrapper`}>
<span>{targetsTotalCount}</span>&nbsp;hosts targeted&nbsp; (
{targetsRespondedPercent}%&nbsp;
{percentResponded}%&nbsp;
<TooltipWrapper
tipContent={`
Hosts that respond may<br /> return results, errors, or <br />no results`}
@@ -310,8 +283,8 @@ const QueryResults = ({
</span>
</Tab>
</TabList>
<TabPanel>{renderResultsTable()}</TabPanel>
<TabPanel>{renderErrorsTable()}</TabPanel>
<TabPanel>{renderResultsTab()}</TabPanel>
<TabPanel>{renderErrorsTab()}</TabPanel>
</Tabs>
</TabsWrapper>
{showQueryModal && <ShowQueryModal onCancel={onShowQueryModal} />}
@@ -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;
}
}