Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add Tag Categories to Tag filters #1535

Merged
merged 4 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);

Check warning on line 41 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L40-L41

Added lines #L40 - L41 were not covered by tests
const flatOptions = !hasGroupings
? selectOptions
: Object.values(selectOptions).flatMap((i) => i);

Check warning on line 44 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L43-L44

Added lines #L43 - L44 were not covered by tests

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;

Check warning on line 48 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L48

Added line #L48 was not covered by tests

const getOptionKeyFromChip = (chip: string) =>
category.selectOptions.find(
(optionProps) => optionProps.value.toString() === chip
)?.key;
flatOptions.find(({ value }) => value.toString() === chip)?.key;

Check warning on line 51 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L51

Added line #L51 was not covered by tests

const getOptionValueFromOptionKey = (optionKey: string) =>
category.selectOptions.find((optionProps) => optionProps.key === optionKey)
?.value;
flatOptions.find(({ key }) => key === optionKey)?.value;

Check warning on line 54 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L54

Added line #L54 was not covered by tests

const onFilterSelect = (value: string | SelectOptionObject) => {
const optionKey = getOptionKeyFromOptionValue(value);
if (optionKey && filterValue?.includes(optionKey)) {
let updatedValues = filterValue.filter(
const updatedValues = filterValue.filter(

Check warning on line 59 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L59

Added line #L59 was not covered by tests
(item: string) => item !== optionKey
);
setFilterValue(updatedValues);
} else {
if (filterValue) {
let updatedValues = [...filterValue, optionKey];
const updatedValues = [...filterValue, optionKey];

Check warning on line 65 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L65

Added line #L65 was not covered by tests
setFilterValue(updatedValues as string[]);
} else {
setFilterValue([optionKey || ""]);
}
}
};

const onFilterClear = (chip: string) => {
const optionKey = getOptionKeyFromChip(chip);
const onFilterClear = (chip: string | ToolbarChip) => {

Check warning on line 73 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L73

Added line #L73 was not covered by tests
const chipKey = typeof chip === "string" ? chip : chip.key;
const optionKey = getOptionKeyFromChip(chipKey);

Check warning on line 75 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L75

Added line #L75 was not covered by tests
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) => {

Check warning on line 90 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L90

Added line #L90 was not covered by tests
const chip: string = s?.toString() ?? "";
const idx = chip.indexOf(CHIP_BREAK_DELINEATOR);

Check warning on line 92 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L92

Added line #L92 was not covered by tests

if (idx > 0) {
const tooltip = chip.substring(0, idx);
const text = chip.substring(idx + CHIP_BREAK_DELINEATOR.length);
return {

Check warning on line 97 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L95-L97

Added lines #L95 - L97 were not covered by tests
key: chip,
node: (
<Tooltip id={`tooltip-chip-${index}`} content={<div>{tooltip}</div>}>
<div>{text}</div>
</Tooltip>
),
} as ToolbarChip;
}
return chip;

Check warning on line 106 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L106

Added line #L106 was not covered by tests
});

const renderSelectOptions = (

Check warning on line 109 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L109

Added line #L109 was not covered by tests
filter: (option: FilterSelectOptionProps, groupName?: string) => boolean
) =>
hasGroupings
? Object.entries(selectOptions)
.sort(([groupA], [groupB]) => groupA.localeCompare(groupB))
.map(([group, options], index) => {

Check warning on line 115 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L113-L115

Added lines #L113 - L115 were not covered by tests
const groupFiltered =
options?.filter((o) => filter(o, group)) ?? [];
return groupFiltered.length == 0 ? undefined : (
<SelectGroup key={`group-${index}`} label={group}>

Check warning on line 119 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L119

Added line #L119 was not covered by tests
{groupFiltered.map((optionProps) => (
<SelectOption {...optionProps} key={optionProps.key} />

Check warning on line 121 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L121

Added line #L121 was not covered by tests
))}
</SelectGroup>
);
})
.filter(Boolean)
: flatOptions
.filter((o) => filter(o))

Check warning on line 128 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L127-L128

Added lines #L127 - L128 were not covered by tests
.map((optionProps) => (
<SelectOption {...optionProps} key={optionProps.key} />

Check warning on line 130 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L130

Added line #L130 was not covered by tests
));

/**
* 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();

Check warning on line 138 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L137-L138

Added lines #L137 - L138 were not covered by tests

return renderSelectOptions((optionProps, groupName) => {

Check warning on line 140 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L140

Added line #L140 was not covered by tests
if (!input) return false;

// TODO: Checking for a filter match against the key or the value may not be desirable.
return (

Check warning on line 144 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L144

Added line #L144 was not covered by tests
groupName?.toLowerCase().includes(input) ||
optionProps?.key?.toLowerCase().includes(input) ||
optionProps?.value?.toString().toLowerCase().includes(input)

Check warning on line 147 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L146-L147

Added lines #L146 - L147 were not covered by tests
);
});
};

return (
<ToolbarFilter
id={`filter-control-${category.key}`}
chips={chips}
deleteChip={(_, chip) => onFilterClear(chip as string)}
deleteChip={(_, chip) => onFilterClear(chip)}

Check warning on line 156 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L156

Added line #L156 was not covered by tests
categoryName={category.title}
showToolbarItem={showToolbarItem}
>
Expand All @@ -140,7 +171,7 @@
hasInlineFilter
onFilter={onOptionsFilter}
>
{renderSelectOptions(category.selectOptions)}
{renderSelectOptions(() => true)}

Check warning on line 174 in client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx#L174

Added line #L174 was not covered by tests
</Select>
</ToolbarFilter>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
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 @@

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

const renderSelectOptions = (options: OptionPropsWithKey[]) =>
const renderSelectOptions = (options: FilterSelectOptionProps[]) =>

Check warning on line 78 in client/src/app/components/FilterToolbar/SelectFilterControl.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/FilterToolbar/SelectFilterControl.tsx#L78

Added line #L78 was not covered by tests
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
Loading