From 2af8077b9d97b14cfc84e216e6f7183e48fe45cc Mon Sep 17 00:00:00 2001 From: Scott Dickerson Date: Thu, 9 Nov 2023 15:59:48 -0500 Subject: [PATCH] :sparkles: Add Tag Categories to Tag filters (#1535) Resolves: https://issues.redhat.com/browse/MTA-1534 On the following tables, the Tag filter has been enhanced to show the Tag with Tag Category: - Applications - Issues / All Issues - Dependencies - Dependency Details Drawer / Application Table Tables that use Tag filters now use a set of options in the format "Tag Category Name / Tag Name". The filter render component `MultiselectFilterControl` has been updated to have special handling of selected values where the selection chips display the Tag Name and the Tag Category Name is displayed as a tooltip. Existing item filtering and filter values are still operating as a `string` or `string[]`, but use a string that can uniquely identify each tag. The net effect means that if a tag of the same name exists in more than one category, they are unique. Selecting one of those Tags will only select the tag in that specific category. Additional change details: - Type `OptionPropsWithKey` renamed `FilterSelectOptionProps` for more consistent type/component naming - Added some jsdoc to `FilterToolbar` --------- Signed-off-by: Scott J Dickerson --- .../FilterToolbar/FilterToolbar.tsx | 32 +++- .../MultiselectFilterControl.tsx | 157 +++++++++++------- .../FilterToolbar/SelectFilterControl.tsx | 9 +- .../applications-table/applications-table.tsx | 29 ++-- .../dependencies/dependency-apps-table.tsx | 19 ++- client/src/app/pages/issues/helpers.ts | 39 ++--- client/src/app/queries/tags.ts | 5 + 7 files changed, 175 insertions(+), 115 deletions(-) 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,