diff --git a/client/src/app/components/FilterToolbar/FilterToolbar.tsx b/client/src/app/components/FilterToolbar/FilterToolbar.tsx index dc3f17f6c8..8987a63432 100644 --- a/client/src/app/components/FilterToolbar/FilterToolbar.tsx +++ b/client/src/app/components/FilterToolbar/FilterToolbar.tsx @@ -22,35 +22,51 @@ export enum FilterType { export type FilterValue = string[] | undefined | null; -export interface OptionPropsWithKey extends SelectOptionProps { +export interface FilterSelectOptionProps extends SelectOptionProps { key: string; } export interface IBasicFilterCategory< - TItem, // The actual API objects we're filtering + /** The actual API objects we're filtering */ + TItem, TFilterCategoryKey extends string, // Unique identifiers for each filter category (inferred from key properties if possible) > { - key: TFilterCategoryKey; // For use in the filterValues state object. Must be unique per category. + /** For use in the filterValues state object. Must be unique per category. */ + key: TFilterCategoryKey; + /** Title of the filter as displayed in the filter selection dropdown and filter chip groups. */ title: string; - type: FilterType; // If we want to support arbitrary filter types, this could be a React node that consumes context instead of an enum + /** Type of filter component to use to select the filter's content. */ + type: FilterType; + /** Optional grouping to display this filter in the filter selection dropdown. */ filterGroup?: string; + /** For client side filtering, return the value of `TItem` the filter will be applied against. */ getItemValue?: (item: TItem) => string | boolean; // For client-side filtering - serverFilterField?: string; // For server-side filtering, defaults to `key` if omitted. Does not need to be unique if the server supports joining repeated filters. - getServerFilterValue?: (filterValue: FilterValue) => FilterValue; // For server-side filtering. Defaults to using the UI state's value if omitted. + /** For server-side filtering, defaults to `key` if omitted. Does not need to be unique if the server supports joining repeated filters. */ + serverFilterField?: string; + /** + * For server-side filtering, return the search value for currently selected filter items. + * Defaults to using the UI state's value if omitted. + */ + getServerFilterValue?: (filterValue: FilterValue) => string[] | undefined; } export interface IMultiselectFilterCategory< TItem, TFilterCategoryKey extends string, > extends IBasicFilterCategory { - selectOptions: OptionPropsWithKey[]; + /** The full set of options to select from for this filter. */ + selectOptions: + | FilterSelectOptionProps[] + | Record; + /** Option search input field placeholder text. */ placeholderText?: string; + /** How to connect multiple selected options together. Defaults to "AND". */ logicOperator?: "AND" | "OR"; } export interface ISelectFilterCategory extends IBasicFilterCategory { - selectOptions: OptionPropsWithKey[]; + selectOptions: FilterSelectOptionProps[]; } export interface ISearchFilterCategory diff --git a/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx b/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx index 338225e773..f79e677df7 100644 --- a/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx +++ b/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx @@ -1,34 +1,31 @@ import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { ToolbarFilter } from "@patternfly/react-core"; +import { ToolbarChip, ToolbarFilter, Tooltip } from "@patternfly/react-core"; import { Select, SelectOption, SelectOptionObject, SelectVariant, SelectProps, + SelectGroup, } from "@patternfly/react-core/deprecated"; import { IFilterControlProps } from "./FilterControl"; import { IMultiselectFilterCategory, - OptionPropsWithKey, + FilterSelectOptionProps, } from "./FilterToolbar"; import { css } from "@patternfly/react-styles"; import "./select-overrides.css"; -export interface IMultiselectFilterControlProps< - TItem, - TFilterCategoryKey extends string -> extends IFilterControlProps { - category: IMultiselectFilterCategory; +const CHIP_BREAK_DELINEATOR = " / "; + +export interface IMultiselectFilterControlProps + extends IFilterControlProps { + category: IMultiselectFilterCategory; isScrollable?: boolean; } -export const MultiselectFilterControl = < - TItem, - TFilterCategoryKey extends string ->({ +export const MultiselectFilterControl = ({ category, filterValue, setFilterValue, @@ -36,42 +33,36 @@ export const MultiselectFilterControl = < isDisabled = false, isScrollable = false, }: React.PropsWithChildren< - IMultiselectFilterControlProps + IMultiselectFilterControlProps >): JSX.Element | null => { - const { t } = useTranslation(); - const [isFilterDropdownOpen, setIsFilterDropdownOpen] = React.useState(false); + const { selectOptions } = category; + const hasGroupings = !Array.isArray(selectOptions); + const flatOptions = !hasGroupings + ? selectOptions + : Object.values(selectOptions).flatMap((i) => i); + const getOptionKeyFromOptionValue = ( optionValue: string | SelectOptionObject - ) => - category.selectOptions.find( - (optionProps) => optionProps.value === optionValue - )?.key; - - const getChipFromOptionValue = ( - optionValue: string | SelectOptionObject | undefined - ) => (optionValue ? optionValue.toString() : ""); + ) => flatOptions.find(({ value }) => value === optionValue)?.key; const getOptionKeyFromChip = (chip: string) => - category.selectOptions.find( - (optionProps) => optionProps.value.toString() === chip - )?.key; + flatOptions.find(({ value }) => value.toString() === chip)?.key; const getOptionValueFromOptionKey = (optionKey: string) => - category.selectOptions.find((optionProps) => optionProps.key === optionKey) - ?.value; + flatOptions.find(({ key }) => key === optionKey)?.value; const onFilterSelect = (value: string | SelectOptionObject) => { const optionKey = getOptionKeyFromOptionValue(value); if (optionKey && filterValue?.includes(optionKey)) { - let updatedValues = filterValue.filter( + const updatedValues = filterValue.filter( (item: string) => item !== optionKey ); setFilterValue(updatedValues); } else { if (filterValue) { - let updatedValues = [...filterValue, optionKey]; + const updatedValues = [...filterValue, optionKey]; setFilterValue(updatedValues as string[]); } else { setFilterValue([optionKey || ""]); @@ -79,8 +70,9 @@ export const MultiselectFilterControl = < } }; - const onFilterClear = (chip: string) => { - const optionKey = getOptionKeyFromChip(chip); + const onFilterClear = (chip: string | ToolbarChip) => { + const chipKey = typeof chip === "string" ? chip : chip.key; + const optionKey = getOptionKeyFromChip(chipKey); const newValue = filterValue ? filterValue.filter((val) => val !== optionKey) : []; @@ -88,41 +80,80 @@ export const MultiselectFilterControl = < }; // Select expects "selections" to be an array of the "value" props from the relevant optionProps - const selections = filterValue - ? filterValue.map(getOptionValueFromOptionKey) - : null; - - const chips = selections ? selections.map(getChipFromOptionValue) : []; - - const renderSelectOptions = (options: OptionPropsWithKey[]) => - options.map((optionProps) => ( - - )); - - const onOptionsFilter: SelectProps["onFilter"] = (_event, textInput) => - renderSelectOptions( - category.selectOptions.filter((optionProps) => { - // Note: The in-dropdown filter can match the option's key or value. This may not be desirable? - if (!textInput) return false; - const optionValue = optionProps?.value?.toString(); - return ( - optionProps?.key?.toLowerCase().includes(textInput.toLowerCase()) || - optionValue.toLowerCase().includes(textInput.toLowerCase()) - ); - }) - ); - - const placeholderText = - category.placeholderText || - `${t("actions.filterBy", { - what: category.title, - })}...`; + const selections = filterValue?.map(getOptionValueFromOptionKey) ?? []; + + /* + * Note: Chips can be a `ToolbarChip` or a plain `string`. Use a hack to split a + * selected option in 2 parts. Assuming the option is in the format "Group / Item" + * break the text and show a chip with the Item and the Group as a tooltip. + */ + const chips = selections.map((s, index) => { + const chip: string = s?.toString() ?? ""; + const idx = chip.indexOf(CHIP_BREAK_DELINEATOR); + + if (idx > 0) { + const tooltip = chip.substring(0, idx); + const text = chip.substring(idx + CHIP_BREAK_DELINEATOR.length); + return { + key: chip, + node: ( + {tooltip}}> +
{text}
+
+ ), + } as ToolbarChip; + } + return chip; + }); + + const renderSelectOptions = ( + filter: (option: FilterSelectOptionProps, groupName?: string) => boolean + ) => + hasGroupings + ? Object.entries(selectOptions) + .sort(([groupA], [groupB]) => groupA.localeCompare(groupB)) + .map(([group, options], index) => { + const groupFiltered = + options?.filter((o) => filter(o, group)) ?? []; + return groupFiltered.length == 0 ? undefined : ( + + {groupFiltered.map((optionProps) => ( + + ))} + + ); + }) + .filter(Boolean) + : flatOptions + .filter((o) => filter(o)) + .map((optionProps) => ( + + )); + + /** + * Render options (with categories if available) where the option value OR key includes + * the filterInput. + */ + const onOptionsFilter: SelectProps["onFilter"] = (_event, textInput) => { + const input = textInput?.toLowerCase(); + + return renderSelectOptions((optionProps, groupName) => { + if (!input) return false; + + // TODO: Checking for a filter match against the key or the value may not be desirable. + return ( + groupName?.toLowerCase().includes(input) || + optionProps?.key?.toLowerCase().includes(input) || + optionProps?.value?.toString().toLowerCase().includes(input) + ); + }); + }; return ( onFilterClear(chip as string)} + deleteChip={(_, chip) => onFilterClear(chip)} categoryName={category.title} showToolbarItem={showToolbarItem} > @@ -140,7 +171,7 @@ export const MultiselectFilterControl = < hasInlineFilter onFilter={onOptionsFilter} > - {renderSelectOptions(category.selectOptions)} + {renderSelectOptions(() => true)} ); diff --git a/client/src/app/components/FilterToolbar/SelectFilterControl.tsx b/client/src/app/components/FilterToolbar/SelectFilterControl.tsx index 9c7d6362a4..3e0ed1abb1 100644 --- a/client/src/app/components/FilterToolbar/SelectFilterControl.tsx +++ b/client/src/app/components/FilterToolbar/SelectFilterControl.tsx @@ -6,14 +6,17 @@ import { SelectOptionObject, } from "@patternfly/react-core/deprecated"; import { IFilterControlProps } from "./FilterControl"; -import { ISelectFilterCategory, OptionPropsWithKey } from "./FilterToolbar"; +import { + ISelectFilterCategory, + FilterSelectOptionProps, +} from "./FilterToolbar"; import { css } from "@patternfly/react-styles"; import "./select-overrides.css"; export interface ISelectFilterControlProps< TItem, - TFilterCategoryKey extends string + TFilterCategoryKey extends string, > extends IFilterControlProps { category: ISelectFilterCategory; isScrollable?: boolean; @@ -72,7 +75,7 @@ export const SelectFilterControl = ({ const chips = selections ? selections.map(getChipFromOptionValue) : []; - const renderSelectOptions = (options: OptionPropsWithKey[]) => + const renderSelectOptions = (options: FilterSelectOptionProps[]) => options.map((optionProps) => ( )); diff --git a/client/src/app/pages/applications/applications-table/applications-table.tsx b/client/src/app/pages/applications/applications-table/applications-table.tsx index 71eac890f8..87427ed8ae 100644 --- a/client/src/app/pages/applications/applications-table/applications-table.tsx +++ b/client/src/app/pages/applications/applications-table/applications-table.tsx @@ -78,7 +78,7 @@ import { useCancelTaskMutation, useFetchTasks } from "@app/queries/tasks"; import { useDeleteAssessmentMutation } from "@app/queries/assessments"; import { useDeleteReviewMutation } from "@app/queries/reviews"; import { useFetchIdentities } from "@app/queries/identities"; -import { useFetchTagCategories } from "@app/queries/tags"; +import { useFetchTagsWithTagItems } from "@app/queries/tags"; // Relative components import { ApplicationAssessmentStatus } from "../components/application-assessment-status"; @@ -169,7 +169,7 @@ export const ApplicationsTable: React.FC = () => { ); /*** Analysis */ - const { tagCategories: tagCategories } = useFetchTagCategories(); + const { tagItems } = useFetchTagsWithTagItems(); const [applicationDependenciesToManage, setApplicationDependenciesToManage] = React.useState(null); @@ -429,17 +429,22 @@ export const ApplicationsTable: React.FC = () => { t("actions.filterBy", { what: t("terms.tagName").toLowerCase(), }) + "...", + selectOptions: tagItems.map(({ name }) => ({ key: name, value: name })), + /** + * Create a single string from an Application's Tags that can be used to + * match against the `selectOptions`'s values (here on the client side) + */ getItemValue: (item) => { - const tagNames = item?.tags?.map((tag) => tag.name).join(""); - return tagNames || ""; + const appTagItems = item?.tags + ?.map(({ id }) => tagItems.find((item) => id === item.id)) + .filter(Boolean); + + const matchString = !appTagItems + ? "" + : appTagItems.map(({ name }) => name).join("^"); + + return matchString; }, - selectOptions: dedupeFunction( - tagCategories - ?.map((tagCategory) => tagCategory?.tags) - .flat() - .filter((tag) => tag && tag.name) - .map((tag) => ({ key: tag?.name, value: tag?.name })) - ), }, ], initialItemsPerPage: 10, @@ -641,7 +646,7 @@ export const ApplicationsTable: React.FC = () => { - + {...filterToolbarProps} /> = ({ }) => { const { t } = useTranslation(); const { businessServices } = useFetchBusinessServices(); - const { tags } = useFetchTags(); + const { tagItems } = useFetchTagsWithTagItems(); const tableControlState = useTableControlState({ persistTo: "urlParams", @@ -54,7 +54,7 @@ export const DependencyAppsTable: React.FC = ({ type: FilterType.search, placeholderText: t("actions.filterBy", { - what: "name", // TODO i18n + what: t("terms.name").toLowerCase(), }) + "...", getServerFilterValue: (value) => (value ? [`*${value[0]}*`] : []), }, @@ -78,9 +78,16 @@ export const DependencyAppsTable: React.FC = ({ t("actions.filterBy", { what: t("terms.tagName").toLowerCase(), }) + "...", - selectOptions: [...new Set(tags.map((tag) => tag.name))].map( - (tagName) => ({ key: tagName, value: tagName }) - ), + selectOptions: tagItems.map(({ name }) => ({ key: name, value: name })), + /** + * Convert the selected `selectOptions` to an array of tag ids the server side + * filtering will understand. + */ + getServerFilterValue: (selectedOptions) => + selectedOptions + ?.map((option) => tagItems.find((item) => option === item.name)) + .filter(Boolean) + .map(({ id }) => String(id)) ?? [], }, ], initialItemsPerPage: 10, diff --git a/client/src/app/pages/issues/helpers.ts b/client/src/app/pages/issues/helpers.ts index 6cec0692c2..c02a7db658 100644 --- a/client/src/app/pages/issues/helpers.ts +++ b/client/src/app/pages/issues/helpers.ts @@ -18,7 +18,7 @@ import { Paths } from "@app/Paths"; import { TablePersistenceKeyPrefix } from "@app/Constants"; import { IssueFilterGroups } from "./issues"; import { useFetchBusinessServices } from "@app/queries/businessservices"; -import { useFetchTags } from "@app/queries/tags"; +import { useFetchTagsWithTagItems } from "@app/queries/tags"; import { useTranslation } from "react-i18next"; // Certain filters are shared between the Issues page and the Affected Applications Page. @@ -30,18 +30,14 @@ const filterKeysToCarry = [ "businessService.name", "tag.id", ] as const; -type IssuesFilterKeyToCarry = (typeof filterKeysToCarry)[number]; -export type IssuesFilterValuesToCarry = Partial< - Record ->; +export type IssuesFilterValuesToCarry = Partial>; -export const useSharedAffectedApplicationFilterCategories = (): FilterCategory< - unknown, - IssuesFilterKeyToCarry ->[] => { +export const useSharedAffectedApplicationFilterCategories = < + TItem, +>(): FilterCategory[] => { const { t } = useTranslation(); - const { tags } = useFetchTags(); const { businessServices } = useFetchBusinessServices(); + const { tagCategories, tags, tagItems } = useFetchTagsWithTagItems(); return [ { @@ -77,19 +73,16 @@ export const useSharedAffectedApplicationFilterCategories = (): FilterCategory< t("actions.filterBy", { what: t("terms.tagName").toLowerCase(), }) + "...", - selectOptions: [...new Set(tags.map((tag) => tag.name))].map( - (tagName) => ({ key: tagName, value: tagName }) - ), - // NOTE: The same tag name can appear in multiple tag categories. - // To replicate the behavior of the app inventory page, selecting a tag name - // will perform an OR filter matching all tags with that name across tag categories. - // In the future we may instead want to present the tag select options to the user in category sections. - getServerFilterValue: (tagNames) => - tagNames?.flatMap((tagName) => - tags - .filter((tag) => tag.name === tagName) - .map((tag) => String(tag.id)) - ), + selectOptions: tagItems.map(({ name }) => ({ key: name, value: name })), + /** + * Convert the selected `selectOptions` to an array of tag ids the server side + * filtering will understand. + */ + getServerFilterValue: (selectedOptions) => + selectedOptions + ?.map((option) => tagItems.find((item) => option === item.name)) + .filter(Boolean) + .map(({ id }) => String(id)) ?? [], }, ]; }; diff --git a/client/src/app/queries/tags.ts b/client/src/app/queries/tags.ts index 0eb3ada293..05183a7806 100644 --- a/client/src/app/queries/tags.ts +++ b/client/src/app/queries/tags.ts @@ -52,6 +52,8 @@ export const useFetchTagCategories = () => { export interface TagItemType { id: number; name: string; + categoryName: string; + tagName: string; tooltip?: string; } @@ -81,6 +83,8 @@ export const useFetchTagsWithTagItems = () => { return tags .map((tag) => ({ id: tag.id, + categoryName: tag.category?.name ?? "", + tagName: tag.name, name: `${tag.category?.name} / ${tag.name}`, tooltip: tag.category?.name, })) @@ -88,6 +92,7 @@ export const useFetchTagsWithTagItems = () => { }, [tags]); return { + tagCategories, tags, tagItems, isFetching,