Show count of visible results after a column is filtered on live query page (#6199)
This commit is contained in:
@@ -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> hosts targeted (
|
||||
{targetsRespondedPercent}%
|
||||
{percentResponded}%
|
||||
<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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user