Skip to content

Commit

Permalink
✨ Add Tag Categories to Tag filters (#1535)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
sjd78 authored Nov 9, 2023
1 parent db83950 commit 2af8077
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 115 deletions.
32 changes: 24 additions & 8 deletions client/src/app/components/FilterToolbar/FilterToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TItem, TFilterCategoryKey> {
selectOptions: OptionPropsWithKey[];
/** The full set of options to select from for this filter. */
selectOptions:
| FilterSelectOptionProps[]
| Record<string, FilterSelectOptionProps[]>;
/** Option search input field placeholder text. */
placeholderText?: string;
/** How to connect multiple selected options together. Defaults to "AND". */
logicOperator?: "AND" | "OR";
}

export interface ISelectFilterCategory<TItem, TFilterCategoryKey extends string>
extends IBasicFilterCategory<TItem, TFilterCategoryKey> {
selectOptions: OptionPropsWithKey[];
selectOptions: FilterSelectOptionProps[];
}

export interface ISearchFilterCategory<TItem, TFilterCategoryKey extends string>
Expand Down
157 changes: 94 additions & 63 deletions client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx
Original file line number Diff line number Diff line change
@@ -1,128 +1,159 @@
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<TItem, TFilterCategoryKey> {
category: IMultiselectFilterCategory<TItem, TFilterCategoryKey>;
const CHIP_BREAK_DELINEATOR = " / ";

export interface IMultiselectFilterControlProps<TItem>
extends IFilterControlProps<TItem, string> {
category: IMultiselectFilterCategory<TItem, string>;
isScrollable?: boolean;
}

export const MultiselectFilterControl = <
TItem,
TFilterCategoryKey extends string
>({
export const MultiselectFilterControl = <TItem,>({
category,
filterValue,
setFilterValue,
showToolbarItem,
isDisabled = false,
isScrollable = false,
}: React.PropsWithChildren<
IMultiselectFilterControlProps<TItem, TFilterCategoryKey>
IMultiselectFilterControlProps<TItem>
>): 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 || ""]);
}
}
};

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)
: [];
setFilterValue(newValue.length > 0 ? newValue : null);
};

// 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) => (
<SelectOption {...optionProps} key={optionProps.key} />
));

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 id={`tooltip-chip-${index}`} content={<div>{tooltip}</div>}>
<div>{text}</div>
</Tooltip>
),
} 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 : (
<SelectGroup key={`group-${index}`} label={group}>
{groupFiltered.map((optionProps) => (
<SelectOption {...optionProps} key={optionProps.key} />
))}
</SelectGroup>
);
})
.filter(Boolean)
: flatOptions
.filter((o) => filter(o))
.map((optionProps) => (
<SelectOption {...optionProps} key={optionProps.key} />
));

/**
* 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 (
<ToolbarFilter
id={`filter-control-${category.key}`}
chips={chips}
deleteChip={(_, chip) => onFilterClear(chip as string)}
deleteChip={(_, chip) => onFilterClear(chip)}
categoryName={category.title}
showToolbarItem={showToolbarItem}
>
Expand All @@ -140,7 +171,7 @@ export const MultiselectFilterControl = <
hasInlineFilter
onFilter={onOptionsFilter}
>
{renderSelectOptions(category.selectOptions)}
{renderSelectOptions(() => true)}
</Select>
</ToolbarFilter>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TItem, TFilterCategoryKey> {
category: ISelectFilterCategory<TItem, TFilterCategoryKey>;
isScrollable?: boolean;
Expand Down Expand Up @@ -72,7 +75,7 @@ export const SelectFilterControl = <TItem, TFilterCategoryKey extends string>({

const chips = selections ? selections.map(getChipFromOptionValue) : [];

const renderSelectOptions = (options: OptionPropsWithKey[]) =>
const renderSelectOptions = (options: FilterSelectOptionProps[]) =>
options.map((optionProps) => (
<SelectOption {...optionProps} key={optionProps.key} />
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -169,7 +169,7 @@ export const ApplicationsTable: React.FC = () => {
);
/*** Analysis */

const { tagCategories: tagCategories } = useFetchTagCategories();
const { tagItems } = useFetchTagsWithTagItems();

const [applicationDependenciesToManage, setApplicationDependenciesToManage] =
React.useState<Application | null>(null);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -641,7 +646,7 @@ export const ApplicationsTable: React.FC = () => {
<Toolbar {...toolbarProps}>
<ToolbarContent>
<ToolbarBulkSelector {...toolbarBulkSelectorProps} />
<FilterToolbar {...filterToolbarProps} />
<FilterToolbar<Application, string> {...filterToolbarProps} />
<ToolbarGroup variant="button-group">
<ToolbarItem>
<RBAC
Expand Down
Loading

0 comments on commit 2af8077

Please sign in to comment.