Skip to content

Commit

Permalink
Merge branch 'main' into app-actions-discard-review-fix
Browse files Browse the repository at this point in the history
  • Loading branch information
ibolton336 authored Nov 10, 2023
2 parents 2c066a6 + 2af8077 commit 18ab44a
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 @@ -82,7 +82,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 @@ -173,7 +173,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 @@ -442,17 +442,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 @@ -654,7 +659,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 18ab44a

Please sign in to comment.