diff --git a/client/src/app/api/rest.ts b/client/src/app/api/rest.ts index 5ea61fe4..8906db10 100644 --- a/client/src/app/api/rest.ts +++ b/client/src/app/api/rest.ts @@ -1,35 +1,38 @@ import axios from "axios"; -import { serializeRequestParamsForHub } from "@app/hooks/table-controls"; +import { serializeRequestParamsForHub } from "@app/hooks/table-controls/getHubRequestParams"; import { Advisory, CVE, - SBOM, HubPaginatedResult, HubRequestParams, Package, + SBOM, } from "./models"; const HUB = "/hub"; -export const ADVISORIES = HUB + "/advisories"; +export const ADVISORIES = HUB + "/api/v1/search/advisory"; export const CVES = HUB + "/cves"; export const SBOMS = HUB + "/sboms"; export const PACKAGES = HUB + "/packages"; +export interface PaginatedResponse { + items: T[]; + total: number; +} + export const getHubPaginatedResult = ( url: string, params: HubRequestParams = {} ): Promise> => axios - .get(url, { + .get>(url, { params: serializeRequestParamsForHub(params), }) - .then(({ data, headers }) => ({ - data, - total: headers["x-total"] - ? parseInt(headers["x-total"], 10) - : data.length, + .then(({ data }) => ({ + data: data.items, + total: data.total, params, })); diff --git a/client/src/app/common/types.ts b/client/src/app/common/types.ts new file mode 100644 index 00000000..2f6e5bcd --- /dev/null +++ b/client/src/app/common/types.ts @@ -0,0 +1,9 @@ +export interface Page { + page: number; + perPage: number; +} + +export interface SortBy { + index: number; + direction: 'asc' | 'desc'; +} diff --git a/client/src/app/components/FilterToolbar/FilterControl.tsx b/client/src/app/components/FilterToolbar/FilterControl.tsx new file mode 100644 index 00000000..52a0f73b --- /dev/null +++ b/client/src/app/components/FilterToolbar/FilterControl.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; + +import { + FilterCategory, + FilterValue, + FilterType, + ISelectFilterCategory, + ISearchFilterCategory, + IMultiselectFilterCategory, +} from "./FilterToolbar"; +import { SelectFilterControl } from "./SelectFilterControl"; +import { SearchFilterControl } from "./SearchFilterControl"; +import { MultiselectFilterControl } from "./MultiselectFilterControl"; + +export interface IFilterControlProps { + category: FilterCategory; + filterValue: FilterValue; + setFilterValue: (newValue: FilterValue) => void; + showToolbarItem: boolean; + isDisabled?: boolean; +} + +export const FilterControl = ({ + category, + ...props +}: React.PropsWithChildren< + IFilterControlProps +>): JSX.Element | null => { + if (category.type === FilterType.select) { + return ( + } + {...props} + /> + ); + } + if ( + category.type === FilterType.search || + category.type === FilterType.numsearch + ) { + return ( + } + isNumeric={category.type === FilterType.numsearch} + {...props} + /> + ); + } + if (category.type === FilterType.multiselect) { + return ( + + } + {...props} + /> + ); + } + return null; +}; diff --git a/client/src/app/components/FilterToolbar/FilterToolbar.tsx b/client/src/app/components/FilterToolbar/FilterToolbar.tsx new file mode 100644 index 00000000..eaa169a2 --- /dev/null +++ b/client/src/app/components/FilterToolbar/FilterToolbar.tsx @@ -0,0 +1,239 @@ +import * as React from "react"; +import { + Dropdown, + DropdownItem, + DropdownGroup, + DropdownList, + MenuToggle, + SelectOptionProps, + ToolbarToggleGroup, + ToolbarItem, +} from "@patternfly/react-core"; +import FilterIcon from "@patternfly/react-icons/dist/esm/icons/filter-icon"; + +import { FilterControl } from "./FilterControl"; + +export enum FilterType { + select = "select", + multiselect = "multiselect", + search = "search", + numsearch = "numsearch", +} + +export type FilterValue = string[] | undefined | null; + +export interface FilterSelectOptionProps { + optionProps?: SelectOptionProps; + value: string; + label?: string; + chipLabel?: string; + groupLabel?: string; +} + +export interface IBasicFilterCategory< + /** The actual API objects we're filtering */ + TItem, + TFilterCategoryKey extends string, // Unique identifiers for each filter category (inferred from key properties if possible) +> { + /** For use in the filterValues state object. Must be unique per category. */ + categoryKey: TFilterCategoryKey; + /** Title of the filter as displayed in the filter selection dropdown and filter chip groups. */ + title: string; + /** 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 + /** 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 { + /** 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: FilterSelectOptionProps[]; +} + +export interface ISearchFilterCategory + extends IBasicFilterCategory { + placeholderText: string; +} + +export type FilterCategory = + | IMultiselectFilterCategory + | ISelectFilterCategory + | ISearchFilterCategory; + +export type IFilterValues = Partial< + Record +>; + +export const getFilterLogicOperator = < + TItem, + TFilterCategoryKey extends string, +>( + filterCategory?: FilterCategory, + defaultOperator: "AND" | "OR" = "OR" +) => + (filterCategory && + (filterCategory as IMultiselectFilterCategory) + .logicOperator) || + defaultOperator; + +export interface IFilterToolbarProps { + filterCategories: FilterCategory[]; + filterValues: IFilterValues; + setFilterValues: (values: IFilterValues) => void; + beginToolbarItems?: JSX.Element; + endToolbarItems?: JSX.Element; + pagination?: JSX.Element; + showFiltersSideBySide?: boolean; + isDisabled?: boolean; +} + +export const FilterToolbar = ({ + filterCategories, + filterValues, + setFilterValues, + pagination, + showFiltersSideBySide = false, + isDisabled = false, +}: React.PropsWithChildren< + IFilterToolbarProps +>): JSX.Element | null => { + const [isCategoryDropdownOpen, setIsCategoryDropdownOpen] = + React.useState(false); + const [currentFilterCategoryKey, setCurrentFilterCategoryKey] = + React.useState(filterCategories[0].categoryKey); + + const onCategorySelect = ( + category: FilterCategory + ) => { + setCurrentFilterCategoryKey(category.categoryKey); + setIsCategoryDropdownOpen(false); + }; + + const setFilterValue = ( + category: FilterCategory, + newValue: FilterValue + ) => setFilterValues({ ...filterValues, [category.categoryKey]: newValue }); + + const currentFilterCategory = filterCategories.find( + (category) => category.categoryKey === currentFilterCategoryKey + ); + + const filterGroups = filterCategories.reduce( + (groups, category) => + !category.filterGroup || groups.includes(category.filterGroup) + ? groups + : [...groups, category.filterGroup], + [] as string[] + ); + + const renderDropdownItems = () => { + if (filterGroups.length) { + return filterGroups.map((filterGroup) => ( + + + {filterCategories + .filter( + (filterCategory) => filterCategory.filterGroup === filterGroup + ) + .map((filterCategory) => { + return ( + onCategorySelect(filterCategory)} + > + {filterCategory.title} + + ); + })} + + + )); + } else { + return filterCategories.map((category) => ( + onCategorySelect(category)} + > + {category.title} + + )); + } + }; + + return ( + <> + } + breakpoint="2xl" + spaceItems={ + showFiltersSideBySide ? { default: "spaceItemsMd" } : undefined + } + > + {!showFiltersSideBySide && ( + + ( + + setIsCategoryDropdownOpen(!isCategoryDropdownOpen) + } + isDisabled={isDisabled} + > + {currentFilterCategory?.title} + + )} + isOpen={isCategoryDropdownOpen} + > + {renderDropdownItems()} + + + )} + + {filterCategories.map((category) => ( + + key={category.categoryKey} + category={category} + filterValue={filterValues[category.categoryKey]} + setFilterValue={(newValue) => setFilterValue(category, newValue)} + showToolbarItem={ + showFiltersSideBySide || + currentFilterCategory?.categoryKey === category.categoryKey + } + isDisabled={isDisabled} + /> + ))} + + {pagination ? ( + {pagination} + ) : null} + + ); +}; diff --git a/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx b/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx new file mode 100644 index 00000000..25f4ba4a --- /dev/null +++ b/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx @@ -0,0 +1,363 @@ +import * as React from "react"; +import { + Badge, + Button, + MenuToggle, + MenuToggleElement, + Select, + SelectGroup, + SelectList, + SelectOption, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + ToolbarChip, + ToolbarFilter, + Tooltip, +} from "@patternfly/react-core"; +import { IFilterControlProps } from "./FilterControl"; +import { + IMultiselectFilterCategory, + FilterSelectOptionProps, +} from "./FilterToolbar"; +import { css } from "@patternfly/react-styles"; +import { TimesIcon } from "@patternfly/react-icons"; + +import "./select-overrides.css"; + +export interface IMultiselectFilterControlProps + extends IFilterControlProps { + category: IMultiselectFilterCategory; + isScrollable?: boolean; +} + +export const MultiselectFilterControl = ({ + category, + filterValue, + setFilterValue, + showToolbarItem, + isDisabled = false, + isScrollable = false, +}: React.PropsWithChildren< + IMultiselectFilterControlProps +>): JSX.Element | null => { + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = React.useState(false); + + const [selectOptions, setSelectOptions] = React.useState< + FilterSelectOptionProps[] + >(Array.isArray(category.selectOptions) ? category.selectOptions : []); + + React.useEffect(() => { + setSelectOptions( + Array.isArray(category.selectOptions) ? category.selectOptions : [] + ); + }, [category.selectOptions]); + + const hasGroupings = !Array.isArray(selectOptions); + + const flatOptions: FilterSelectOptionProps[] = !hasGroupings + ? selectOptions + : (Object.values(selectOptions).flatMap( + (i) => i + ) as FilterSelectOptionProps[]); + + const getOptionFromOptionValue = (optionValue: string) => + flatOptions.find(({ value }) => value === optionValue); + + const [focusedItemIndex, setFocusedItemIndex] = React.useState( + null + ); + + const [activeItem, setActiveItem] = React.useState(null); + const textInputRef = React.useRef(); + const [inputValue, setInputValue] = React.useState(""); + + const onFilterClearAll = () => setFilterValue([]); + const onFilterClear = (chip: string | ToolbarChip) => { + const value = typeof chip === "string" ? chip : chip.key; + + if (value) { + const newValue = filterValue?.filter((val) => val !== value) ?? []; + setFilterValue(newValue.length > 0 ? newValue : null); + } + }; + + /* + * Note: Create chips only as `ToolbarChip` (no plain string) + */ + const chips = filterValue + ?.map((value, index) => { + const option = getOptionFromOptionValue(value); + if (!option) { + return null; + } + + const { chipLabel, label, groupLabel } = option; + const displayValue: string = chipLabel ?? label ?? value ?? ""; + + return { + key: value, + node: groupLabel ? ( + {groupLabel}} + > +
{displayValue}
+
+ ) : ( + displayValue + ), + }; + }) + + .filter(Boolean); + + const renderSelectOptions = ( + filter: (option: FilterSelectOptionProps, groupName?: string) => boolean + ) => + hasGroupings + ? Object.entries( + selectOptions as Record + ) + .sort(([groupA], [groupB]) => groupA.localeCompare(groupB)) + .map(([group, options]): [string, FilterSelectOptionProps[]] => [ + group, + options?.filter((o) => filter(o, group)) ?? [], + ]) + .filter(([, groupFiltered]) => groupFiltered?.length) + .map(([group, groupFiltered], index) => ( + + {groupFiltered.map(({ value, label, optionProps }) => ( + + {label ?? value} + + ))} + + )) + : flatOptions + .filter((o) => filter(o)) + .map(({ label, value, optionProps = {} }, index) => ( + + {label ?? value} + + )); + + const onSelect = (value: string | undefined) => { + if (value && value !== "No results") { + let newFilterValue: string[]; + + if (filterValue && filterValue.includes(value)) { + newFilterValue = filterValue.filter((item) => item !== value); + } else { + newFilterValue = filterValue ? [...filterValue, value] : [value]; + } + + setFilterValue(newFilterValue); + } + textInputRef.current?.focus(); + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + if (isFilterDropdownOpen) { + if (key === "ArrowUp") { + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + } + + if (key === "ArrowDown") { + if ( + focusedItemIndex === null || + focusedItemIndex === selectOptions.length - 1 + ) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + setFocusedItemIndex(indexToFocus); + const focusedItem = selectOptions.filter( + ({ optionProps }) => !optionProps?.isDisabled + )[indexToFocus]; + setActiveItem( + `select-multi-typeahead-checkbox-${focusedItem.value.replace(" ", "-")}` + ); + } + }; + + React.useEffect(() => { + let newSelectOptions = Array.isArray(category.selectOptions) + ? category.selectOptions + : []; + + if (inputValue) { + newSelectOptions = Array.isArray(category.selectOptions) + ? category.selectOptions?.filter((menuItem) => + String(menuItem.value) + .toLowerCase() + .includes(inputValue.trim().toLowerCase()) + ) + : []; + + if (!newSelectOptions.length) { + newSelectOptions = [ + { + value: "no-results", + optionProps: { + isDisabled: true, + hasCheckbox: false, + }, + label: `No results found for "${inputValue}"`, + }, + ]; + } + } + + setSelectOptions(newSelectOptions); + setFocusedItemIndex(null); + setActiveItem(null); + }, [inputValue, category.selectOptions]); + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const enabledMenuItems = Array.isArray(selectOptions) + ? selectOptions.filter(({ optionProps }) => !optionProps?.isDisabled) + : []; + const [firstMenuItem] = enabledMenuItems; + const focusedItem = focusedItemIndex + ? enabledMenuItems[focusedItemIndex] + : firstMenuItem; + + const newSelectOptions = flatOptions.filter((menuItem) => + menuItem.value.toLowerCase().includes(inputValue.toLowerCase()) + ); + const selectedItem = + newSelectOptions.find( + (option) => option.value.toLowerCase() === inputValue.toLowerCase() + ) || focusedItem; + + switch (event.key) { + case "Enter": + if (!isFilterDropdownOpen) { + setIsFilterDropdownOpen((prev) => !prev); + } else if (selectedItem && selectedItem.value !== "No results") { + onSelect(selectedItem.value); + } + break; + case "Tab": + case "Escape": + setIsFilterDropdownOpen(false); + setActiveItem(null); + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + default: + break; + } + }; + + const onTextInputChange = ( + _event: React.FormEvent, + value: string + ) => { + setInputValue(value); + if (!isFilterDropdownOpen) { + setIsFilterDropdownOpen(true); + } + }; + + const toggle = (toggleRef: React.Ref) => ( + { + setIsFilterDropdownOpen(!isFilterDropdownOpen); + }} + isExpanded={isFilterDropdownOpen} + isDisabled={isDisabled || !category.selectOptions.length} + isFullWidth + > + + { + setIsFilterDropdownOpen(!isFilterDropdownOpen); + }} + onChange={onTextInputChange} + onKeyDown={onInputKeyDown} + id="typeahead-select-input" + autoComplete="off" + innerRef={textInputRef} + placeholder={category.placeholderText} + {...(activeItem && { "aria-activedescendant": activeItem })} + role="combobox" + isExpanded={isFilterDropdownOpen} + aria-controls="select-typeahead-listbox" + /> + + + {!!inputValue && ( + + )} + {filterValue?.length ? ( + {filterValue.length} + ) : null} + + + + ); + + return ( + onFilterClear(chip)} + deleteChipGroup={onFilterClearAll} + categoryName={category.title} + showToolbarItem={showToolbarItem} + > + + + ); +}; diff --git a/client/src/app/components/FilterToolbar/SearchFilterControl.tsx b/client/src/app/components/FilterToolbar/SearchFilterControl.tsx new file mode 100644 index 00000000..02244919 --- /dev/null +++ b/client/src/app/components/FilterToolbar/SearchFilterControl.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { + ToolbarFilter, + InputGroup, + TextInput, + Button, + ButtonVariant, +} from "@patternfly/react-core"; +import SearchIcon from "@patternfly/react-icons/dist/esm/icons/search-icon"; +import { IFilterControlProps } from "./FilterControl"; +import { ISearchFilterCategory } from "./FilterToolbar"; + +export interface ISearchFilterControlProps< + TItem, + TFilterCategoryKey extends string, +> extends IFilterControlProps { + category: ISearchFilterCategory; + isNumeric: boolean; +} + +export const SearchFilterControl = ({ + category, + filterValue, + setFilterValue, + showToolbarItem, + isNumeric, + isDisabled = false, +}: React.PropsWithChildren< + ISearchFilterControlProps +>): JSX.Element | null => { + // Keep internal copy of value until submitted by user + const [inputValue, setInputValue] = React.useState(filterValue?.[0] || ""); + // Update it if it changes externally + React.useEffect(() => { + setInputValue(filterValue?.[0] || ""); + }, [filterValue]); + + const onFilterSubmit = () => { + const trimmedValue = inputValue.trim(); + setFilterValue(trimmedValue ? [trimmedValue.replace(/\s+/g, " ")] : []); + }; + + const id = `${category.categoryKey}-input`; + return ( + setFilterValue([])} + categoryName={category.title} + showToolbarItem={showToolbarItem} + > + + setInputValue(value)} + aria-label={`${category.title} filter`} + value={inputValue} + placeholder={category.placeholderText} + onKeyDown={(event: React.KeyboardEvent) => { + if (event.key && event.key !== "Enter") return; + onFilterSubmit(); + }} + isDisabled={isDisabled} + /> + + + + ); +}; diff --git a/client/src/app/components/FilterToolbar/SelectFilterControl.tsx b/client/src/app/components/FilterToolbar/SelectFilterControl.tsx new file mode 100644 index 00000000..30eabc37 --- /dev/null +++ b/client/src/app/components/FilterToolbar/SelectFilterControl.tsx @@ -0,0 +1,129 @@ +import * as React from "react"; +import { + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + ToolbarFilter, +} from "@patternfly/react-core"; +import { IFilterControlProps } from "./FilterControl"; +import { ISelectFilterCategory } from "./FilterToolbar"; +import { css } from "@patternfly/react-styles"; + +import "./select-overrides.css"; + +export interface ISelectFilterControlProps< + TItem, + TFilterCategoryKey extends string, +> extends IFilterControlProps { + category: ISelectFilterCategory; + isScrollable?: boolean; +} + +export const SelectFilterControl = ({ + category, + filterValue, + setFilterValue, + showToolbarItem, + isDisabled = false, + isScrollable = false, +}: React.PropsWithChildren< + ISelectFilterControlProps +>): JSX.Element | null => { + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = React.useState(false); + + const getOptionFromOptionValue = (optionValue: string) => + category.selectOptions.find(({ value }) => value === optionValue); + + const chips = filterValue + ?.map((value) => { + const option = getOptionFromOptionValue(value); + if (!option) { + return null; + } + const { chipLabel, label } = option; + return { + key: value, + node: chipLabel ?? label ?? value, + }; + }) + .filter(Boolean); + + const onFilterSelect = (value: string) => { + const option = getOptionFromOptionValue(value); + setFilterValue(option ? [value] : null); + setIsFilterDropdownOpen(false); + }; + + const onFilterClear = (chip: string) => { + const newValue = filterValue?.filter((val) => val !== chip); + setFilterValue(newValue?.length ? newValue : null); + }; + + const toggle = (toggleRef: React.Ref) => { + let displayText = "Any"; + if (filterValue && filterValue.length > 0) { + const selectedKey = filterValue[0]; + const selectedDisplayValue = getOptionFromOptionValue(selectedKey)?.label; + displayText = selectedDisplayValue ? selectedDisplayValue : selectedKey; + } + + return ( + { + setIsFilterDropdownOpen(!isFilterDropdownOpen); + }} + isExpanded={isFilterDropdownOpen} + isDisabled={isDisabled || category.selectOptions.length === 0} + > + {displayText} + + ); + }; + + return ( + onFilterClear(chip as string)} + categoryName={category.title} + showToolbarItem={showToolbarItem} + > + + + ); +}; diff --git a/client/src/app/components/FilterToolbar/index.ts b/client/src/app/components/FilterToolbar/index.ts new file mode 100644 index 00000000..bab2f275 --- /dev/null +++ b/client/src/app/components/FilterToolbar/index.ts @@ -0,0 +1 @@ +export * from "./FilterToolbar"; diff --git a/client/src/app/components/FilterToolbar/select-overrides.css b/client/src/app/components/FilterToolbar/select-overrides.css new file mode 100644 index 00000000..8fe8da92 --- /dev/null +++ b/client/src/app/components/FilterToolbar/select-overrides.css @@ -0,0 +1,4 @@ +.pf-v5-c-select.isScrollable .pf-v5-c-select__menu { + max-height: 60vh; + overflow-y: auto; +} diff --git a/client/src/app/components/NoDataEmptyState.tsx b/client/src/app/components/NoDataEmptyState.tsx new file mode 100644 index 00000000..00422f04 --- /dev/null +++ b/client/src/app/components/NoDataEmptyState.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateVariant, + Title, +} from "@patternfly/react-core"; +import CubesIcon from "@patternfly/react-icons/dist/esm/icons/cubes-icon"; + +export interface NoDataEmptyStateProps { + title: string; + description?: string; +} + +export const NoDataEmptyState: React.FC = ({ + title, + description, +}) => { + return ( + + + + {title} + + {description && {description}} + + ); +}; diff --git a/client/src/app/components/SimplePagination/SimplePagination.tsx b/client/src/app/components/SimplePagination/SimplePagination.tsx new file mode 100644 index 00000000..e61bd0d6 --- /dev/null +++ b/client/src/app/components/SimplePagination/SimplePagination.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +import { + Pagination, + PaginationProps, + PaginationVariant, +} from "@patternfly/react-core"; +import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; + +export type PaginationStateProps = Pick< + PaginationProps, + "itemCount" | "perPage" | "page" | "onSetPage" | "onPerPageSelect" +>; + +export interface SimplePaginationProps { + paginationProps: PaginationStateProps; + isTop: boolean; + isCompact?: boolean; + noMargin?: boolean; + idPrefix?: string; +} + +export const SimplePagination: React.FC = ({ + paginationProps, + isTop, + isCompact = false, + noMargin = false, + idPrefix = "", +}) => { + return ( + + ); +}; diff --git a/client/src/app/components/SimplePagination/index.ts b/client/src/app/components/SimplePagination/index.ts new file mode 100644 index 00000000..cf567f42 --- /dev/null +++ b/client/src/app/components/SimplePagination/index.ts @@ -0,0 +1 @@ +export { SimplePagination } from "./SimplePagination"; diff --git a/client/src/app/components/StateError.tsx b/client/src/app/components/StateError.tsx new file mode 100644 index 00000000..3aff2a63 --- /dev/null +++ b/client/src/app/components/StateError.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { + EmptyState, + EmptyStateIcon, + EmptyStateVariant, + Title, + EmptyStateBody, +} from "@patternfly/react-core"; +import ExclamationCircleIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon"; +import { global_danger_color_200 as globalDangerColor200 } from "@patternfly/react-tokens"; + +export const StateError: React.FC = () => { + return ( + + + + Unable to connect + + + There was an error retrieving data. Check your connection and try again. + + + ); +}; diff --git a/client/src/app/components/StateNoData.tsx b/client/src/app/components/StateNoData.tsx new file mode 100644 index 00000000..2c7e6b7e --- /dev/null +++ b/client/src/app/components/StateNoData.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { NoDataEmptyState } from "./NoDataEmptyState"; + +export const StateNoData: React.FC = () => { + return ( + + ); +}; diff --git a/client/src/app/components/StateNoResults.tsx b/client/src/app/components/StateNoResults.tsx new file mode 100644 index 00000000..12d286f0 --- /dev/null +++ b/client/src/app/components/StateNoResults.tsx @@ -0,0 +1,25 @@ +import React from "react"; + +import { + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateVariant, + Title, +} from "@patternfly/react-core"; +import SearchIcon from "@patternfly/react-icons/dist/esm/icons/search-icon"; + +export const StateNoResults: React.FC = () => { + return ( + + + + No results found + + + No results match the filter criteria. Remove all filters or clear all + filters to show results. + + + ); +}; diff --git a/client/src/app/components/TableControls/ConditionalTableBody.tsx b/client/src/app/components/TableControls/ConditionalTableBody.tsx new file mode 100644 index 00000000..8b4a82f3 --- /dev/null +++ b/client/src/app/components/TableControls/ConditionalTableBody.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { Bullseye, Spinner } from "@patternfly/react-core"; +import { Tbody, Tr, Td } from "@patternfly/react-table"; +import { StateError } from "../StateError"; +import { StateNoData } from "../StateNoData"; + +export interface IConditionalTableBodyProps { + numRenderedColumns: number; + isLoading?: boolean; + isError?: boolean; + isNoData?: boolean; + errorEmptyState?: React.ReactNode; + noDataEmptyState?: React.ReactNode; + children: React.ReactNode; +} + +export const ConditionalTableBody: React.FC = ({ + numRenderedColumns, + isLoading = false, + isError = false, + isNoData = false, + errorEmptyState = null, + noDataEmptyState = null, + children, +}) => ( + <> + {isLoading ? ( + + + + + + + + + + ) : isError ? ( + + + + {errorEmptyState || } + + + + ) : isNoData ? ( + + + + {noDataEmptyState || } + + + + ) : ( + children + )} + +); diff --git a/client/src/app/components/TableControls/TableHeaderContentWithControls.tsx b/client/src/app/components/TableControls/TableHeaderContentWithControls.tsx new file mode 100644 index 00000000..e12c8c17 --- /dev/null +++ b/client/src/app/components/TableControls/TableHeaderContentWithControls.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Th } from "@patternfly/react-table"; + +export interface ITableHeaderContentWithControlsProps { + numColumnsBeforeData: number; + numColumnsAfterData: number; + children: React.ReactNode; +} + +export const TableHeaderContentWithControls: React.FC< + ITableHeaderContentWithControlsProps +> = ({ numColumnsBeforeData, numColumnsAfterData, children }) => ( + <> + {Array(numColumnsBeforeData) + .fill(null) + .map((_, i) => ( + + ))} + {children} + {Array(numColumnsAfterData) + .fill(null) + .map((_, i) => ( + + ))} + +); diff --git a/client/src/app/components/TableControls/TableRowContentWithControls.tsx b/client/src/app/components/TableControls/TableRowContentWithControls.tsx new file mode 100644 index 00000000..9c1ad5fc --- /dev/null +++ b/client/src/app/components/TableControls/TableRowContentWithControls.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { Td } from "@patternfly/react-table"; +import { ITableControls } from "@app/hooks/table-controls"; + +export interface ITableRowContentWithControlsProps< + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, +> { + isExpansionEnabled?: boolean; + expandableVariant?: "single" | "compound"; + isSelectionEnabled?: boolean; + propHelpers: ITableControls< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix + >["propHelpers"]; + item: TItem; + rowIndex: number; + children: React.ReactNode; +} + +export const TableRowContentWithControls = < + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, +>({ + isExpansionEnabled = false, + expandableVariant, + isSelectionEnabled = false, + propHelpers: { getSingleExpandButtonTdProps, getSelectCheckboxTdProps }, + item, + rowIndex, + children, +}: React.PropsWithChildren< + ITableRowContentWithControlsProps +>) => ( + <> + {isExpansionEnabled && expandableVariant === "single" ? ( + + ) : null} + {isSelectionEnabled ? ( + + ) : null} + {children} + +); diff --git a/client/src/app/components/TableControls/index.ts b/client/src/app/components/TableControls/index.ts new file mode 100644 index 00000000..a964f4f3 --- /dev/null +++ b/client/src/app/components/TableControls/index.ts @@ -0,0 +1,3 @@ +export * from "./ConditionalTableBody"; +export * from "./TableHeaderContentWithControls"; +export * from "./TableRowContentWithControls"; diff --git a/client/src/app/components/ToolbarBulkSelector.tsx b/client/src/app/components/ToolbarBulkSelector.tsx new file mode 100644 index 00000000..919a86a5 --- /dev/null +++ b/client/src/app/components/ToolbarBulkSelector.tsx @@ -0,0 +1,144 @@ +import React, { useState } from "react"; +import { + Button, + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + MenuToggleCheckbox, + PaginationProps, + ToolbarItem, +} from "@patternfly/react-core"; + +import AngleDownIcon from "@patternfly/react-icons/dist/esm/icons/angle-down-icon"; +import AngleRightIcon from "@patternfly/react-icons/dist/esm/icons/angle-right-icon"; + +export interface IToolbarBulkSelectorProps { + areAllSelected: boolean; + areAllExpanded?: boolean; + onSelectAll: (flag: boolean) => void; + onExpandAll?: (flag: boolean) => void; + selectedRows: T[]; + onSelectMultiple: (items: T[], isSelecting: boolean) => void; + currentPageItems: T[]; + paginationProps: PaginationProps; + isExpandable?: boolean; +} + +export const ToolbarBulkSelector = ({ + currentPageItems, + areAllSelected, + onSelectAll, + onExpandAll, + areAllExpanded, + selectedRows, + onSelectMultiple, + paginationProps, + isExpandable, +}: React.PropsWithChildren< + IToolbarBulkSelectorProps +>): JSX.Element | null => { + const [isOpen, setIsOpen] = useState(false); + + const toggleCollapseAll = (collapse: boolean) => { + onExpandAll && onExpandAll(!collapse); + }; + const collapseAllBtn = () => ( + + ); + + const getBulkSelectState = () => { + let state: boolean | null; + if (areAllSelected) { + state = true; + } else if (selectedRows.length === 0) { + state = false; + } else { + state = null; + } + return state; + }; + const handleSelectAll = (checked: boolean) => { + onSelectAll(!!checked); + }; + + const dropdownItems = [ + { + handleSelectAll(false); + }} + data-action="none" + key="select-none" + component="button" + > + Select none (0 items) + , + { + onSelectMultiple( + currentPageItems.map((item: T) => item), + true + ); + }} + data-action="page" + key="select-page" + component="button" + > + Select page ({currentPageItems.length} items) + , + { + handleSelectAll(true); + }} + data-action="all" + key="select-all" + component="button" + > + Select all ({paginationProps.itemCount}) + , + ]; + + return ( + <> + {isExpandable && {collapseAllBtn()}} + + ( + setIsOpen(!isOpen)} + splitButtonOptions={{ + items: [ + { + if (getBulkSelectState() !== false) { + onSelectAll(false); + } else { + onSelectAll(true); + } + }} + isChecked={getBulkSelectState()} + />, + ], + }} + /> + )} + > + {dropdownItems} + + + + ); +}; diff --git a/client/src/app/hooks/table-controls/active-item/getActiveItemDerivedState.ts b/client/src/app/hooks/table-controls/active-item/getActiveItemDerivedState.ts new file mode 100644 index 00000000..25747ca1 --- /dev/null +++ b/client/src/app/hooks/table-controls/active-item/getActiveItemDerivedState.ts @@ -0,0 +1,72 @@ +import { KeyWithValueType } from "@app/utils/type-utils"; +import { IActiveItemState } from "./useActiveItemState"; + +/** + * Args for getActiveItemDerivedState + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by useTableControlProps (IUseTableControlPropsArgs) + * @see ITableControlState + * @see IUseTableControlPropsArgs + */ +export interface IActiveItemDerivedStateArgs { + /** + * The current page of API data items after filtering/sorting/pagination + */ + currentPageItems: TItem[]; + /** + * The string key/name of a property on the API data item objects that can be used as a unique identifier (string or number) + */ + idProperty: KeyWithValueType; + /** + * The "source of truth" state for the active item feature (returned by useActiveItemState) + */ + activeItemState: IActiveItemState; +} + +/** + * Derived state for the active item feature + * - "Derived state" here refers to values and convenience functions derived at render time based on the "source of truth" state. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + */ +export interface IActiveItemDerivedState { + /** + * The API data object matching the `activeItemId` in `activeItemState` + */ + activeItem: TItem | null; + /** + * Updates the active item (sets `activeItemId` in `activeItemState` to the id of the given item). + * - Pass null to dismiss the active item. + */ + setActiveItem: (item: TItem | null) => void; + /** + * Dismisses the active item. Shorthand for setActiveItem(null). + */ + clearActiveItem: () => void; + /** + * Returns whether the given item matches the `activeItemId` in `activeItemState`. + */ + isActiveItem: (item: TItem) => boolean; +} + +/** + * Given the "source of truth" state for the active item feature and additional arguments, returns "derived state" values and convenience functions. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + * + * NOTE: Unlike `getLocal[Filter|Sort|Pagination]DerivedState`, this is not named `getLocalActiveItemDerivedState` because it + * is always local/client-computed, and it is still used when working with server-computed tables + * (it's not specific to client-only-computed tables like the other `getLocal*DerivedState` functions are). + */ +export const getActiveItemDerivedState = ({ + currentPageItems, + idProperty, + activeItemState: { activeItemId, setActiveItemId }, +}: IActiveItemDerivedStateArgs): IActiveItemDerivedState => ({ + activeItem: + currentPageItems.find((item) => item[idProperty] === activeItemId) || null, + setActiveItem: (item: TItem | null) => { + const itemId = (item?.[idProperty] ?? null) as string | number | null; // TODO Assertion shouldn't be necessary here but TS isn't fully inferring item[idProperty]? + setActiveItemId(itemId); + }, + clearActiveItem: () => setActiveItemId(null), + isActiveItem: (item) => item[idProperty] === activeItemId, +}); diff --git a/client/src/app/hooks/table-controls/active-item/index.ts b/client/src/app/hooks/table-controls/active-item/index.ts new file mode 100644 index 00000000..afa4d418 --- /dev/null +++ b/client/src/app/hooks/table-controls/active-item/index.ts @@ -0,0 +1,4 @@ +export * from "./useActiveItemState"; +export * from "./getActiveItemDerivedState"; +export * from "./useActiveItemPropHelpers"; +export * from "./useActiveItemEffects"; diff --git a/client/src/app/hooks/table-controls/active-item/useActiveItemEffects.ts b/client/src/app/hooks/table-controls/active-item/useActiveItemEffects.ts new file mode 100644 index 00000000..bbaa2ca8 --- /dev/null +++ b/client/src/app/hooks/table-controls/active-item/useActiveItemEffects.ts @@ -0,0 +1,41 @@ +import * as React from "react"; +import { IActiveItemDerivedState } from "./getActiveItemDerivedState"; +import { IActiveItemState } from "./useActiveItemState"; + +/** + * Args for useActiveItemEffects + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by useTableControlProps (IUseTableControlPropsArgs) + */ +export interface IUseActiveItemEffectsArgs { + /** + * Whether the table data is loading + */ + isLoading?: boolean; + /** + * The "source of truth" state for the active item feature (returned by useActiveItemState) + */ + activeItemState: IActiveItemState; + /** + * The "derived state" for the active item feature (returned by getActiveItemDerivedState) + */ + activeItemDerivedState: IActiveItemDerivedState; +} + +/** + * Registers side effects necessary to prevent invalid state related to the active item feature. + * - Used internally by useActiveItemPropHelpers as part of useTableControlProps + * - The effect: If some state change (e.g. refetch, pagination interaction) causes the active item to disappear, + * remove its id from state so the drawer won't automatically reopen if the item comes back. + */ +export const useActiveItemEffects = ({ + isLoading, + activeItemState: { activeItemId }, + activeItemDerivedState: { activeItem, clearActiveItem }, +}: IUseActiveItemEffectsArgs) => { + React.useEffect(() => { + if (!isLoading && activeItemId && !activeItem) { + clearActiveItem(); + } + }, [isLoading, activeItemId, activeItem]); // TODO fix the exhaustive-deps lint warning here without affecting behavior +}; diff --git a/client/src/app/hooks/table-controls/active-item/useActiveItemPropHelpers.ts b/client/src/app/hooks/table-controls/active-item/useActiveItemPropHelpers.ts new file mode 100644 index 00000000..ad7038d9 --- /dev/null +++ b/client/src/app/hooks/table-controls/active-item/useActiveItemPropHelpers.ts @@ -0,0 +1,69 @@ +import { TrProps } from "@patternfly/react-table"; +import { + IActiveItemDerivedStateArgs, + getActiveItemDerivedState, +} from "./getActiveItemDerivedState"; +import { IActiveItemState } from "./useActiveItemState"; +import { + IUseActiveItemEffectsArgs, + useActiveItemEffects, +} from "./useActiveItemEffects"; + +/** + * Args for useActiveItemPropHelpers that come from outside useTableControlProps + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by useTableControlProps (IUseTableControlPropsArgs) + * @see ITableControlState + * @see IUseTableControlPropsArgs + */ +export type IActiveItemPropHelpersExternalArgs = + IActiveItemDerivedStateArgs & + Omit, "activeItemDerivedState"> & { + /** + * Whether the table data is loading + */ + isLoading?: boolean; + /** + * The "source of truth" state for the active item feature (returned by useActiveItemState) + */ + activeItemState: IActiveItemState; + }; + +/** + * Given "source of truth" state for the active item feature, returns derived state and `propHelpers`. + * - Used internally by useTableControlProps + * - Also triggers side effects to prevent invalid state + * - "Derived state" here refers to values and convenience functions derived at render time. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + */ +export const useActiveItemPropHelpers = ( + args: IActiveItemPropHelpersExternalArgs +) => { + const activeItemDerivedState = getActiveItemDerivedState(args); + const { isActiveItem, setActiveItem, clearActiveItem } = + activeItemDerivedState; + + useActiveItemEffects({ ...args, activeItemDerivedState }); + + /** + * Returns props for a clickable Tr in a table with the active item feature enabled. Sets or clears the active item when clicked. + */ + const getActiveItemTrProps = ({ + item, + }: { + item: TItem; + }): Omit => ({ + isSelectable: true, + isClickable: true, + isRowSelected: item && isActiveItem(item), + onRowClick: () => { + if (!isActiveItem(item)) { + setActiveItem(item); + } else { + clearActiveItem(); + } + }, + }); + + return { activeItemDerivedState, getActiveItemTrProps }; +}; diff --git a/client/src/app/hooks/table-controls/active-item/useActiveItemState.ts b/client/src/app/hooks/table-controls/active-item/useActiveItemState.ts new file mode 100644 index 00000000..f0e3f362 --- /dev/null +++ b/client/src/app/hooks/table-controls/active-item/useActiveItemState.ts @@ -0,0 +1,82 @@ +import { parseMaybeNumericString } from "@app/utils/utils"; +import { IFeaturePersistenceArgs } from "../types"; +import { usePersistentState } from "@app/hooks/usePersistentState"; + +/** + * The "source of truth" state for the active item feature. + * - Included in the object returned by useTableControlState (ITableControlState) under the `activeItemState` property. + * - Also included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see ITableControlState + * @see ITableControls + */ +export interface IActiveItemState { + /** + * The item id (string or number resolved from `item[idProperty]`) of the active item. Null if no item is active. + */ + activeItemId: string | number | null; + /** + * Updates the active item by id. Pass null to dismiss the active item. + */ + setActiveItemId: (id: string | number | null) => void; +} + +/** + * Args for useActiveItemState + * - Makes up part of the arguments object taken by useTableControlState (IUseTableControlStateArgs) + * - Properties here are included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see IUseTableControlStateArgs + * @see ITableControls + */ +export type IActiveItemStateArgs = { + /** + * The only arg for this feature is the enabled flag. + * - This does not use DiscriminatedArgs because there are no additional args when the active item feature is enabled. + */ + isActiveItemEnabled?: boolean; +}; + +/** + * Provides the "source of truth" state for the active item feature. + * - Used internally by useTableControlState + * - Takes args defined above as well as optional args for persisting state to a configurable storage target. + * @see PersistTarget + */ +export const useActiveItemState = < + TPersistenceKeyPrefix extends string = string, +>( + args: IActiveItemStateArgs & + IFeaturePersistenceArgs = {} +): IActiveItemState => { + const { isActiveItemEnabled, persistTo, persistenceKeyPrefix } = args; + + // We won't need to pass the latter two type params here if TS adds support for partial inference. + // See https://github.com/konveyor/tackle2-ui/issues/1456 + const [activeItemId, setActiveItemId] = usePersistentState< + string | number | null, + TPersistenceKeyPrefix, + "activeItem" + >({ + isEnabled: !!isActiveItemEnabled, + defaultValue: null, + persistenceKeyPrefix, + // Note: For the discriminated union here to work without TypeScript getting confused + // (e.g. require the urlParams-specific options when persistTo === "urlParams"), + // we need to pass persistTo inside each type-narrowed options object instead of outside the ternary. + ...(persistTo === "urlParams" + ? { + persistTo, + keys: ["activeItem"], + serialize: (activeItemId) => ({ + activeItem: activeItemId !== null ? String(activeItemId) : null, + }), + deserialize: ({ activeItem }) => parseMaybeNumericString(activeItem), + } + : persistTo === "localStorage" || persistTo === "sessionStorage" + ? { + persistTo, + key: "activeItem", + } + : { persistTo }), + }); + return { activeItemId, setActiveItemId }; +}; diff --git a/client/src/app/hooks/table-controls/column/useColumnState.ts b/client/src/app/hooks/table-controls/column/useColumnState.ts new file mode 100644 index 00000000..1f65d3ed --- /dev/null +++ b/client/src/app/hooks/table-controls/column/useColumnState.ts @@ -0,0 +1,28 @@ +import { useLocalStorage } from "@app/hooks/useStorage"; + +export interface ColumnState { + id: TColumnKey; + label: string; + isVisible: boolean; +} + +export interface IColumnState { + columns: ColumnState[]; + setColumns: (newColumns: ColumnState[]) => void; +} + +interface IColumnStateArgs { + initialColumns: ColumnState[]; + columnsKey: string; +} + +export const useColumnState = ( + args: IColumnStateArgs +): IColumnState => { + const [columns, setColumns] = useLocalStorage[]>({ + key: args.columnsKey, + defaultValue: args.initialColumns, + }); + + return { columns, setColumns }; +}; diff --git a/client/src/app/hooks/table-controls/expansion/getExpansionDerivedState.ts b/client/src/app/hooks/table-controls/expansion/getExpansionDerivedState.ts new file mode 100644 index 00000000..5c0511cb --- /dev/null +++ b/client/src/app/hooks/table-controls/expansion/getExpansionDerivedState.ts @@ -0,0 +1,86 @@ +import { KeyWithValueType } from "@app/utils/type-utils"; +import { IExpansionState } from "./useExpansionState"; + +/** + * Args for getExpansionDerivedState + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by useTableControlProps (IUseTableControlPropsArgs) + * @see ITableControlState + * @see IUseTableControlPropsArgs + */ +export interface IExpansionDerivedStateArgs { + /** + * The string key/name of a property on the API data item objects that can be used as a unique identifier (string or number) + */ + idProperty: KeyWithValueType; + /** + * The "source of truth" state for the expansion feature (returned by useExpansionState) + */ + expansionState: IExpansionState; +} + +/** + * Derived state for the expansion feature + * - "Derived state" here refers to values and convenience functions derived at render time based on the "source of truth" state. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + */ +export interface IExpansionDerivedState { + /** + * Returns whether a cell or a row is expanded + * - If called with a columnKey, returns whether that column's cell in this row is expanded (for compound-expand) + * - If called without a columnKey, returns whether the entire row or any cell in it is expanded (for both single-expand and compound-expand) + */ + isCellExpanded: (item: TItem, columnKey?: TColumnKey) => boolean; + /** + * Set a cell or a row as expanded or collapsed + * - If called with a columnKey, sets that column's cell in this row expanded or collapsed (for compound-expand) + * - If called without a columnKey, sets the entire row as expanded or collapsed (for single-expand) + */ + setCellExpanded: (args: { + item: TItem; + isExpanding?: boolean; + columnKey?: TColumnKey; + }) => void; +} + +/** + * Given the "source of truth" state for the expansion feature and additional arguments, returns "derived state" values and convenience functions. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + * + * NOTE: Unlike `getLocal[Filter|Sort|Pagination]DerivedState`, this is not named `getLocalExpansionDerivedState` because it + * is always local/client-computed, and it is still used when working with server-computed tables + * (it's not specific to client-only-computed tables like the other `getLocal*DerivedState` functions are). + */ +export const getExpansionDerivedState = ({ + idProperty, + expansionState: { expandedCells, setExpandedCells }, +}: IExpansionDerivedStateArgs): IExpansionDerivedState< + TItem, + TColumnKey +> => { + const isCellExpanded = (item: TItem, columnKey?: TColumnKey) => { + return columnKey + ? expandedCells[String(item[idProperty])] === columnKey + : !!expandedCells[String(item[idProperty])]; + }; + + const setCellExpanded = ({ + item, + isExpanding = true, + columnKey, + }: { + item: TItem; + isExpanding?: boolean; + columnKey?: TColumnKey; + }) => { + const newExpandedCells = { ...expandedCells }; + if (isExpanding) { + newExpandedCells[String(item[idProperty])] = columnKey || true; + } else { + delete newExpandedCells[String(item[idProperty])]; + } + setExpandedCells(newExpandedCells); + }; + + return { isCellExpanded, setCellExpanded }; +}; diff --git a/client/src/app/hooks/table-controls/expansion/index.ts b/client/src/app/hooks/table-controls/expansion/index.ts new file mode 100644 index 00000000..495c4018 --- /dev/null +++ b/client/src/app/hooks/table-controls/expansion/index.ts @@ -0,0 +1,3 @@ +export * from "./useExpansionState"; +export * from "./getExpansionDerivedState"; +export * from "./useExpansionPropHelpers"; diff --git a/client/src/app/hooks/table-controls/expansion/useExpansionPropHelpers.ts b/client/src/app/hooks/table-controls/expansion/useExpansionPropHelpers.ts new file mode 100644 index 00000000..9292af10 --- /dev/null +++ b/client/src/app/hooks/table-controls/expansion/useExpansionPropHelpers.ts @@ -0,0 +1,147 @@ +import { KeyWithValueType } from "@app/utils/type-utils"; +import { IExpansionState } from "./useExpansionState"; +import { getExpansionDerivedState } from "./getExpansionDerivedState"; +import { TdProps } from "@patternfly/react-table"; + +/** + * Args for useExpansionPropHelpers that come from outside useTableControlProps + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by useTableControlProps (IUseTableControlPropsArgs) + * @see ITableControlState + * @see IUseTableControlPropsArgs + */ +export interface IExpansionPropHelpersExternalArgs< + TItem, + TColumnKey extends string, +> { + /** + * An ordered mapping of unique keys to human-readable column name strings. + * - Keys of this object are used as unique identifiers for columns (`columnKey`). + * - Values of this object are rendered in the column headers by default (can be overridden by passing children to ) and used as `dataLabel` for cells in the column. + */ + columnNames: Record; + /** + * The string key/name of a property on the API data item objects that can be used as a unique identifier (string or number) + */ + idProperty: KeyWithValueType; + /** + * The "source of truth" state for the expansion feature (returned by useExpansionState) + */ + expansionState: IExpansionState; +} + +/** + * Additional args for useExpansionPropHelpers that come from logic inside useTableControlProps + * @see useTableControlProps + */ +export interface IExpansionPropHelpersInternalArgs { + /** + * The keys of the `columnNames` object (unique keys identifying each column). + */ + columnKeys: TColumnKey[]; + /** + * The total number of columns (Td elements that should be rendered in each Tr) + * - Includes data cells (based on the number of `columnKeys`) and non-data cells for enabled features. + * - For use as the colSpan of a cell that spans an entire row. + */ + numRenderedColumns: number; +} + +/** + * Returns derived state and prop helpers for the expansion feature based on given "source of truth" state. + * - Used internally by useTableControlProps + * - "Derived state" here refers to values and convenience functions derived at render time. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + */ +export const useExpansionPropHelpers = ( + args: IExpansionPropHelpersExternalArgs & + IExpansionPropHelpersInternalArgs +) => { + const { + columnNames, + idProperty, + columnKeys, + numRenderedColumns, + expansionState: { expandedCells }, + } = args; + + const expansionDerivedState = getExpansionDerivedState(args); + const { isCellExpanded, setCellExpanded } = expansionDerivedState; + + /** + * Returns props for the Td to the left of the data cells which contains each row's expansion toggle button (only for single-expand). + */ + const getSingleExpandButtonTdProps = ({ + item, + rowIndex, + }: { + item: TItem; + rowIndex: number; + }): Omit => ({ + expand: { + rowIndex, + isExpanded: isCellExpanded(item), + onToggle: () => + setCellExpanded({ + item, + isExpanding: !isCellExpanded(item), + }), + expandId: `expandable-row-${item[idProperty]}`, + }, + }); + + /** + * Returns props for the Td which is a data cell in an expandable column and functions as an expand toggle (only for compound-expand) + */ + const getCompoundExpandTdProps = ({ + columnKey, + item, + rowIndex, + }: { + columnKey: TColumnKey; + item: TItem; + rowIndex: number; + }): Omit => ({ + compoundExpand: { + isExpanded: isCellExpanded(item, columnKey), + onToggle: () => + setCellExpanded({ + item, + isExpanding: !isCellExpanded(item, columnKey), + columnKey, + }), + expandId: `compound-expand-${item[idProperty]}-${columnKey}`, + rowIndex, + columnIndex: columnKeys.indexOf(columnKey), + }, + }); + + /** + * Returns props for the Td which contains the expanded content below an expandable row (for both single-expand and compound-expand). + * This Td should be rendered as the only cell in a Tr just below the Tr containing the corresponding row. + * The Tr for the row content and the Tr for the expanded content should be the only two children of a Tbody grouping them (one per expandable row). + */ + const getExpandedContentTdProps = ({ + item, + }: { + item: TItem; + }): Omit => { + const expandedColumnKey = expandedCells[String(item[idProperty])]; + return { + dataLabel: + typeof expandedColumnKey === "string" + ? columnNames[expandedColumnKey] + : undefined, + noPadding: true, + colSpan: numRenderedColumns, + width: 100, + }; + }; + + return { + expansionDerivedState, + getSingleExpandButtonTdProps, + getCompoundExpandTdProps, + getExpandedContentTdProps, + }; +}; diff --git a/client/src/app/hooks/table-controls/expansion/useExpansionState.ts b/client/src/app/hooks/table-controls/expansion/useExpansionState.ts new file mode 100644 index 00000000..ac3a873b --- /dev/null +++ b/client/src/app/hooks/table-controls/expansion/useExpansionState.ts @@ -0,0 +1,117 @@ +import { usePersistentState } from "@app/hooks/usePersistentState"; +import { objectKeys } from "@app/utils/utils"; +import { IFeaturePersistenceArgs } from "../types"; +import { DiscriminatedArgs } from "@app/utils/type-utils"; + +/** + * A map of item ids (strings resolved from `item[idProperty]`) to either: + * - a `columnKey` if that item's row has a compound-expanded cell + * - or a boolean: + * - true if the row is expanded (for single-expand) + * - false if the row and all its cells are collapsed (for both single-expand and compound-expand). + */ +export type TExpandedCells = Record< + string, + TColumnKey | boolean +>; + +/** + * The "source of truth" state for the expansion feature. + * - Included in the object returned by useTableControlState (ITableControlState) under the `expansionState` property. + * - Also included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see ITableControlState + * @see ITableControls + */ +export interface IExpansionState { + /** + * A map of item ids to a `columnKey` or boolean for the current expansion state of that cell/row + * @see TExpandedCells + */ + expandedCells: TExpandedCells; + /** + * Updates the `expandedCells` map (replacing the entire map). + * - See `expansionDerivedState` for helper functions to expand/collapse individual cells/rows. + * @see IExpansionDerivedState + */ + setExpandedCells: (newExpandedCells: TExpandedCells) => void; +} + +/** + * Args for useExpansionState + * - Makes up part of the arguments object taken by useTableControlState (IUseTableControlStateArgs) + * - The properties defined here are only required by useTableControlState if isExpansionEnabled is true (see DiscriminatedArgs) + * - Properties here are included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see IUseTableControlStateArgs + * @see DiscriminatedArgs + * @see ITableControls + */ +export type IExpansionStateArgs = DiscriminatedArgs< + "isExpansionEnabled", + { + /** + * Whether to use single-expand or compound-expand behavior + * - "single" for the entire row to be expandable with one toggle. + * - "compound" for multiple cells in a row to be expandable with individual toggles. + */ + expandableVariant: "single" | "compound"; + } +>; + +/** + * Provides the "source of truth" state for the expansion feature. + * - Used internally by useTableControlState + * - Takes args defined above as well as optional args for persisting state to a configurable storage target. + * @see PersistTarget + */ +export const useExpansionState = < + TColumnKey extends string, + TPersistenceKeyPrefix extends string = string, +>( + args: IExpansionStateArgs & + IFeaturePersistenceArgs = {} +): IExpansionState => { + const { + isExpansionEnabled, + persistTo = "state", + persistenceKeyPrefix, + } = args; + + // We won't need to pass the latter two type params here if TS adds support for partial inference. + // See https://github.com/konveyor/tackle2-ui/issues/1456 + const [expandedCells, setExpandedCells] = usePersistentState< + TExpandedCells, + TPersistenceKeyPrefix, + "expandedCells" + >({ + isEnabled: !!isExpansionEnabled, + defaultValue: {}, + persistenceKeyPrefix, + // Note: For the discriminated union here to work without TypeScript getting confused + // (e.g. require the urlParams-specific options when persistTo === "urlParams"), + // we need to pass persistTo inside each type-narrowed options object instead of outside the ternary. + ...(persistTo === "urlParams" + ? { + persistTo, + keys: ["expandedCells"], + serialize: (expandedCellsObj) => { + if (!expandedCellsObj || objectKeys(expandedCellsObj).length === 0) + return { expandedCells: null }; + return { expandedCells: JSON.stringify(expandedCellsObj) }; + }, + deserialize: ({ expandedCells: expandedCellsStr }) => { + try { + return JSON.parse(expandedCellsStr || "{}"); + } catch (e) { + return {}; + } + }, + } + : persistTo === "localStorage" || persistTo === "sessionStorage" + ? { + persistTo, + key: "expandedCells", + } + : { persistTo }), + }); + return { expandedCells, setExpandedCells }; +}; diff --git a/client/src/app/hooks/table-controls/getFilterHubRequestParams.ts b/client/src/app/hooks/table-controls/filtering/getFilterHubRequestParams.ts similarity index 75% rename from client/src/app/hooks/table-controls/getFilterHubRequestParams.ts rename to client/src/app/hooks/table-controls/filtering/getFilterHubRequestParams.ts index 9a9cdd0a..7cd421ff 100644 --- a/client/src/app/hooks/table-controls/getFilterHubRequestParams.ts +++ b/client/src/app/hooks/table-controls/filtering/getFilterHubRequestParams.ts @@ -2,9 +2,9 @@ import { HubFilter, HubRequestParams } from "@app/api/models"; import { objectKeys } from "@app/utils/utils"; import { FilterCategory, - FilterState, getFilterLogicOperator, -} from "@carlosthe19916-latest/react-table-batteries"; +} from "@app/components/FilterToolbar"; +import { IFilterState } from "./useFilterState"; /** * Helper function for getFilterHubRequestParams @@ -61,7 +61,7 @@ export interface IGetFilterHubRequestParamsArgs< /** * The "source of truth" state for the filter feature (returned by useFilterState) */ - filter?: FilterState; + filterState?: IFilterState; /** * Definitions of the filters to be used (must include `getItemValue` functions for each category when performing filtering locally) */ @@ -78,7 +78,7 @@ export const getFilterHubRequestParams = < TItem, TFilterCategoryKey extends string = string, >({ - filter, + filterState, filterCategories, implicitFilters, }: IGetFilterHubRequestParamsArgs< @@ -87,18 +87,18 @@ export const getFilterHubRequestParams = < >): Partial => { if ( !implicitFilters?.length && - (!filter || + (!filterState || !filterCategories || - objectKeys(filter.filterValues).length === 0) + objectKeys(filterState.filterValues).length === 0) ) { return {}; } const filters: HubFilter[] = []; - if (filter) { - const { filterValues } = filter; + if (filterState) { + const { filterValues } = filterState; objectKeys(filterValues).forEach((categoryKey) => { const filterCategory = filterCategories?.find( - (category) => category.key === categoryKey + (category) => category.categoryKey === categoryKey ); const filterValue = filterValues[categoryKey]; if (!filterCategory || !filterValue) return; @@ -160,66 +160,15 @@ export const wrapInQuotesAndEscape = (value: string | number): string => */ export const serializeFilterForHub = (filter: HubFilter): string => { const { field, operator, value } = filter; - - let sikula: (fieldName: string, fieldValue: string) => string = () => ""; - switch (operator) { - case "=": - sikula = (fieldName, fieldValue) => { - const f = fieldName.split(":"); - if (f.length == 2) { - switch (f[1]) { - case "in": - return `(${fieldValue} in:${f[0]})`; - case "is": - return `(is:${fieldValue})`; - } - } - - return `${fieldName}:${fieldValue}`; - }; - break; - case "!=": - sikula = (fieldName, fieldValue) => { - return `-${fieldName}:${fieldValue}`; - }; - break; - case "~": - sikula = (_, fieldValue) => { - return fieldValue; - }; - break; - case ">": - sikula = (fieldName, fieldValue) => { - return `-${fieldName}:>${fieldValue}`; - }; - break; - case "<": - sikula = (fieldName, fieldValue) => { - return `-${fieldName}:<${fieldValue}`; - }; - break; - case "<=": - sikula = (fieldName, fieldValue) => { - return `-${fieldName}:<=${fieldValue}`; - }; - break; - case ">=": - sikula = (fieldName, fieldValue) => { - return `-${fieldName}:>=${fieldValue}`; - }; - break; - } - - if (typeof value === "string") { - return sikula(field, wrapInQuotesAndEscape(value)); - } else if (typeof value === "number") { - return sikula(field, `"${value}"`); - } else { - return value.list - .map(wrapInQuotesAndEscape) - .map((val) => sikula(field, val)) - .join(` ${value.operator || "AND"} `); - } + const joinedValue = + typeof value === "string" + ? wrapInQuotesAndEscape(value) + : typeof value === "number" + ? `"${value}"` + : `(${value.list + .map(wrapInQuotesAndEscape) + .join(value.operator === "OR" ? "|" : ",")})`; + return `${field}${operator}${joinedValue}`; }; /** @@ -235,8 +184,8 @@ export const serializeFilterRequestParamsForHub = ( const { filters } = deserializedParams; if (filters) { serializedParams.append( - "q", - `(${filters.map(serializeFilterForHub).join(")(")})` + "filter", + filters.map(serializeFilterForHub).join(",") ); } }; diff --git a/client/src/app/hooks/table-controls/filtering/getLocalFilterDerivedState.ts b/client/src/app/hooks/table-controls/filtering/getLocalFilterDerivedState.ts new file mode 100644 index 00000000..d7fe683b --- /dev/null +++ b/client/src/app/hooks/table-controls/filtering/getLocalFilterDerivedState.ts @@ -0,0 +1,69 @@ +import { + FilterCategory, + getFilterLogicOperator, +} from "@app/components/FilterToolbar"; +import { objectKeys } from "@app/utils/utils"; +import { IFilterState } from "./useFilterState"; + +/** + * Args for getLocalFilterDerivedState + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by getLocalTableControlDerivedState (ITableControlLocalDerivedStateArgs) + * @see ITableControlState + * @see ITableControlLocalDerivedStateArgs + */ +export interface ILocalFilterDerivedStateArgs< + TItem, + TFilterCategoryKey extends string, +> { + /** + * The API data items before filtering + */ + items: TItem[]; + /** + * Definitions of the filters to be used (must include `getItemValue` functions for each category when performing filtering locally) + */ + filterCategories?: FilterCategory[]; + /** + * The "source of truth" state for the filter feature (returned by useFilterState) + */ + filterState: IFilterState; +} + +/** + * Given the "source of truth" state for the filter feature and additional arguments, returns "derived state" values and convenience functions. + * - For local/client-computed tables only. Performs the actual filtering logic, which is done on the server for server-computed tables. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + */ +export const getLocalFilterDerivedState = < + TItem, + TFilterCategoryKey extends string, +>({ + items, + filterCategories = [], + filterState: { filterValues }, +}: ILocalFilterDerivedStateArgs) => { + const filteredItems = items.filter((item) => + objectKeys(filterValues).every((categoryKey) => { + const values = filterValues[categoryKey]; + if (!values || values.length === 0) return true; + const filterCategory = filterCategories.find( + (category) => category.categoryKey === categoryKey + ); + let itemValue = (item as any)[categoryKey]; + if (filterCategory?.getItemValue) { + itemValue = filterCategory.getItemValue(item); + } + const logicOperator = getFilterLogicOperator(filterCategory); + return values[logicOperator === "AND" ? "every" : "some"]( + (filterValue) => { + if (!itemValue) return false; + const lowerCaseItemValue = String(itemValue).toLowerCase(); + const lowerCaseFilterValue = String(filterValue).toLowerCase(); + return lowerCaseItemValue.indexOf(lowerCaseFilterValue) !== -1; + } + ); + }) + ); + return { filteredItems }; +}; diff --git a/client/src/app/hooks/table-controls/filtering/helpers.ts b/client/src/app/hooks/table-controls/filtering/helpers.ts new file mode 100644 index 00000000..67811ac6 --- /dev/null +++ b/client/src/app/hooks/table-controls/filtering/helpers.ts @@ -0,0 +1,43 @@ +import { FilterValue, IFilterValues } from "@app/components/FilterToolbar"; +import { objectKeys } from "@app/utils/utils"; + +/** + * Helper function for useFilterState + * Given a structured filter values object, returns a string to be stored in the feature's PersistTarget (URL params, localStorage, etc). + */ +export const serializeFilterUrlParams = ( + filterValues: IFilterValues +): { filters?: string | null } => { + // If a filter value is empty/cleared, don't put it in the object in URL params + const trimmedFilterValues = { ...filterValues }; + objectKeys(trimmedFilterValues).forEach((filterCategoryKey) => { + if ( + !trimmedFilterValues[filterCategoryKey] || + trimmedFilterValues[filterCategoryKey]?.length === 0 + ) { + delete trimmedFilterValues[filterCategoryKey]; + } + }); + return { + filters: + objectKeys(trimmedFilterValues).length > 0 + ? JSON.stringify(trimmedFilterValues) + : null, // If there are no filters, remove the filters param from the URL entirely. + }; +}; + +/** + * Helper function for useFilterState + * Given a string retrieved from the feature's PersistTarget (URL params, localStorage, etc), converts it back to the structured filter values object. + */ +export const deserializeFilterUrlParams = < + TFilterCategoryKey extends string, +>(serializedParams: { + filters?: string | null; +}): Partial> => { + try { + return JSON.parse(serializedParams.filters || "{}"); + } catch (e) { + return {}; + } +}; diff --git a/client/src/app/hooks/table-controls/filtering/index.ts b/client/src/app/hooks/table-controls/filtering/index.ts new file mode 100644 index 00000000..545b3aec --- /dev/null +++ b/client/src/app/hooks/table-controls/filtering/index.ts @@ -0,0 +1,5 @@ +export * from "./useFilterState"; +export * from "./getLocalFilterDerivedState"; +export * from "./useFilterPropHelpers"; +export * from "./getFilterHubRequestParams"; +export * from "./helpers"; diff --git a/client/src/app/hooks/table-controls/filtering/useFilterPropHelpers.ts b/client/src/app/hooks/table-controls/filtering/useFilterPropHelpers.ts new file mode 100644 index 00000000..1b9469e3 --- /dev/null +++ b/client/src/app/hooks/table-controls/filtering/useFilterPropHelpers.ts @@ -0,0 +1,64 @@ +import { + FilterCategory, + IFilterToolbarProps, +} from "@app/components/FilterToolbar"; +import { IFilterState } from "./useFilterState"; +import { ToolbarProps } from "@patternfly/react-core"; + +/** + * Args for useFilterPropHelpers that come from outside useTableControlProps + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by useTableControlProps (IUseTableControlPropsArgs) + * @see ITableControlState + * @see IUseTableControlPropsArgs + */ +export interface IFilterPropHelpersExternalArgs< + TItem, + TFilterCategoryKey extends string, +> { + /** + * The "source of truth" state for the filter feature (returned by useFilterState) + */ + filterState: IFilterState; + /** + * Definitions of the filters to be used (must include `getItemValue` functions for each category when performing filtering locally) + */ + filterCategories?: FilterCategory[]; +} + +/** + * Returns derived state and prop helpers for the filter feature based on given "source of truth" state. + * - Used internally by useTableControlProps + * - "Derived state" here refers to values and convenience functions derived at render time. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + */ +export const useFilterPropHelpers = ( + args: IFilterPropHelpersExternalArgs +) => { + const { + filterState: { filterValues, setFilterValues }, + filterCategories = [], + } = args; + + /** + * Filter-related props for the PF Toolbar component + */ + const filterPropsForToolbar: ToolbarProps = { + collapseListedFiltersBreakpoint: "xl", + clearAllFilters: () => setFilterValues({}), + clearFiltersButtonText: "Clear all filters", + }; + + /** + * Props for the FilterToolbar component (our component for rendering filters) + */ + const propsForFilterToolbar: IFilterToolbarProps = + { + filterCategories, + filterValues, + setFilterValues, + }; + + // TODO fix the confusing naming here... we have FilterToolbar and Toolbar which both have filter-related props + return { filterPropsForToolbar, propsForFilterToolbar }; +}; diff --git a/client/src/app/hooks/table-controls/filtering/useFilterState.ts b/client/src/app/hooks/table-controls/filtering/useFilterState.ts new file mode 100644 index 00000000..2de43e1f --- /dev/null +++ b/client/src/app/hooks/table-controls/filtering/useFilterState.ts @@ -0,0 +1,110 @@ +import { FilterCategory, IFilterValues } from "@app/components/FilterToolbar"; +import { IFeaturePersistenceArgs } from "../types"; +import { usePersistentState } from "@app/hooks/usePersistentState"; +import { serializeFilterUrlParams } from "./helpers"; +import { deserializeFilterUrlParams } from "./helpers"; +import { DiscriminatedArgs } from "@app/utils/type-utils"; +import { useEffect, useState } from "react"; + +/** + * The "source of truth" state for the filter feature. + * - Included in the object returned by useTableControlState (ITableControlState) under the `filterState` property. + * - Also included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see ITableControlState + * @see ITableControls + */ +export interface IFilterState { + /** + * A mapping: + * - from string keys uniquely identifying a filterCategory (inferred from the `key` properties of elements in the `filterCategories` array) + * - to arrays of strings representing the current value(s) of that filter. Single-value filters are stored as an array with one element. + */ + filterValues: IFilterValues; + /** + * Updates the `filterValues` mapping. + */ + setFilterValues: (values: IFilterValues) => void; +} + +/** + * Args for useFilterState + * - Makes up part of the arguments object taken by useTableControlState (IUseTableControlStateArgs) + * - The properties defined here are only required by useTableControlState if isFilterEnabled is true (see DiscriminatedArgs) + * - Properties here are included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see IUseTableControlStateArgs + * @see DiscriminatedArgs + * @see ITableControls + */ +export type IFilterStateArgs< + TItem, + TFilterCategoryKey extends string, +> = DiscriminatedArgs< + "isFilterEnabled", + { + /** + * Definitions of the filters to be used (must include `getItemValue` functions for each category when performing filtering locally) + */ + filterCategories: FilterCategory[]; + initialFilterValues?: IFilterValues; + } +>; + +/** + * Provides the "source of truth" state for the filter feature. + * - Used internally by useTableControlState + * - Takes args defined above as well as optional args for persisting state to a configurable storage target. + * @see PersistTarget + */ +export const useFilterState = < + TItem, + TFilterCategoryKey extends string, + TPersistenceKeyPrefix extends string = string, +>( + args: IFilterStateArgs & + IFeaturePersistenceArgs +): IFilterState => { + const { isFilterEnabled, persistTo = "state", persistenceKeyPrefix } = args; + + // We need to know if it's the initial load to avoid overwriting changes to the filter values + const [isInitialLoad, setIsInitialLoad] = useState(true); + + let initialFilterValues = {}; + + if (isInitialLoad) { + initialFilterValues = isFilterEnabled + ? args?.initialFilterValues ?? {} + : {}; + } + + useEffect(() => { + if (isInitialLoad) { + setIsInitialLoad(false); + } + }, [isInitialLoad]); + + // We won't need to pass the latter two type params here if TS adds support for partial inference. + // See https://github.com/konveyor/tackle2-ui/issues/1456 + const [filterValues, setFilterValues] = usePersistentState< + IFilterValues, + TPersistenceKeyPrefix, + "filters" + >({ + isEnabled: !!isFilterEnabled, + defaultValue: initialFilterValues, + persistenceKeyPrefix, + // Note: For the discriminated union here to work without TypeScript getting confused + // (e.g. require the urlParams-specific options when persistTo === "urlParams"), + // we need to pass persistTo inside each type-narrowed options object instead of outside the ternary. + ...(persistTo === "urlParams" + ? { + persistTo, + keys: ["filters"], + serialize: serializeFilterUrlParams, + deserialize: deserializeFilterUrlParams, + } + : persistTo === "localStorage" || persistTo === "sessionStorage" + ? { persistTo, key: "filters" } + : { persistTo }), + }); + return { filterValues, setFilterValues }; +}; diff --git a/client/src/app/hooks/table-controls/getHubRequestParams.ts b/client/src/app/hooks/table-controls/getHubRequestParams.ts index 062d0cda..fc7042df 100644 --- a/client/src/app/hooks/table-controls/getHubRequestParams.ts +++ b/client/src/app/hooks/table-controls/getHubRequestParams.ts @@ -3,24 +3,26 @@ import { HubRequestParams } from "@app/api/models"; import { - serializeFilterRequestParamsForHub, - getFilterHubRequestParams, IGetFilterHubRequestParamsArgs, -} from "./getFilterHubRequestParams"; + getFilterHubRequestParams, + serializeFilterRequestParamsForHub, +} from "./filtering"; import { - serializeSortRequestParamsForHub, - getSortHubRequestParams, IGetSortHubRequestParamsArgs, -} from "./getSortHubRequestParams"; + getSortHubRequestParams, + serializeSortRequestParamsForHub, +} from "./sorting"; import { - serializePaginationRequestParamsForHub, - getPaginationHubRequestParams, IGetPaginationHubRequestParamsArgs, -} from "./getPaginationHubRequestParams"; + getPaginationHubRequestParams, + serializePaginationRequestParamsForHub, +} from "./pagination"; + +// TODO move this outside this directory as part of decoupling Konveyor-specific code from table-controls. /** * Returns params required to fetch server-filtered/sorted/paginated data from the hub API. - * - NOTE: This is Hub-specific. + * - NOTE: This is Konveyor-specific. * - Takes "source of truth" state for all table features (returned by useTableControlState), * - Call after useTableControlState and before fetching API data and then calling useTableControlProps. * - Returns a HubRequestParams object which is structured for easier consumption by other code before the fetch is made. @@ -43,7 +45,7 @@ export const getHubRequestParams = < /** * Converts the HubRequestParams object created above into URLSearchParams (the browser API object for URL query parameters). - * - NOTE: This is Hub-specific. + * - NOTE: This is Konveyor-specific. * - Used internally by the application's useFetch[Resource] hooks */ export const serializeRequestParamsForHub = ( @@ -53,19 +55,5 @@ export const serializeRequestParamsForHub = ( serializeFilterRequestParamsForHub(deserializedParams, serializedParams); serializeSortRequestParamsForHub(deserializedParams, serializedParams); serializePaginationRequestParamsForHub(deserializedParams, serializedParams); - - // Sikula forces sort to have "sorting" data within the query itself - // rather than its own queryParams, therefore: - if (serializedParams.has("q") && serializedParams.has("sort")) { - serializedParams.set( - "q", - `${serializedParams.get("q")} (${serializedParams.get("sort")})` - ); - serializedParams.delete("sort"); - } else if (serializedParams.has("sort")) { - serializedParams.set("q", `(${serializedParams.get("sort")})`); - serializedParams.delete("sort"); - } - return serializedParams; }; diff --git a/client/src/app/hooks/table-controls/getLocalTableControlDerivedState.ts b/client/src/app/hooks/table-controls/getLocalTableControlDerivedState.ts new file mode 100644 index 00000000..ec98ee93 --- /dev/null +++ b/client/src/app/hooks/table-controls/getLocalTableControlDerivedState.ts @@ -0,0 +1,54 @@ +import { getLocalFilterDerivedState } from "./filtering"; +import { getLocalSortDerivedState } from "./sorting"; +import { getLocalPaginationDerivedState } from "./pagination"; +import { + ITableControlLocalDerivedStateArgs, + ITableControlDerivedState, + ITableControlState, +} from "./types"; + +/** + * Returns table-level "derived state" (the results of local/client-computed filtering/sorting/pagination) + * - Used internally by the shorthand hook useLocalTableControls. + * - Takes "source of truth" state for all features and additional args. + * @see useLocalTableControls + */ +export const getLocalTableControlDerivedState = < + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, +>( + args: ITableControlState< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix + > & + ITableControlLocalDerivedStateArgs< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey + > +): ITableControlDerivedState => { + const { items, isPaginationEnabled = true } = args; + const { filteredItems } = getLocalFilterDerivedState({ + ...args, + items, + }); + const { sortedItems } = getLocalSortDerivedState({ + ...args, + items: filteredItems, + }); + const { currentPageItems } = getLocalPaginationDerivedState({ + ...args, + items: sortedItems, + }); + return { + totalItemCount: filteredItems.length, + currentPageItems: isPaginationEnabled ? currentPageItems : sortedItems, + }; +}; diff --git a/client/src/app/hooks/table-controls/index.ts b/client/src/app/hooks/table-controls/index.ts index eb811eed..9145da1f 100644 --- a/client/src/app/hooks/table-controls/index.ts +++ b/client/src/app/hooks/table-controls/index.ts @@ -1 +1,12 @@ +export * from "./types"; +export * from "./utils"; +export * from "./useTableControlState"; +export * from "./useTableControlProps"; +export * from "./getLocalTableControlDerivedState"; +export * from "./useLocalTableControls"; export * from "./getHubRequestParams"; +export * from "./filtering"; +export * from "./sorting"; +export * from "./pagination"; +export * from "./expansion"; +export * from "./active-item"; diff --git a/client/src/app/hooks/table-controls/pagination/getLocalPaginationDerivedState.ts b/client/src/app/hooks/table-controls/pagination/getLocalPaginationDerivedState.ts new file mode 100644 index 00000000..307155ae --- /dev/null +++ b/client/src/app/hooks/table-controls/pagination/getLocalPaginationDerivedState.ts @@ -0,0 +1,36 @@ +import { IPaginationState } from "./usePaginationState"; + +/** + * Args for getLocalPaginationDerivedState + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by getLocalTableControlDerivedState (ITableControlLocalDerivedStateArgs) + * @see ITableControlState + * @see ITableControlLocalDerivedStateArgs + */ +export interface ILocalPaginationDerivedStateArgs { + /** + * The API data items before pagination (but after filtering) + */ + items: TItem[]; + /** + * The "source of truth" state for the pagination feature (returned by usePaginationState) + */ + paginationState: IPaginationState; +} + +/** + * Given the "source of truth" state for the pagination feature and additional arguments, returns "derived state" values and convenience functions. + * - For local/client-computed tables only. Performs the actual pagination logic, which is done on the server for server-computed tables. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + */ +export const getLocalPaginationDerivedState = ({ + items, + paginationState: { pageNumber, itemsPerPage }, +}: ILocalPaginationDerivedStateArgs) => { + const pageStartIndex = (pageNumber - 1) * itemsPerPage; + const currentPageItems = items.slice( + pageStartIndex, + pageStartIndex + itemsPerPage + ); + return { currentPageItems }; +}; diff --git a/client/src/app/hooks/table-controls/getPaginationHubRequestParams.ts b/client/src/app/hooks/table-controls/pagination/getPaginationHubRequestParams.ts similarity index 91% rename from client/src/app/hooks/table-controls/getPaginationHubRequestParams.ts rename to client/src/app/hooks/table-controls/pagination/getPaginationHubRequestParams.ts index 68e14747..8103b2ed 100644 --- a/client/src/app/hooks/table-controls/getPaginationHubRequestParams.ts +++ b/client/src/app/hooks/table-controls/pagination/getPaginationHubRequestParams.ts @@ -1,5 +1,5 @@ import { HubRequestParams } from "@app/api/models"; -import { PaginationState } from "@carlosthe19916-latest/react-table-batteries"; +import { IPaginationState } from "./usePaginationState"; /** * Args for getPaginationHubRequestParams @@ -9,7 +9,7 @@ export interface IGetPaginationHubRequestParamsArgs { /** * The "source of truth" state for the pagination feature (returned by usePaginationState) */ - pagination?: PaginationState; + paginationState?: IPaginationState; } /** @@ -18,7 +18,7 @@ export interface IGetPaginationHubRequestParamsArgs { * @see getHubRequestParams */ export const getPaginationHubRequestParams = ({ - pagination: paginationState, + paginationState, }: IGetPaginationHubRequestParamsArgs): Partial => { if (!paginationState) return {}; const { pageNumber, itemsPerPage } = paginationState; diff --git a/client/src/app/hooks/table-controls/pagination/index.ts b/client/src/app/hooks/table-controls/pagination/index.ts new file mode 100644 index 00000000..b3d17069 --- /dev/null +++ b/client/src/app/hooks/table-controls/pagination/index.ts @@ -0,0 +1,5 @@ +export * from "./usePaginationState"; +export * from "./getLocalPaginationDerivedState"; +export * from "./usePaginationPropHelpers"; +export * from "./usePaginationEffects"; +export * from "./getPaginationHubRequestParams"; diff --git a/client/src/app/hooks/table-controls/pagination/usePaginationEffects.ts b/client/src/app/hooks/table-controls/pagination/usePaginationEffects.ts new file mode 100644 index 00000000..7fe9109c --- /dev/null +++ b/client/src/app/hooks/table-controls/pagination/usePaginationEffects.ts @@ -0,0 +1,35 @@ +import * as React from "react"; +import { IPaginationState } from "./usePaginationState"; + +/** + * Args for usePaginationEffects + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by useTableControlProps (IUseTableControlPropsArgs) + */ +export interface IUsePaginationEffectsArgs { + isPaginationEnabled?: boolean; + paginationState: IPaginationState; + totalItemCount: number; + isLoading?: boolean; +} + +/** + * Registers side effects necessary to prevent invalid state related to the pagination feature. + * - Used internally by usePaginationPropHelpers as part of useTableControlProps + * - The effect: When API data updates, if there are fewer total items and the current page no longer exists + * (e.g. you were on page 11 and now the last page is 10), move to the last page of data. + */ +export const usePaginationEffects = ({ + isPaginationEnabled, + paginationState: { itemsPerPage, pageNumber, setPageNumber }, + totalItemCount, + isLoading = false, +}: IUsePaginationEffectsArgs) => { + // When items are removed, make sure the current page still exists + const lastPageNumber = Math.max(Math.ceil(totalItemCount / itemsPerPage), 1); + React.useEffect(() => { + if (isPaginationEnabled && pageNumber > lastPageNumber && !isLoading) { + setPageNumber(lastPageNumber); + } + }); +}; diff --git a/client/src/app/hooks/table-controls/pagination/usePaginationPropHelpers.ts b/client/src/app/hooks/table-controls/pagination/usePaginationPropHelpers.ts new file mode 100644 index 00000000..1f1fd178 --- /dev/null +++ b/client/src/app/hooks/table-controls/pagination/usePaginationPropHelpers.ts @@ -0,0 +1,70 @@ +import { PaginationProps, ToolbarItemProps } from "@patternfly/react-core"; +import { IPaginationState } from "./usePaginationState"; +import { + IUsePaginationEffectsArgs, + usePaginationEffects, +} from "./usePaginationEffects"; + +/** + * Args for usePaginationPropHelpers that come from outside useTableControlProps + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by useTableControlProps (IUseTableControlPropsArgs) + * @see ITableControlState + * @see IUseTableControlPropsArgs + */ +export type IPaginationPropHelpersExternalArgs = IUsePaginationEffectsArgs & { + /** + * The "source of truth" state for the pagination feature (returned by usePaginationState) + */ + paginationState: IPaginationState; + /** + The total number of items in the entire un-filtered, un-paginated table (the size of the entire API collection being tabulated). + */ + totalItemCount: number; +}; + +/** + * Returns derived state and prop helpers for the pagination feature based on given "source of truth" state. + * - Used internally by useTableControlProps + * - "Derived state" here refers to values and convenience functions derived at render time. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + */ +export const usePaginationPropHelpers = ( + args: IPaginationPropHelpersExternalArgs +) => { + const { + totalItemCount, + paginationState: { + itemsPerPage, + pageNumber, + setPageNumber, + setItemsPerPage, + }, + } = args; + + usePaginationEffects(args); + + /** + * Props for the PF Pagination component + */ + const paginationProps: PaginationProps = { + itemCount: totalItemCount, + perPage: itemsPerPage, + page: pageNumber, + onSetPage: (event, pageNumber) => setPageNumber(pageNumber), + onPerPageSelect: (event, perPage) => { + setPageNumber(1); + setItemsPerPage(perPage); + }, + }; + + /** + * Props for the PF ToolbarItem component which contains the Pagination component + */ + const paginationToolbarItemProps: ToolbarItemProps = { + variant: "pagination", + align: { default: "alignRight" }, + }; + + return { paginationProps, paginationToolbarItemProps }; +}; diff --git a/client/src/app/hooks/table-controls/pagination/usePaginationState.ts b/client/src/app/hooks/table-controls/pagination/usePaginationState.ts new file mode 100644 index 00000000..6fd87c84 --- /dev/null +++ b/client/src/app/hooks/table-controls/pagination/usePaginationState.ts @@ -0,0 +1,133 @@ +import { usePersistentState } from "@app/hooks/usePersistentState"; +import { IFeaturePersistenceArgs } from "../types"; +import { DiscriminatedArgs } from "@app/utils/type-utils"; + +/** + * The currently applied pagination parameters + */ +export interface IActivePagination { + /** + * The current page number on the user's pagination controls (counting from 1) + */ + pageNumber: number; + /** + * The current "items per page" setting on the user's pagination controls (defaults to 10) + */ + itemsPerPage: number; +} + +/** + * The "source of truth" state for the pagination feature. + * - Included in the object returned by useTableControlState (ITableControlState) under the `paginationState` property. + * - Also included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see ITableControlState + * @see ITableControls + */ +export interface IPaginationState extends IActivePagination { + /** + * Updates the current page number on the user's pagination controls (counting from 1) + */ + setPageNumber: (pageNumber: number) => void; + /** + * Updates the "items per page" setting on the user's pagination controls (defaults to 10) + */ + setItemsPerPage: (numItems: number) => void; +} + +/** + * Args for usePaginationState + * - Makes up part of the arguments object taken by useTableControlState (IUseTableControlStateArgs) + * - The properties defined here are only required by useTableControlState if isPaginationEnabled is true (see DiscriminatedArgs) + * - Properties here are included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see IUseTableControlStateArgs + * @see DiscriminatedArgs + * @see ITableControls + */ +export type IPaginationStateArgs = DiscriminatedArgs< + "isPaginationEnabled", + { + /** + * The initial value of the "items per page" setting on the user's pagination controls (defaults to 10) + */ + initialItemsPerPage?: number; + } +>; + +/** + * Provides the "source of truth" state for the pagination feature. + * - Used internally by useTableControlState + * - Takes args defined above as well as optional args for persisting state to a configurable storage target. + * @see PersistTarget + */ +export const usePaginationState = < + TPersistenceKeyPrefix extends string = string, +>( + args: IPaginationStateArgs & IFeaturePersistenceArgs +): IPaginationState => { + const { + isPaginationEnabled, + persistTo = "state", + persistenceKeyPrefix, + } = args; + const initialItemsPerPage = + (isPaginationEnabled && args.initialItemsPerPage) || 10; + + const defaultValue: IActivePagination = { + pageNumber: 1, + itemsPerPage: initialItemsPerPage, + }; + + // We won't need to pass the latter two type params here if TS adds support for partial inference. + // See https://github.com/konveyor/tackle2-ui/issues/1456 + const [paginationState, setPaginationState] = usePersistentState< + IActivePagination, + TPersistenceKeyPrefix, + "pageNumber" | "itemsPerPage" + >({ + isEnabled: !!isPaginationEnabled, + defaultValue, + persistenceKeyPrefix, + // Note: For the discriminated union here to work without TypeScript getting confused + // (e.g. require the urlParams-specific options when persistTo === "urlParams"), + // we need to pass persistTo inside each type-narrowed options object instead of outside the ternary. + ...(persistTo === "urlParams" + ? { + persistTo, + keys: ["pageNumber", "itemsPerPage"], + serialize: (state) => { + const { pageNumber, itemsPerPage } = state || {}; + return { + pageNumber: pageNumber ? String(pageNumber) : undefined, + itemsPerPage: itemsPerPage ? String(itemsPerPage) : undefined, + }; + }, + deserialize: (urlParams) => { + const { pageNumber, itemsPerPage } = urlParams || {}; + return pageNumber && itemsPerPage + ? { + pageNumber: parseInt(pageNumber, 10), + itemsPerPage: parseInt(itemsPerPage, 10), + } + : defaultValue; + }, + } + : persistTo === "localStorage" || persistTo === "sessionStorage" + ? { + persistTo, + key: "pagination", + } + : { persistTo }), + }); + const { pageNumber, itemsPerPage } = paginationState || defaultValue; + const setPageNumber = (num: number) => + setPaginationState({ + pageNumber: num >= 1 ? num : 1, + itemsPerPage: paginationState?.itemsPerPage || initialItemsPerPage, + }); + const setItemsPerPage = (itemsPerPage: number) => + setPaginationState({ + pageNumber: paginationState?.pageNumber || 1, + itemsPerPage, + }); + return { pageNumber, setPageNumber, itemsPerPage, setItemsPerPage }; +}; diff --git a/client/src/app/hooks/table-controls/sorting/getLocalSortDerivedState.ts b/client/src/app/hooks/table-controls/sorting/getLocalSortDerivedState.ts new file mode 100644 index 00000000..774a752d --- /dev/null +++ b/client/src/app/hooks/table-controls/sorting/getLocalSortDerivedState.ts @@ -0,0 +1,72 @@ +import { ISortState } from "./useSortState"; + +/** + * Args for getLocalSortDerivedState + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by getLocalTableControlDerivedState (ITableControlLocalDerivedStateArgs) + * @see ITableControlState + * @see ITableControlLocalDerivedStateArgs + */ +export interface ILocalSortDerivedStateArgs< + TItem, + TSortableColumnKey extends string, +> { + /** + * The API data items before sorting + */ + items: TItem[]; + /** + * A callback function to return, for a given API data item, a record of sortable primitives for that item's sortable columns + * - The record maps: + * - from `columnKey` values (the keys of the `columnNames` object passed to useTableControlState) + * - to easily sorted primitive values (string | number | boolean) for this item's value in that column + */ + getSortValues?: ( + // TODO can we require this as non-optional in types that extend this when we know we're configuring a client-computed table? + item: TItem + ) => Record; + /** + * The "source of truth" state for the sort feature (returned by useSortState) + */ + sortState: ISortState; +} + +/** + * Given the "source of truth" state for the sort feature and additional arguments, returns "derived state" values and convenience functions. + * - For local/client-computed tables only. Performs the actual sorting logic, which is done on the server for server-computed tables. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + */ +export const getLocalSortDerivedState = < + TItem, + TSortableColumnKey extends string, +>({ + items, + getSortValues, + sortState: { activeSort }, +}: ILocalSortDerivedStateArgs) => { + if (!getSortValues || !activeSort) { + return { sortedItems: items }; + } + + let sortedItems = items; + sortedItems = [...items].sort((a: TItem, b: TItem) => { + let aValue = getSortValues(a)[activeSort.columnKey]; + let bValue = getSortValues(b)[activeSort.columnKey]; + if (typeof aValue === "string" && typeof bValue === "string") { + aValue = aValue.replace(/ +/g, ""); + bValue = bValue.replace(/ +/g, ""); + const aSortResult = aValue.localeCompare(bValue); + const bSortResult = bValue.localeCompare(aValue); + return activeSort.direction === "asc" ? aSortResult : bSortResult; + } else if (typeof aValue === "number" && typeof bValue === "number") { + return activeSort.direction === "asc" ? aValue - bValue : bValue - aValue; + } else { + if (aValue > bValue) return activeSort.direction === "asc" ? -1 : 1; + if (aValue < bValue) return activeSort.direction === "asc" ? -1 : 1; + } + + return 0; + }); + + return { sortedItems }; +}; diff --git a/client/src/app/hooks/table-controls/getSortHubRequestParams.ts b/client/src/app/hooks/table-controls/sorting/getSortHubRequestParams.ts similarity index 89% rename from client/src/app/hooks/table-controls/getSortHubRequestParams.ts rename to client/src/app/hooks/table-controls/sorting/getSortHubRequestParams.ts index 79bddc65..155b053b 100644 --- a/client/src/app/hooks/table-controls/getSortHubRequestParams.ts +++ b/client/src/app/hooks/table-controls/sorting/getSortHubRequestParams.ts @@ -1,5 +1,5 @@ import { HubRequestParams } from "@app/api/models"; -import { SortState } from "@carlosthe19916-latest/react-table-batteries"; +import { ISortState } from "./useSortState"; /** * Args for getSortHubRequestParams @@ -11,7 +11,7 @@ export interface IGetSortHubRequestParamsArgs< /** * The "source of truth" state for the sort feature (returned by usePaginationState) */ - sort?: SortState; + sortState?: ISortState; /** * A map of `columnKey` values (keys of the `columnNames` object passed to useTableControlState) to the field keys used by the hub API for sorting on those columns * - Keys and values in this object will usually be the same, but sometimes we need to present a hub field with a different name/key or have a column that is a composite of multiple hub fields. @@ -25,7 +25,7 @@ export interface IGetSortHubRequestParamsArgs< * @see getHubRequestParams */ export const getSortHubRequestParams = ({ - sort: sortState, + sortState, hubSortFieldKeys, }: IGetSortHubRequestParamsArgs): Partial => { if (!sortState?.activeSort || !hubSortFieldKeys) return {}; @@ -51,10 +51,6 @@ export const serializeSortRequestParamsForHub = ( const { sort } = deserializedParams; if (sort) { const { field, direction } = sort; - - serializedParams.append( - "sort", - `${direction === "desc" ? "-" : ""}sort:${field}` - ); + serializedParams.append("sort", `${direction}:${field}`); } }; diff --git a/client/src/app/hooks/table-controls/sorting/index.ts b/client/src/app/hooks/table-controls/sorting/index.ts new file mode 100644 index 00000000..cf37beb7 --- /dev/null +++ b/client/src/app/hooks/table-controls/sorting/index.ts @@ -0,0 +1,4 @@ +export * from "./useSortState"; +export * from "./getLocalSortDerivedState"; +export * from "./useSortPropHelpers"; +export * from "./getSortHubRequestParams"; diff --git a/client/src/app/hooks/table-controls/sorting/useSortPropHelpers.ts b/client/src/app/hooks/table-controls/sorting/useSortPropHelpers.ts new file mode 100644 index 00000000..a8fa132f --- /dev/null +++ b/client/src/app/hooks/table-controls/sorting/useSortPropHelpers.ts @@ -0,0 +1,84 @@ +import { ThProps } from "@patternfly/react-table"; +import { ISortState } from "./useSortState"; + +/** + * Args for useSortPropHelpers that come from outside useTableControlProps + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + * - Makes up part of the arguments object taken by useTableControlProps (IUseTableControlPropsArgs) + * @see ITableControlState + * @see IUseTableControlPropsArgs + */ +export interface ISortPropHelpersExternalArgs< + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, +> { + /** + * The "source of truth" state for the sort feature (returned by useSortState) + */ + sortState: ISortState; + /** + * The `columnKey` values (keys of the `columnNames` object passed to useTableControlState) corresponding to columns with sorting enabled + */ + sortableColumns?: TSortableColumnKey[]; +} + +/** + * Additional args for useSortPropHelpers that come from logic inside useTableControlProps + * @see useTableControlProps + */ +export interface ISortPropHelpersInternalArgs { + /** + * The keys of the `columnNames` object passed to useTableControlState (for all columns, not just the sortable ones) + */ + columnKeys: TColumnKey[]; +} + +/** + * Returns derived state and prop helpers for the sort feature based on given "source of truth" state. + * - Used internally by useTableControlProps + * - "Derived state" here refers to values and convenience functions derived at render time. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + */ +export const useSortPropHelpers = < + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, +>( + args: ISortPropHelpersExternalArgs & + ISortPropHelpersInternalArgs +) => { + const { + sortState: { activeSort, setActiveSort }, + sortableColumns = [], + columnKeys, + } = args; + + /** + * Returns props for the Th component for a column with sorting enabled. + */ + const getSortThProps = ({ + columnKey, + }: { + columnKey: TSortableColumnKey; + }): Pick => + sortableColumns.includes(columnKey) + ? { + sort: { + columnIndex: columnKeys.indexOf(columnKey), + sortBy: { + index: activeSort + ? columnKeys.indexOf(activeSort.columnKey) + : undefined, + direction: activeSort?.direction, + }, + onSort: (event, index, direction) => { + setActiveSort({ + columnKey: columnKeys[index] as TSortableColumnKey, + direction, + }); + }, + }, + } + : {}; + + return { getSortThProps }; +}; diff --git a/client/src/app/hooks/table-controls/sorting/useSortState.ts b/client/src/app/hooks/table-controls/sorting/useSortState.ts new file mode 100644 index 00000000..87fc6190 --- /dev/null +++ b/client/src/app/hooks/table-controls/sorting/useSortState.ts @@ -0,0 +1,117 @@ +import { DiscriminatedArgs } from "@app/utils/type-utils"; +import { IFeaturePersistenceArgs } from ".."; +import { usePersistentState } from "@app/hooks/usePersistentState"; + +/** + * The currently applied sort parameters + */ +export interface IActiveSort { + /** + * The identifier for the currently sorted column (`columnKey` values come from the keys of the `columnNames` object passed to useTableControlState) + */ + columnKey: TSortableColumnKey; + /** + * The direction of the currently applied sort (ascending or descending) + */ + direction: "asc" | "desc"; +} + +/** + * The "source of truth" state for the sort feature. + * - Included in the object returned by useTableControlState (ITableControlState) under the `sortState` property. + * - Also included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see ITableControlState + * @see ITableControls + */ +export interface ISortState { + /** + * The currently applied sort column and direction + */ + activeSort: IActiveSort | null; + /** + * Updates the currently applied sort column and direction + */ + setActiveSort: (sort: IActiveSort) => void; +} + +/** + * Args for useSortState + * - Makes up part of the arguments object taken by useTableControlState (IUseTableControlStateArgs) + * - The properties defined here are only required by useTableControlState if isSortEnabled is true (see DiscriminatedArgs) + * - Properties here are included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see IUseTableControlStateArgs + * @see DiscriminatedArgs + * @see ITableControls + */ +export type ISortStateArgs = + DiscriminatedArgs< + "isSortEnabled", + { + /** + * The `columnKey` values (keys of the `columnNames` object passed to useTableControlState) corresponding to columns with sorting enabled + */ + sortableColumns: TSortableColumnKey[]; + /** + * The sort column and direction that should be applied by default when the table first loads + */ + initialSort?: IActiveSort | null; + } + >; + +/** + * Provides the "source of truth" state for the sort feature. + * - Used internally by useTableControlState + * - Takes args defined above as well as optional args for persisting state to a configurable storage target. + * @see PersistTarget + */ +export const useSortState = < + TSortableColumnKey extends string, + TPersistenceKeyPrefix extends string = string, +>( + args: ISortStateArgs & + IFeaturePersistenceArgs +): ISortState => { + const { isSortEnabled, persistTo = "state", persistenceKeyPrefix } = args; + const sortableColumns = (isSortEnabled && args.sortableColumns) || []; + const initialSort: IActiveSort | null = sortableColumns[0] + ? { columnKey: sortableColumns[0], direction: "asc" } + : null; + + // We won't need to pass the latter two type params here if TS adds support for partial inference. + // See https://github.com/konveyor/tackle2-ui/issues/1456 + const [activeSort, setActiveSort] = usePersistentState< + IActiveSort | null, + TPersistenceKeyPrefix, + "sortColumn" | "sortDirection" + >({ + isEnabled: !!isSortEnabled, + defaultValue: initialSort, + persistenceKeyPrefix, + // Note: For the discriminated union here to work without TypeScript getting confused + // (e.g. require the urlParams-specific options when persistTo === "urlParams"), + // we need to pass persistTo inside each type-narrowed options object instead of outside the ternary. + ...(persistTo === "urlParams" + ? { + persistTo, + keys: ["sortColumn", "sortDirection"], + serialize: (activeSort) => ({ + sortColumn: activeSort?.columnKey || null, + sortDirection: activeSort?.direction || null, + }), + deserialize: (urlParams) => + urlParams.sortColumn && urlParams.sortDirection + ? { + columnKey: urlParams.sortColumn as TSortableColumnKey, + direction: urlParams.sortDirection as "asc" | "desc", + } + : null, + } + : persistTo === "localStorage" || persistTo === "sessionStorage" + ? { + persistTo, + key: "sort", + } + : { persistTo }), + }); + return { activeSort, setActiveSort }; +}; diff --git a/client/src/app/hooks/table-controls/types.ts b/client/src/app/hooks/table-controls/types.ts new file mode 100644 index 00000000..daa4a891 --- /dev/null +++ b/client/src/app/hooks/table-controls/types.ts @@ -0,0 +1,485 @@ +import { TableProps, TdProps, ThProps, TrProps } from "@patternfly/react-table"; +import { ISelectionStateArgs, useSelectionState } from "@app/hooks/useSelectionState"; +import { DisallowCharacters, DiscriminatedArgs } from "@app/utils/type-utils"; +import { + IFilterStateArgs, + ILocalFilterDerivedStateArgs, + IFilterPropHelpersExternalArgs, + IFilterState, +} from "./filtering"; +import { + ILocalSortDerivedStateArgs, + ISortPropHelpersExternalArgs, + ISortState, + ISortStateArgs, +} from "./sorting"; +import { + IPaginationStateArgs, + ILocalPaginationDerivedStateArgs, + IPaginationPropHelpersExternalArgs, + IPaginationState, +} from "./pagination"; +import { + IExpansionDerivedState, + IExpansionState, + IExpansionStateArgs, +} from "./expansion"; +import { + IActiveItemDerivedState, + IActiveItemPropHelpersExternalArgs, + IActiveItemState, + IActiveItemStateArgs, +} from "./active-item"; +import { + PaginationProps, + ToolbarItemProps, + ToolbarProps, +} from "@patternfly/react-core"; +import { IFilterToolbarProps } from "@app/components/FilterToolbar"; +import { IToolbarBulkSelectorProps } from "@app/components/ToolbarBulkSelector"; +import { IExpansionPropHelpersExternalArgs } from "./expansion/useExpansionPropHelpers"; +import { IColumnState } from "./column/useColumnState"; + +// Generic type params used here: +// TItem - The actual API objects represented by rows in the table. Can be any object. +// TColumnKey - Union type of unique identifier strings for the columns in the table +// TSortableColumnKey - A subset of column keys that have sorting enabled +// TFilterCategoryKey - Union type of unique identifier strings for filters (not necessarily the same as column keys) +// TPersistenceKeyPrefix - String (must not include a `:` character) used to distinguish persisted state for multiple tables +// TODO move this to DOCS.md and reference the paragraph here + +/** + * Identifier for a feature of the table. State concerns are separated by feature. + */ +export type TableFeature = + | "filter" + | "sort" + | "pagination" + | "selection" + | "expansion" + | "activeItem" + | "columns"; + +/** + * Identifier for where to persist state for a single table feature or for all table features. + * - "state" (default) - Plain React state. Resets on component unmount or page reload. + * - "urlParams" (recommended) - URL query parameters. Persists on page reload, browser history buttons (back/forward) or loading a bookmark. Resets on page navigation. + * - "localStorage" - Browser localStorage API. Persists semi-permanently and is shared across all tabs/windows. Resets only when the user clears their browsing data. + * - "sessionStorage" - Browser sessionStorage API. Persists on page/history navigation/reload. Resets when the tab/window is closed. + */ +export type PersistTarget = + | "state" + | "urlParams" + | "localStorage" + | "sessionStorage"; + +/** + * Common persistence-specific args + * - Makes up part of the arguments object taken by useTableControlState (IUseTableControlStateArgs) + * - Extra args needed for persisting state both at the table level and in each use[Feature]State hook. + * - Not required if using the default "state" PersistTarget + */ +export type ICommonPersistenceArgs< + TPersistenceKeyPrefix extends string = string, +> = { + /** + * A short string uniquely identifying a specific table. Automatically prepended to any key used in state persistence (e.g. in a URL parameter or localStorage). + * - Optional: Only omit if this table will not be rendered at the same time as any other tables. + * - Allows multiple tables to be used on the same page with the same PersistTarget. + * - Cannot contain a `:` character since this is used as the delimiter in the prefixed key. + * - Should be short, especially when using the "urlParams" PersistTarget. + */ + persistenceKeyPrefix?: DisallowCharacters; +}; +/** + * Feature-level persistence-specific args + * - Extra args needed for persisting state in each use[Feature]State hook. + * - Not required if using the default "state" PersistTarget. + */ +export type IFeaturePersistenceArgs< + TPersistenceKeyPrefix extends string = string, +> = ICommonPersistenceArgs & { + /** + * Where to persist state for this feature. + */ + persistTo?: PersistTarget; +}; +/** + * Table-level persistence-specific args + * - Extra args needed for persisting state at the table level. + * - Supports specifying a single PersistTarget for the whole table or a different PersistTarget for each feature. + * - When using multiple PersistTargets, a `default` target can be passed that will be used for any features not configured explicitly. + * - Not required if using the default "state" PersistTarget. + */ +export type ITablePersistenceArgs< + TPersistenceKeyPrefix extends string = string, +> = ICommonPersistenceArgs & { + /** + * Where to persist state for this table. Can either be a single target for all features or an object mapping individual features to different targets. + */ + persistTo?: + | PersistTarget + | Partial>; +}; + +/** + * Table-level state configuration arguments + * - Taken by useTableControlState + * - Made up of the combined feature-level state configuration argument objects. + * - Does not require any state or API data in scope (can be called at the top of your component). + * - Requires/disallows feature-specific args based on `is[Feature]Enabled` booleans via discriminated unions (see individual [Feature]StateArgs types) + * - Properties here are included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see ITableControls + */ +export type IUseTableControlStateArgs< + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, +> = { + /** + * Unique table identifier. Used for state persistence and to distinguish between stored state between multiple tables. + */ + tableName: string; + /** + * An ordered mapping of unique keys to human-readable column name strings. + * - Keys of this object are used as unique identifiers for columns (`columnKey`). + * - Values of this object are rendered in the column headers by default (can be overridden by passing children to ) and used as `dataLabel` for cells in the column. + */ + columnNames: Record; + /** + * Initial state for the columns feature. If omitted, all columns are enabled by default. + */ +} & IFilterStateArgs & + ISortStateArgs & + IPaginationStateArgs & { + isSelectionEnabled?: boolean; // TODO move this into useSelectionState when we move it from lib-ui + } & IExpansionStateArgs & + IActiveItemStateArgs & + ITablePersistenceArgs; + +/** + * Table-level state object + * - Returned by useTableControlState + * - Provides persisted "source of truth" state for all table features. + * - Also includes all of useTableControlState's arguments for convenience, since useTableControlProps requires them along with the state itself. + * - Note that this only contains the "source of truth" state and does not include "derived state" which is computed at render time. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + * - Properties here are included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see ITableControls + */ +export type ITableControlState< + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, +> = IUseTableControlStateArgs< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix +> & { + /** + * State for the filter feature. Returned by useFilterState. + */ + filterState: IFilterState; + /** + * State for the sort feature. Returned by useSortState. + */ + sortState: ISortState; + /** + * State for the pagination feature. Returned by usePaginationState. + */ + paginationState: IPaginationState; + /** + * State for the expansion feature. Returned by usePaginationState. + */ + expansionState: IExpansionState; + /** + * State for the active item feature. Returned by useActiveItemState. + */ + activeItemState: IActiveItemState; + /** + * State for the columns feature. Returned by useColumnState. + */ + columnState: IColumnState; +}; + +/** + * Table-level local derived state configuration arguments + * - "Local derived state" refers to the results of client-side filtering/sorting/pagination. This is not used for server-paginated tables. + * - Made up of the combined feature-level local derived state argument objects. + * - Used by getLocalTableControlDerivedState. + * - getLocalTableControlDerivedState also requires the return values from useTableControlState. + * - Also used indirectly by the useLocalTableControls shorthand hook. + * - Requires state and API data in scope (or just API data if using useLocalTableControls). + */ +export type ITableControlLocalDerivedStateArgs< + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, +> = ILocalFilterDerivedStateArgs & + ILocalSortDerivedStateArgs & + ILocalPaginationDerivedStateArgs; +// There is no ILocalExpansionDerivedStateArgs type because expansion derived state is always local and internal to useTableControlProps +// There is no ILocalActiveItemDerivedStateArgs type because expansion derived state is always local and internal to useTableControlProps + +/** + * Table-level derived state object + * - "Derived state" here refers to the results of filtering/sorting/pagination performed either on the client or the server. + * - Makes up part of the arguments object taken by useTableControlProps (IUseTableControlPropsArgs) + * - Provided by either: + * - Return values of getLocalTableControlDerivedState (client-side filtering/sorting/pagination) + * - The consumer directly (server-side filtering/sorting/pagination) + * - Properties here are included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see ITableControls + */ +export type ITableControlDerivedState = { + /** + * The items to be rendered on the current page of the table. These items have already been filtered, sorted and paginated. + */ + currentPageItems: TItem[]; + /** + * The total number of items after filtering but before pagination. + */ + totalItemCount: number; +}; + +/** + * Rendering configuration arguments + * - Used by only useTableControlProps + * - Requires state and API data in scope + * - Combines all args for useTableControlState with the return values of useTableControlState, args used only for rendering, and args derived from either: + * - Server-side filtering/sorting/pagination provided by the consumer + * - getLocalTableControlDerivedState (client-side filtering/sorting/pagination) + * - Properties here are included in the `ITableControls` object returned by useTableControlProps and useLocalTableControls. + * @see ITableControls + */ +export type IUseTableControlPropsArgs< + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, +> = IUseTableControlStateArgs< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix +> & + IFilterPropHelpersExternalArgs & + ISortPropHelpersExternalArgs & + IPaginationPropHelpersExternalArgs & + // ISelectionPropHelpersExternalArgs // TODO when we move selection from lib-ui + IExpansionPropHelpersExternalArgs & + IActiveItemPropHelpersExternalArgs & + ITableControlDerivedState & { + /** + * Whether the table data is loading + */ + isLoading?: boolean; + /** + * Override the `numRenderedColumns` value used internally. This should be equal to the colSpan of a cell that takes the full width of the table. + * - Optional: when omitted, the value used is based on the number of `columnNames` and whether features are enabled that insert additional columns (like checkboxes for selection, a kebab for actions, etc). + */ + forceNumRenderedColumns?: number; + /** + * The variant of the table. Affects some spacing. Gets included in `propHelpers.tableProps`. + */ + variant?: TableProps["variant"]; + /** + * Whether there is a separate column for action buttons/menus at the right side of the table + */ + hasActionsColumn?: boolean; + /** + * Selection state + * @todo this won't be included here when useSelectionState gets moved from lib-ui. It is separated from the other state temporarily and used only at render time. + */ + selectionState: ReturnType>; + /** + * The state for the columns feature. Returned by useColumnState. + */ + columnState: IColumnState; + }; + +/** + * Table controls object + * - The object used for rendering. Includes everything you need to return JSX for your table. + * - Returned by useTableControlProps and useLocalTableControls + * - Includes all args and return values from useTableControlState and useTableControlProps (configuration, state, derived state and propHelpers). + */ +export type ITableControls< + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, +> = IUseTableControlPropsArgs< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix +> & { + /** + * The number of extra non-data columns that appear before the data in each row. Based on whether selection and single-expansion features are enabled. + */ + numColumnsBeforeData: number; + /** + * The number of extra non-data columns that appear after the data in each row. Based on `hasActionsColumn`. + */ + numColumnsAfterData: number; + /** + * The total number of columns to be rendered including data and non-data columns. + */ + numRenderedColumns: number; + /** + * Values derived at render time from the expansion feature state. Includes helper functions for convenience. + */ + expansionDerivedState: IExpansionDerivedState; + /** + * Values derived at render time from the column feature state. Includes helper functions for convenience. + * + * + * + * + */ + columnState: IColumnState; + /** + * Values derived at render time from the active-item feature state. Includes helper functions for convenience. + */ + + activeItemDerivedState: IActiveItemDerivedState; + /** + * Prop helpers: where it all comes together. + * These objects and functions provide props for specific PatternFly components in your table derived from the state and arguments above. + * As much of the prop passing as possible is abstracted away via these helpers, which are to be used with spread syntax (e.g. ). + * Any props included here can be overridden by simply passing additional props after spreading the helper onto a component. + */ + propHelpers: { + /** + * Props for the Toolbar component. + * Includes spacing based on the table variant and props related to filtering. + */ + toolbarProps: Omit; + /** + * Props for the Table component. + */ + tableProps: Omit; + /** + * Returns props for the Th component for a specific column. + * Includes default children (column name) and props related to sorting. + */ + getThProps: (args: { columnKey: TColumnKey }) => Omit; + /** + * Returns props for the Tr component for a specific data item. + * Includes props related to the active-item feature. + */ + getTrProps: (args: { + item: TItem; + onRowClick?: TrProps["onRowClick"]; + }) => Omit; + /** + * Returns props for the Td component for a specific column. + * Includes default `dataLabel` (column name) and props related to compound expansion. + * If this cell is a toggle for a compound-expandable row, pass `isCompoundExpandToggle: true`. + * @param args - `columnKey` is always required. If `isCompoundExpandToggle` is passed, `item` and `rowIndex` are also required. + */ + getTdProps: ( + args: { columnKey: TColumnKey } & DiscriminatedArgs< + "isCompoundExpandToggle", + { item: TItem; rowIndex: number } + > + ) => Omit; + /** + * Props for the FilterToolbar component. + */ + filterToolbarProps: IFilterToolbarProps; + /** + * Props for the Pagination component. + */ + paginationProps: PaginationProps; + /** + * Props for the ToolbarItem component containing the Pagination component above the table. + */ + paginationToolbarItemProps: ToolbarItemProps; + /** + * Props for the ToolbarBulkSelector component. + */ + toolbarBulkSelectorProps: IToolbarBulkSelectorProps; + /** + * Returns props for the Td component used as the checkbox cell for each row when using the selection feature. + */ + getSelectCheckboxTdProps: (args: { + item: TItem; + rowIndex: number; + }) => Omit; + /** + * Returns props for the Td component used as the expand toggle when using the single-expand variant of the expansion feature. + */ + getSingleExpandButtonTdProps: (args: { + item: TItem; + rowIndex: number; + }) => Omit; + /** + * Returns props for the Td component used to contain the expanded content when using the expansion feature. + * The Td rendered with these props should be the only child of its Tr, which should be directly after the Tr of the row being expanded. + * The two Trs for the expandable row and expanded content row should be contained in a Tbody with no other Tr components. + */ + getExpandedContentTdProps: (args: { item: TItem }) => Omit; + + /** + * Returns the visibility of a column + */ + + getColumnVisibility: (columnKey: TColumnKey) => boolean; + }; +}; + +/** + * Combined configuration arguments for client-paginated tables + * - Used by useLocalTableControls shorthand hook + * - Combines args for useTableControlState, getLocalTableControlDerivedState and useTableControlProps, omitting args for any of these that come from return values of the others. + */ +export type IUseLocalTableControlsArgs< + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, +> = IUseTableControlStateArgs< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix +> & + Omit< + ITableControlLocalDerivedStateArgs< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey + > & + IUseTableControlPropsArgs< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey + >, + | keyof ITableControlDerivedState + | keyof ITableControlState< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix + > + | "selectionState" // TODO this won't be included here when selection is part of useTableControlState + > & + Pick, "initialSelected" | "isItemSelectable">; // TODO this won't be included here when selection is part of useTableControlState diff --git a/client/src/app/hooks/table-controls/useLocalTableControls.ts b/client/src/app/hooks/table-controls/useLocalTableControls.ts new file mode 100644 index 00000000..9f384687 --- /dev/null +++ b/client/src/app/hooks/table-controls/useLocalTableControls.ts @@ -0,0 +1,49 @@ +import { useTableControlProps } from "./useTableControlProps"; +import { ITableControls, IUseLocalTableControlsArgs } from "./types"; +import { getLocalTableControlDerivedState } from "./getLocalTableControlDerivedState"; +import { useTableControlState } from "./useTableControlState"; +import { useSelectionState } from "../useSelectionState"; + +/** + * Provides all state, derived state, side-effects and prop helpers needed to manage a local/client-computed table. + * - Call this and only this if you aren't using server-side filtering/sorting/pagination. + * - "Derived state" here refers to values and convenience functions derived at render time based on the "source of truth" state. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + */ +export const useLocalTableControls = < + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, +>( + args: IUseLocalTableControlsArgs< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix + > +): ITableControls< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix +> => { + const state = useTableControlState(args); + const derivedState = getLocalTableControlDerivedState({ ...args, ...state }); + const { columnState } = state; + return useTableControlProps({ + ...args, + ...state, + ...derivedState, + // TODO we won't need this here once selection state is part of useTableControlState + selectionState: useSelectionState({ + ...args, + isEqual: (a, b) => a[args.idProperty] === b[args.idProperty], + }), + idProperty: args.idProperty, + ...columnState, + }); +}; diff --git a/client/src/app/hooks/table-controls/useTableControlProps.ts b/client/src/app/hooks/table-controls/useTableControlProps.ts new file mode 100644 index 00000000..ccd74c65 --- /dev/null +++ b/client/src/app/hooks/table-controls/useTableControlProps.ts @@ -0,0 +1,199 @@ +import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; + +import { objectKeys } from "@app/utils/utils"; +import { ITableControls, IUseTableControlPropsArgs } from "./types"; +import { useFilterPropHelpers } from "./filtering"; +import { useSortPropHelpers } from "./sorting"; +import { usePaginationPropHelpers } from "./pagination"; +import { useActiveItemPropHelpers } from "./active-item"; +import { useExpansionPropHelpers } from "./expansion"; +import { handlePropagatedRowClick } from "./utils"; + +/** + * Returns derived state and prop helpers for all features. Used to make rendering the table components easier. + * - Takes "source of truth" state and table-level derived state (derived either on the server or in getLocalTableControlDerivedState) + * along with API data and additional args. + * - Also triggers side-effects for some features to prevent invalid state. + * - If you aren't using server-side filtering/sorting/pagination, call this via the shorthand hook useLocalTableControls. + * - If you are using server-side filtering/sorting/pagination, call this last after calling useTableControlState and fetching your API data. + * @see useLocalTableControls + * @see useTableControlState + * @see getLocalTableControlDerivedState + */ +export const useTableControlProps = < + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, +>( + args: IUseTableControlPropsArgs< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix + > +): ITableControls< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix +> => { + type PropHelpers = ITableControls< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix + >["propHelpers"]; + + // Note: To avoid repetition, not all args are destructured here since the entire + // args object is passed to other other helpers which require other parts of it. + // For future additions, inspect `args` to see if it has anything more you need. + const { + currentPageItems, + forceNumRenderedColumns, + selectionState: { + selectAll, + areAllSelected, + selectedItems, + selectMultiple, + toggleItemSelected, + isItemSelected, + }, + columnNames, + hasActionsColumn = false, + variant, + isFilterEnabled, + isSortEnabled, + isSelectionEnabled, + isExpansionEnabled, + isActiveItemEnabled, + columnState: { columns }, + } = args; + + const columnKeys = objectKeys(columnNames); + + // Some table controls rely on extra columns inserted before or after the ones included in columnNames. + // We need to account for those when dealing with props based on column index and colSpan. + let numColumnsBeforeData = 0; + let numColumnsAfterData = 0; + if (isSelectionEnabled) numColumnsBeforeData++; + if (isExpansionEnabled && args.expandableVariant === "single") + numColumnsBeforeData++; + if (hasActionsColumn) numColumnsAfterData++; + const numRenderedColumns = + forceNumRenderedColumns || + columnKeys.length + numColumnsBeforeData + numColumnsAfterData; + + const { filterPropsForToolbar, propsForFilterToolbar } = + useFilterPropHelpers(args); + const { getSortThProps } = useSortPropHelpers({ ...args, columnKeys }); + const { paginationProps, paginationToolbarItemProps } = + usePaginationPropHelpers(args); + const { + expansionDerivedState, + getSingleExpandButtonTdProps, + getCompoundExpandTdProps, + getExpandedContentTdProps, + } = useExpansionPropHelpers({ ...args, columnKeys, numRenderedColumns }); + const { activeItemDerivedState, getActiveItemTrProps } = + useActiveItemPropHelpers(args); + + const toolbarProps: PropHelpers["toolbarProps"] = { + className: variant === "compact" ? spacing.pt_0 : "", + ...(isFilterEnabled && filterPropsForToolbar), + }; + + // TODO move this to a useSelectionPropHelpers when we move selection from lib-ui + const toolbarBulkSelectorProps: PropHelpers["toolbarBulkSelectorProps"] = { + onSelectAll: selectAll, + areAllSelected, + selectedRows: selectedItems, + paginationProps, + currentPageItems, + onSelectMultiple: selectMultiple, + }; + + const tableProps: PropHelpers["tableProps"] = { + variant, + isExpandable: isExpansionEnabled && !!args.expandableVariant, + }; + + const getThProps: PropHelpers["getThProps"] = ({ columnKey }) => ({ + ...(isSortEnabled && + getSortThProps({ columnKey: columnKey as TSortableColumnKey })), + children: columnNames[columnKey], + }); + + const getTrProps: PropHelpers["getTrProps"] = ({ item, onRowClick }) => { + const activeItemTrProps = getActiveItemTrProps({ item }); + return { + ...(isActiveItemEnabled && activeItemTrProps), + onRowClick: (event) => + handlePropagatedRowClick(event, () => { + activeItemTrProps.onRowClick?.(event); + onRowClick?.(event); + }), + }; + }; + + const getTdProps: PropHelpers["getTdProps"] = (getTdPropsArgs) => { + const { columnKey } = getTdPropsArgs; + return { + dataLabel: columnNames[columnKey], + ...(isExpansionEnabled && + args.expandableVariant === "compound" && + getTdPropsArgs.isCompoundExpandToggle && + getCompoundExpandTdProps({ + columnKey, + item: getTdPropsArgs.item, + rowIndex: getTdPropsArgs.rowIndex, + })), + }; + }; + + // TODO move this into a useSelectionPropHelpers and make it part of getTdProps once we move selection from lib-ui + const getSelectCheckboxTdProps: PropHelpers["getSelectCheckboxTdProps"] = ({ + item, + rowIndex, + }) => ({ + select: { + rowIndex, + onSelect: (_event, isSelecting) => { + toggleItemSelected(item, isSelecting); + }, + isSelected: isItemSelected(item), + }, + }); + + const getColumnVisibility = (columnKey: TColumnKey) => { + return columns.find((column) => column.id === columnKey)?.isVisible ?? true; + }; + + return { + ...args, + numColumnsBeforeData, + numColumnsAfterData, + numRenderedColumns, + expansionDerivedState, + activeItemDerivedState, + propHelpers: { + toolbarProps, + tableProps, + getThProps, + getTrProps, + getTdProps, + filterToolbarProps: propsForFilterToolbar, + paginationProps, + paginationToolbarItemProps, + toolbarBulkSelectorProps, + getSelectCheckboxTdProps, + getSingleExpandButtonTdProps, + getExpandedContentTdProps, + getColumnVisibility, + }, + }; +}; diff --git a/client/src/app/hooks/table-controls/useTableControlState.ts b/client/src/app/hooks/table-controls/useTableControlState.ts new file mode 100644 index 00000000..980e35ac --- /dev/null +++ b/client/src/app/hooks/table-controls/useTableControlState.ts @@ -0,0 +1,92 @@ +import { + ITableControlState, + IUseTableControlStateArgs, + PersistTarget, + TableFeature, +} from "./types"; +import { useFilterState } from "./filtering"; +import { useSortState } from "./sorting"; +import { usePaginationState } from "./pagination"; +import { useActiveItemState } from "./active-item"; +import { useExpansionState } from "./expansion"; +import { useColumnState } from "./column/useColumnState"; + +/** + * Provides the "source of truth" state for all table features. + * - State can be persisted in one or more configurable storage targets, either the same for the entire table or different targets per feature. + * - "source of truth" (persisted) state and "derived state" are kept separate to prevent out-of-sync duplicated state. + * - If you aren't using server-side filtering/sorting/pagination, call this via the shorthand hook useLocalTableControls. + * - If you are using server-side filtering/sorting/pagination, call this first before fetching your API data and then calling useTableControlProps. + * @param args + * @returns + */ +export const useTableControlState = < + TItem, + TColumnKey extends string, + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, +>( + args: IUseTableControlStateArgs< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix + > +): ITableControlState< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix +> => { + const getPersistTo = (feature: TableFeature): PersistTarget | undefined => + !args.persistTo || typeof args.persistTo === "string" + ? args.persistTo + : args.persistTo[feature] || args.persistTo.default; + + const filterState = useFilterState< + TItem, + TFilterCategoryKey, + TPersistenceKeyPrefix + >({ ...args, persistTo: getPersistTo("filter") }); + const sortState = useSortState({ + ...args, + persistTo: getPersistTo("sort"), + }); + const paginationState = usePaginationState({ + ...args, + persistTo: getPersistTo("pagination"), + }); + const expansionState = useExpansionState({ + ...args, + persistTo: getPersistTo("expansion"), + }); + const activeItemState = useActiveItemState({ + ...args, + persistTo: getPersistTo("activeItem"), + }); + + const { columnNames, tableName } = args; + + const initialColumns = Object.entries(columnNames).map(([id, label]) => ({ + id: id as TColumnKey, + label: label as string, + isVisible: true, + })); + + const columnState = useColumnState({ + columnsKey: tableName, + initialColumns, + }); + return { + ...args, + filterState, + sortState, + paginationState, + expansionState, + activeItemState, + columnState, + }; +}; diff --git a/client/src/app/hooks/table-controls/utils.ts b/client/src/app/hooks/table-controls/utils.ts new file mode 100644 index 00000000..3f1da599 --- /dev/null +++ b/client/src/app/hooks/table-controls/utils.ts @@ -0,0 +1,36 @@ +import React from "react"; + +/** + * Works around problems caused by event propagation when handling a clickable element that contains other clickable elements. + * - Used internally by useTableControlProps for the active item feature, but is generic and could be used outside tables. + * - When a click event happens within a row, checks if there is a clickable element in between the target node and the row element. + * (For example: checkboxes, buttons or links). + * - Prevents triggering the row click behavior when inner clickable elements or their children are clicked. + */ +export const handlePropagatedRowClick = < + E extends React.KeyboardEvent | React.MouseEvent, +>( + event: E | undefined, + onRowClick: (event: E) => void +) => { + // This recursive parent check is necessary because the event target could be, + // for example, the SVG icon inside a button rather than the button itself. + const isClickableElementInTheWay = (element: Element): boolean => { + if (["input", "button", "a"].includes(element.tagName.toLowerCase())) { + return true; + } + if ( + !element.parentElement || + element.parentElement?.tagName.toLowerCase() === "tr" + ) { + return false; + } + return isClickableElementInTheWay(element.parentElement); + }; + if ( + event?.target instanceof Element && + !isClickableElementInTheWay(event.target) + ) { + onRowClick(event); + } +}; diff --git a/client/src/app/hooks/usePersistentState.ts b/client/src/app/hooks/usePersistentState.ts new file mode 100644 index 00000000..8e718a00 --- /dev/null +++ b/client/src/app/hooks/usePersistentState.ts @@ -0,0 +1,98 @@ +import React from "react"; +import { IUseUrlParamsArgs, useUrlParams } from "./useUrlParams"; +import { + UseStorageTypeOptions, + useLocalStorage, + useSessionStorage, +} from "./useStorage"; +import { DisallowCharacters } from "@app/utils/type-utils"; + +type PersistToStateOptions = { persistTo?: "state" }; + +type PersistToUrlParamsOptions< + TValue, + TPersistenceKeyPrefix extends string, + TURLParamKey extends string, +> = { + persistTo: "urlParams"; +} & IUseUrlParamsArgs; + +type PersistToStorageOptions = { + persistTo: "localStorage" | "sessionStorage"; +} & UseStorageTypeOptions; + +export type UsePersistentStateOptions< + TValue, + TPersistenceKeyPrefix extends string, + TURLParamKey extends string, +> = { + defaultValue: TValue; + isEnabled?: boolean; + persistenceKeyPrefix?: DisallowCharacters; +} & ( + | PersistToStateOptions + | PersistToUrlParamsOptions + | PersistToStorageOptions +); + +export const usePersistentState = < + TValue, + TPersistenceKeyPrefix extends string, + TURLParamKey extends string, +>( + options: UsePersistentStateOptions< + TValue, + TPersistenceKeyPrefix, + TURLParamKey + > +): [TValue, (value: TValue) => void] => { + const { + defaultValue, + persistTo, + persistenceKeyPrefix, + isEnabled = true, + } = options; + + const isUrlParamsOptions = ( + o: typeof options + ): o is PersistToUrlParamsOptions< + TValue, + TPersistenceKeyPrefix, + TURLParamKey + > => o.persistTo === "urlParams"; + + const isStorageOptions = ( + o: typeof options + ): o is PersistToStorageOptions => + o.persistTo === "localStorage" || o.persistTo === "sessionStorage"; + + const prefixKey = (key: string) => + persistenceKeyPrefix ? `${persistenceKeyPrefix}:${key}` : key; + + const persistence = { + state: React.useState(defaultValue), + urlParams: useUrlParams( + isUrlParamsOptions(options) + ? options + : { + ...options, + isEnabled: false, + keys: [], + serialize: () => ({}), + deserialize: () => defaultValue, + } + ), + localStorage: useLocalStorage( + isStorageOptions(options) + ? { ...options, key: prefixKey(options.key) } + : { ...options, isEnabled: false, key: "" } + ), + sessionStorage: useSessionStorage( + isStorageOptions(options) + ? { ...options, key: prefixKey(options.key) } + : { ...options, isEnabled: false, key: "" } + ), + }; + const [value, setValue] = persistence[persistTo || "state"]; + return isEnabled ? [value, setValue] : [defaultValue, () => {}]; +}; diff --git a/client/src/app/hooks/useUrlParams.ts b/client/src/app/hooks/useUrlParams.ts new file mode 100644 index 00000000..1a24518b --- /dev/null +++ b/client/src/app/hooks/useUrlParams.ts @@ -0,0 +1,151 @@ +import { DisallowCharacters } from "@app/utils/type-utils"; +import { objectKeys } from "@app/utils/utils"; +import React from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +// useUrlParams is a generic hook similar to React.useState which stores its state in the URL search params string. +// The state is retained on a page reload, when using the browser back/forward buttons, or when bookmarking the page. +// It can be used to store a value of any type (`TDeserializedParams`) in one or more URL params by providing: +// - A list of `keys` you want to use for the URL params (strings with any characters except colon ":") +// - A `serialize` function to convert this type into an object with string values (`TSerializedParams`) +// - A `deserialize` function to convert the serialized object back to your state's type +// - An optional `keyPrefix` to allow for multiple instances using the same keys on the same page. +// The return value is the same [value, setValue] tuple returned by React.useState. + +// Note: You do not need to worry about the keyPrefix in your serialize and deserialize functions. +// The keys of TDeserializedParams and TSerializedParams have the prefixes omitted. +// Prefixes are only used at the very first/last step when reading/writing from/to the URLSearchParams object. + +export type TSerializedParams = Partial< + Record +>; + +export interface IUseUrlParamsArgs< + TDeserializedParams, + TPersistenceKeyPrefix extends string, + TURLParamKey extends string, +> { + isEnabled?: boolean; + persistenceKeyPrefix?: DisallowCharacters; + keys: DisallowCharacters[]; + defaultValue: TDeserializedParams; + serialize: ( + params: Partial + ) => TSerializedParams; + deserialize: ( + serializedParams: TSerializedParams + ) => TDeserializedParams; +} + +export type TURLParamStateTuple = [ + TDeserializedParams, + (newParams: Partial) => void, +]; + +export const useUrlParams = < + TDeserializedParams, + TKeyPrefix extends string, + TURLParamKey extends string, +>({ + isEnabled = true, + persistenceKeyPrefix, + keys, + defaultValue, + serialize, + deserialize, +}: IUseUrlParamsArgs< + TDeserializedParams, + TKeyPrefix, + TURLParamKey +>): TURLParamStateTuple => { + type TPrefixedURLParamKey = TURLParamKey | `${TKeyPrefix}:${TURLParamKey}`; + + const navigate = useNavigate(); + + const withPrefix = (key: TURLParamKey): TPrefixedURLParamKey => + persistenceKeyPrefix ? `${persistenceKeyPrefix}:${key}` : key; + + const withPrefixes = ( + serializedParams: TSerializedParams + ): TSerializedParams => + persistenceKeyPrefix + ? objectKeys(serializedParams).reduce( + (obj, key) => ({ + ...obj, + [withPrefix(key)]: serializedParams[key], + }), + {} as TSerializedParams + ) + : (serializedParams as TSerializedParams); + + const setParams = (newParams: Partial) => { + // In case setParams is called multiple times synchronously from the same rendered instance, + // we use document.location here as the current params so these calls never overwrite each other. + // This also retains any unrelated params that might be present and allows newParams to be a partial update. + const { pathname, search } = document.location; + const existingSearchParams = new URLSearchParams(search); + // We prefix the params object here so the serialize function doesn't have to care about the keyPrefix. + const newPrefixedSerializedParams = withPrefixes(serialize(newParams)); + navigate({ + pathname, + search: trimAndStringifyUrlParams({ + existingSearchParams, + newPrefixedSerializedParams, + }), + }); + }; + + // We use useLocation here so we are re-rendering when the params change. + const urlParams = new URLSearchParams(useLocation().search); + // We un-prefix the params object here so the deserialize function doesn't have to care about the keyPrefix. + + let allParamsEmpty = true; + let params: TDeserializedParams = defaultValue; + if (isEnabled) { + const serializedParams = keys.reduce( + (obj, key) => ({ + ...obj, + [key]: urlParams.get(withPrefix(key)), + }), + {} as TSerializedParams + ); + allParamsEmpty = keys.every((key) => !serializedParams[key]); + params = allParamsEmpty ? defaultValue : deserialize(serializedParams); + } + + React.useEffect(() => { + if (allParamsEmpty) setParams(defaultValue); + // Leaving this rule enabled results in a cascade of unnecessary useCallbacks: + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allParamsEmpty]); + + return [params, setParams]; +}; + +export const trimAndStringifyUrlParams = ({ + existingSearchParams = new URLSearchParams(), + newPrefixedSerializedParams, +}: { + existingSearchParams?: URLSearchParams; + newPrefixedSerializedParams: TSerializedParams; +}) => { + const existingPrefixedSerializedParams = + Object.fromEntries(existingSearchParams); + objectKeys(newPrefixedSerializedParams).forEach((key) => { + // Returning undefined for a property from serialize should result in it being omitted from the partial update. + if (newPrefixedSerializedParams[key] === undefined) { + delete newPrefixedSerializedParams[key]; + } + // Returning null for a property from serialize should result in it being removed from the URL. + if (newPrefixedSerializedParams[key] === null) { + delete newPrefixedSerializedParams[key]; + delete existingPrefixedSerializedParams[key]; + } + }); + const newParams = new URLSearchParams({ + ...existingPrefixedSerializedParams, + ...newPrefixedSerializedParams, + }); + newParams.sort(); + return newParams.toString(); +}; diff --git a/client/src/app/pages/advisory-details/cves.tsx b/client/src/app/pages/advisory-details/cves.tsx index eb55ace1..997f9ed5 100644 --- a/client/src/app/pages/advisory-details/cves.tsx +++ b/client/src/app/pages/advisory-details/cves.tsx @@ -1,33 +1,42 @@ -import { ToolbarContent } from "@patternfly/react-core"; +import React from "react"; +import { NavLink } from "react-router-dom"; + +import dayjs from "dayjs"; + +import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core"; import { ExpandableRowContent, Td as PFTd, Tr as PFTr, + Table, + Tbody, + Td, + Th, + Thead, + Tr, } from "@patternfly/react-table"; -import React from "react"; -import { NavLink } from "react-router-dom"; - -import dayjs from "dayjs"; import { RENDER_DATE_FORMAT } from "@app/Constants"; import { CVEBase } from "@app/api/models"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; +import { SimplePagination } from "@app/components/SimplePagination"; import { ConditionalTableBody, - FilterType, - useClientTableBatteries, -} from "@carlosthe19916-latest/react-table-batteries"; + TableHeaderContentWithControls, +} from "@app/components/TableControls"; +import { useLocalTableControls } from "@app/hooks/table-controls"; interface CVEsProps { cves: CVEBase[]; } export const CVEs: React.FC = ({ cves }) => { - const tableControls = useClientTableBatteries({ + const tableControls = useLocalTableControls({ + tableName: "cves-table", idProperty: "id", items: cves, - isLoading: false, columnNames: { cve: "CVE ID", title: "Title", @@ -37,76 +46,71 @@ export const CVEs: React.FC = ({ cves }) => { cwe: "CWE", }, hasActionsColumn: true, - filter: { - isEnabled: true, - filterCategories: [ - { - key: "cve", - title: "ID", - type: FilterType.search, - placeholderText: "Search by ID...", - getItemValue: (item) => item.id || "", - }, - ], - }, - sort: { - isEnabled: true, - sortableColumns: ["cve", "discovery", "release"], - getSortValues: (vuln) => ({ - cve: vuln?.id || "", - discovery: vuln ? dayjs(vuln.date_discovered).millisecond() : 0, - release: vuln ? dayjs(vuln.date_released).millisecond() : 0, - }), - }, - pagination: { isEnabled: true }, - expansion: { - isEnabled: false, - variant: "single", - }, + isSortEnabled: true, + sortableColumns: ["cve", "discovery", "release"], + getSortValues: (vuln) => ({ + cve: vuln?.id || "", + discovery: vuln ? dayjs(vuln.date_discovered).millisecond() : 0, + release: vuln ? dayjs(vuln.date_released).millisecond() : 0, + }), + isPaginationEnabled: true, + initialItemsPerPage: 10, + isExpansionEnabled: true, + expandableVariant: "single", + isFilterEnabled: true, + filterCategories: [ + { + categoryKey: "cve", + title: "ID", + type: FilterType.search, + placeholderText: "Search by ID...", + getItemValue: (item) => item.id || "", + }, + ], }); const { currentPageItems, numRenderedColumns, - components: { - Table, - Thead, - Tr, - Th, - Tbody, - Td, - Toolbar, - FilterToolbar, - PaginationToolbarItem, - Pagination, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, }, - expansion: { isCellExpanded }, + expansionDerivedState: { isCellExpanded }, } = tableControls; return ( <> - + - - - + + - + - +
- - + + = ({ cves }) => { {currentPageItems?.map((item, rowIndex) => { return ( - - + - - - - - @@ -152,10 +160,11 @@ export const CVEs: React.FC = ({ cves }) => { })}
- - - - - +
+ + + + + +
+
{item.id} + {item.title} + {dayjs(item.date_discovered).format(RENDER_DATE_FORMAT)} + {dayjs(item.date_released).format(RENDER_DATE_FORMAT)} + + {item.cwe}
- ); diff --git a/client/src/app/pages/advisory-list/advisory-list.tsx b/client/src/app/pages/advisory-list/advisory-list.tsx index 3a0b8f7e..c52738a8 100644 --- a/client/src/app/pages/advisory-list/advisory-list.tsx +++ b/client/src/app/pages/advisory-list/advisory-list.tsx @@ -1,21 +1,102 @@ import React from "react"; +import { NavLink, useNavigate } from "react-router-dom"; import { + Button, PageSection, PageSectionVariants, Text, TextContent, + Toolbar, ToolbarContent, + ToolbarItem, } from "@patternfly/react-core"; +import DownloadIcon from "@patternfly/react-icons/dist/esm/icons/download-icon"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; -import { useAdvisoryList } from "./useAdvisoryList"; +import { TablePersistenceKeyPrefixes } from "@app/Constants"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; +import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; +import { SimplePagination } from "@app/components/SimplePagination"; +import { + ConditionalTableBody, + TableHeaderContentWithControls, +} from "@app/components/TableControls"; +import { + getHubRequestParams, + useTableControlProps, + useTableControlState, +} from "@app/hooks/table-controls"; +import { useDownload } from "@app/hooks/useDownload"; +import { useSelectionState } from "@app/hooks/useSelectionState"; +import { useFetchAdvisories } from "@app/queries/advisories"; + +import { CVEGalleryCount } from "./components/CVEsGaleryCount"; export const AdvisoryList: React.FC = () => { - const { tableProps, table } = useAdvisoryList(); + const tableControlState = useTableControlState({ + tableName: "advisories", + persistenceKeyPrefix: TablePersistenceKeyPrefixes.advisories, + columnNames: { + id: "ID", + title: "Title", + severity: "Aggregated severity", + revisionDate: "Revision", + cves: "CVEs", + download: "Download", + }, + isSortEnabled: true, + sortableColumns: ["id"], + initialItemsPerPage: 10, + isFilterEnabled: true, + filterCategories: [ + { + categoryKey: "filterText", + title: "Filter text", + placeholderText: "Search", + type: FilterType.search, + }, + ], + }); const { - components: { Toolbar, FilterToolbar, PaginationToolbarItem, Pagination }, - } = tableProps; + result: { data: advisories, total: totalItemCount }, + isFetching, + fetchError, + } = useFetchAdvisories( + getHubRequestParams({ + ...tableControlState, + }) + ); + + const tableControls = useTableControlProps({ + ...tableControlState, + idProperty: "id", + currentPageItems: advisories, + totalItemCount, + isLoading: isFetching, + selectionState: useSelectionState({ + items: advisories, + isEqual: (a, b) => a.id === b.id, + }), + }); + + const { + numRenderedColumns, + currentPageItems, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, + }, + } = tableControls; + + const { downloadAdvisory } = useDownload(); return ( <> @@ -30,22 +111,106 @@ export const AdvisoryList: React.FC = () => { backgroundColor: "var(--pf-v5-global--BackgroundColor--100)", }} > - + - - - + + - + - {table} + + + + + + + + + {currentPageItems.map((item) => { + return ( + + + + + + + + + + + ); + })} + +
+ + + + + + +
+ + {item.id} + + + {item.metadata.title} + + + + + + + + +
+ diff --git a/client/src/app/pages/advisory-list/useAdvisoryList.tsx b/client/src/app/pages/advisory-list/useAdvisoryList.tsx deleted file mode 100644 index d86a8563..00000000 --- a/client/src/app/pages/advisory-list/useAdvisoryList.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { Button } from "@patternfly/react-core"; -import { - ExpandableRowContent, - Td as PFTd, - Tr as PFTr, -} from "@patternfly/react-table"; -import React from "react"; -import { NavLink } from "react-router-dom"; - -import dayjs from "dayjs"; - -import { - ConditionalTableBody, - FilterType, - useTablePropHelpers, - useTableState, -} from "@carlosthe19916-latest/react-table-batteries"; -import DownloadIcon from "@patternfly/react-icons/dist/esm/icons/download-icon"; - -import { useDownload } from "@app/hooks/useDownload"; -import { getHubRequestParams } from "@app/hooks/table-controls"; - -import { - RENDER_DATE_FORMAT, - TablePersistenceKeyPrefixes, -} from "@app/Constants"; -import { useFetchAdvisories } from "@app/queries/advisories"; -import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; -import { CVEGalleryCount } from "./components/CVEsGaleryCount"; - -export const useAdvisoryList = () => { - const tableState = useTableState({ - persistenceKeyPrefix: TablePersistenceKeyPrefixes.advisories, - columnNames: { - id: "ID", - title: "Title", - severity: "Aggregated severity", - revisionDate: "Revision", - cves: "CVEs", - download: "Download", - }, - filter: { - isEnabled: true, - filterCategories: [ - { - key: "filterText", - title: "Filter text", - placeholderText: "Search", - type: FilterType.search, - }, - { - key: "severity", - title: "Severity", - placeholderText: "Severity", - type: FilterType.multiselect, - selectOptions: [ - { key: "low", value: "Low" }, - { key: "moderate", value: "Moderate" }, - { key: "important", value: "Important" }, - { key: "critical", value: "Critical" }, - ], - }, - ], - }, - sort: { - isEnabled: true, - sortableColumns: ["severity"], - }, - pagination: { isEnabled: true }, - expansion: { - isEnabled: false, - variant: "single", - }, - }); - - const { filter, cacheKey } = tableState; - const hubRequestParams = React.useMemo(() => { - return getHubRequestParams({ - ...tableState, - filterCategories: filter.filterCategories, - hubSortFieldKeys: { - severity: "severity", - }, - }); - }, [cacheKey]); - - const { isFetching, fetchError, result } = - useFetchAdvisories(hubRequestParams); - - const tableProps = useTablePropHelpers({ - ...tableState, - idProperty: "id", - isLoading: isFetching, - currentPageItems: result.data, - totalItemCount: result.total, - }); - - const { - currentPageItems, - numRenderedColumns, - components: { Table, Thead, Tr, Th, Tbody, Td, Pagination }, - expansion: { isCellExpanded }, - } = tableProps; - - const { downloadAdvisory } = useDownload(); - - const table = ( - <> - - - - - - - {currentPageItems?.map((item, rowIndex) => { - return ( - - - - - - - - - - {isCellExpanded(item) ? ( - - - - {/* */} - - - - ) : null} - - ); - })} - -
- - - - - -
- {item.id} - - {item.metadata.title} - - - - {dayjs(item.revision_date).format(RENDER_DATE_FORMAT)} - - - - -
- - - ); - - return { - tableProps, - isFetching, - fetchError, - total: result.total, - table, - }; -}; diff --git a/client/src/app/pages/cve-details/related-advisories.tsx b/client/src/app/pages/cve-details/related-advisories.tsx index 19275f8f..be01162d 100644 --- a/client/src/app/pages/cve-details/related-advisories.tsx +++ b/client/src/app/pages/cve-details/related-advisories.tsx @@ -1,17 +1,20 @@ import React from "react"; import { NavLink } from "react-router-dom"; -import { ToolbarContent } from "@patternfly/react-core"; -import { - ConditionalTableBody, - useClientTableBatteries, -} from "@carlosthe19916-latest/react-table-batteries"; - import dayjs from "dayjs"; import { RENDER_DATE_FORMAT } from "@app/Constants"; import { AdvisoryBase } from "@app/api/models"; +import { FilterToolbar } from "@app/components/FilterToolbar"; import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; +import { SimplePagination } from "@app/components/SimplePagination"; +import { + ConditionalTableBody, + TableHeaderContentWithControls, +} from "@app/components/TableControls"; +import { useLocalTableControls } from "@app/hooks/table-controls"; +import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; interface RelatedAdvisoriesProps { advisories: AdvisoryBase[]; @@ -20,10 +23,10 @@ interface RelatedAdvisoriesProps { export const RelatedAdvisories: React.FC = ({ advisories, }) => { - const tableControls = useClientTableBatteries({ - persistTo: "sessionStorage", + const tableControls = useLocalTableControls({ + tableName: "advisories-table", idProperty: "id", - items: advisories || [], + items: advisories, isLoading: false, columnNames: { id: "ID", @@ -32,89 +35,87 @@ export const RelatedAdvisories: React.FC = ({ revision: "Revision", }, hasActionsColumn: true, - filter: { - isEnabled: true, - filterCategories: [], - }, - sort: { - isEnabled: true, - sortableColumns: [], - }, - pagination: { isEnabled: true }, - expansion: { - isEnabled: false, - variant: "single", - persistTo: "state", - }, + isSortEnabled: false, + isPaginationEnabled: true, + initialItemsPerPage: 10, + isFilterEnabled: false, }); const { currentPageItems, numRenderedColumns, - components: { - Table, - Thead, - Tr, - Th, - Tbody, - Td, - Toolbar, - FilterToolbar, - PaginationToolbarItem, - Pagination, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, }, + expansionDerivedState: { isCellExpanded }, } = tableControls; return ( <> - - - - - - - - - - + {tableControls.isFilterEnabled && ( + + + + + + + + + )} +
- - + + {currentPageItems?.map((item, rowIndex) => { return ( - - + - - @@ -123,10 +124,11 @@ export const RelatedAdvisories: React.FC = ({
- - - +
+ + + +
+
{item.id} + {item.metadata.title} + {dayjs(item.revision_date).format(RENDER_DATE_FORMAT)}
- ); diff --git a/client/src/app/pages/cve-details/related-sboms.tsx b/client/src/app/pages/cve-details/related-sboms.tsx index bad83bd6..b4eadebb 100644 --- a/client/src/app/pages/cve-details/related-sboms.tsx +++ b/client/src/app/pages/cve-details/related-sboms.tsx @@ -1,30 +1,39 @@ -import dayjs from "dayjs"; import React from "react"; import { NavLink } from "react-router-dom"; -import { ToolbarContent } from "@patternfly/react-core"; +import dayjs from "dayjs"; + import { ExpandableRowContent, - IExtraData, - IRowData, Td as PFTd, Tr as PFTr, + Table, + Tbody, + Td, + Th, + Thead, + Tr, } from "@patternfly/react-table"; import { RENDER_DATE_FORMAT } from "@app/Constants"; import { SBOMBase } from "@app/api/models"; +import { SimplePagination } from "@app/components/SimplePagination"; import { ConditionalTableBody, - useClientTableBatteries, -} from "@carlosthe19916-latest/react-table-batteries"; + TableHeaderContentWithControls, +} from "@app/components/TableControls"; +import { useLocalTableControls } from "@app/hooks/table-controls"; +import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core"; +import { FilterToolbar } from "@app/components/FilterToolbar"; interface RelatedSBOMsProps { sboms: SBOMBase[]; } export const RelatedSBOMs: React.FC = ({ sboms }) => { - const tableControls = useClientTableBatteries({ + const tableControls = useLocalTableControls({ + tableName: "sboms-table", idProperty: "id", items: sboms, isLoading: false, @@ -37,114 +46,111 @@ export const RelatedSBOMs: React.FC = ({ sboms }) => { createdOn: "Created on", }, hasActionsColumn: true, - filter: { - isEnabled: true, - filterCategories: [], - }, - sort: { - isEnabled: true, - sortableColumns: [], - }, - pagination: { isEnabled: true }, - expansion: { - isEnabled: true, - variant: "compound", - }, + isSortEnabled: false, + isPaginationEnabled: true, + initialItemsPerPage: 10, + isExpansionEnabled: true, + expandableVariant: "compound", + isFilterEnabled: false, }); const { currentPageItems, numRenderedColumns, - components: { - Table, - Thead, - Tr, - Th, - Tbody, - Td, - Toolbar, - FilterToolbar, - PaginationToolbarItem, - Pagination, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, }, - expansion: { isCellExpanded, setCellExpanded }, + expansionDerivedState: { isCellExpanded, setCellExpanded }, } = tableControls; return ( <> - - - - - - - - + {tableControls.isFilterEnabled && ( + + + + + + + + + )} - +
- - + + {currentPageItems?.map((item, rowIndex) => { return ( - - + - - - - @@ -163,10 +169,11 @@ export const RelatedSBOMs: React.FC = ({ sboms }) => { })}
- - - - - +
+ + + + + +
+
{item?.name} + {item?.version} + {item?.supplier} + {dayjs(item.created_on).format(RENDER_DATE_FORMAT)} + TODO: extract status { - setCellExpanded({ - item, - isExpanding: !isOpen, - columnKey: "packages", - }); - }, - }} + {...getTdProps({ + columnKey: "packages", + isCompoundExpandToggle: true, + item: item, + rowIndex, + })} > TODO: # of pkg affected
- ); diff --git a/client/src/app/pages/cve-list/cve-list.tsx b/client/src/app/pages/cve-list/cve-list.tsx index 28f9fb97..8c8dae2a 100644 --- a/client/src/app/pages/cve-list/cve-list.tsx +++ b/client/src/app/pages/cve-list/cve-list.tsx @@ -1,21 +1,114 @@ import React from "react"; +import { NavLink } from "react-router-dom"; + +import dayjs from "dayjs"; import { PageSection, PageSectionVariants, Text, TextContent, + Toolbar, ToolbarContent, + ToolbarItem, } from "@patternfly/react-core"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; -import { useCveList } from "./useCveList"; +import { + RENDER_DATE_FORMAT, + TablePersistenceKeyPrefixes, +} from "@app/Constants"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; +import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; +import { SimplePagination } from "@app/components/SimplePagination"; +import { + ConditionalTableBody, + TableHeaderContentWithControls, +} from "@app/components/TableControls"; +import { + getHubRequestParams, + useTableControlProps, + useTableControlState, +} from "@app/hooks/table-controls"; +import { useDownload } from "@app/hooks/useDownload"; +import { useSelectionState } from "@app/hooks/useSelectionState"; +import { useFetchCVEs } from "@app/queries/cves"; export const CveList: React.FC = () => { - const { tableProps, table } = useCveList(); + const tableControlState = useTableControlState({ + tableName: "cves", + persistenceKeyPrefix: TablePersistenceKeyPrefixes.cves, + columnNames: { + id: "ID", + description: "Description", + severity: "Severity", + datePublished: "Date published", + relatedSBOMs: "Related SBOMs", + }, + isSortEnabled: true, + sortableColumns: ["severity", "datePublished"], + initialItemsPerPage: 10, + isFilterEnabled: true, + filterCategories: [ + { + categoryKey: "filterText", + title: "Filter text", + placeholderText: "Search", + type: FilterType.search, + }, + { + categoryKey: "severity", + title: "Severity", + placeholderText: "Severity", + type: FilterType.multiselect, + selectOptions: [ + { label: "low", value: "Low" }, + { label: "moderate", value: "Moderate" }, + { label: "important", value: "Important" }, + { label: "critical", value: "Critical" }, + ], + }, + ], + }); + + const { + result: { data: advisories, total: totalItemCount }, + isFetching, + fetchError, + } = useFetchCVEs( + getHubRequestParams({ + ...tableControlState, + }) + ); + + const tableControls = useTableControlProps({ + ...tableControlState, + idProperty: "id", + currentPageItems: advisories, + totalItemCount, + isLoading: isFetching, + selectionState: useSelectionState({ + items: advisories, + isEqual: (a, b) => a.id === b.id, + }), + }); const { - components: { Toolbar, FilterToolbar, PaginationToolbarItem, Pagination }, - } = tableProps; + numRenderedColumns, + currentPageItems, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, + }, + } = tableControls; + + const { downloadCVE } = useDownload(); return ( <> @@ -30,22 +123,79 @@ export const CveList: React.FC = () => { backgroundColor: "var(--pf-v5-global--BackgroundColor--100)", }} > - + - - - + + - + - {table} + + + + + + + + + {currentPageItems.map((item) => { + return ( + + + + + + + + + + ); + })} + +
+ + + + + +
+ {item.id} + + {item.title} + + + + {dayjs(item.date_discovered).format(RENDER_DATE_FORMAT)} + + {item.related_sboms.length} +
+ diff --git a/client/src/app/pages/cve-list/useCveList.tsx b/client/src/app/pages/cve-list/useCveList.tsx deleted file mode 100644 index 5dceb88b..00000000 --- a/client/src/app/pages/cve-list/useCveList.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React from "react"; -import { NavLink } from "react-router-dom"; - -import { - ConditionalTableBody, - FilterType, - useTablePropHelpers, - useTableState, -} from "@carlosthe19916-latest/react-table-batteries"; - -import dayjs from "dayjs"; - -import { getHubRequestParams } from "@app/hooks/table-controls"; - -import { - RENDER_DATE_FORMAT, - TablePersistenceKeyPrefixes, -} from "@app/Constants"; - -import { useFetchCVEs } from "@app/queries/cves"; -import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; - -export const useCveList = () => { - const tableState = useTableState({ - persistTo: "state", - persistenceKeyPrefix: TablePersistenceKeyPrefixes.cves, - columnNames: { - id: "ID", - description: "Description", - severity: "Severity", - datePublished: "Date published", - relatedSBOMs: "Related SBOMs", - }, - filter: { - isEnabled: true, - filterCategories: [ - { - key: "filterText", - title: "Filter text", - placeholderText: "Search", - type: FilterType.search, - }, - { - key: "severity", - title: "Severity", - placeholderText: "Severity", - type: FilterType.multiselect, - selectOptions: [ - { key: "low", value: "Low" }, - { key: "moderate", value: "Moderate" }, - { key: "important", value: "Important" }, - { key: "critical", value: "Critical" }, - ], - }, - ], - }, - sort: { - isEnabled: true, - sortableColumns: ["severity", "datePublished"], - }, - pagination: { isEnabled: true }, - }); - - const { filter, cacheKey } = tableState; - const hubRequestParams = React.useMemo(() => { - return getHubRequestParams({ - ...tableState, - filterCategories: filter.filterCategories, - hubSortFieldKeys: { - severity: "severity", - datePublished: "datePublished", - }, - }); - }, [cacheKey]); - - const { isFetching, result, fetchError } = useFetchCVEs(hubRequestParams); - - const tableProps = useTablePropHelpers({ - ...tableState, - idProperty: "id", - isLoading: isFetching, - currentPageItems: result.data, - totalItemCount: result.total, - }); - - const { - currentPageItems, - numRenderedColumns, - components: { Table, Thead, Tr, Th, Tbody, Td, Pagination }, - } = tableProps; - - const table = ( - <> - - - - - - - - {currentPageItems?.map((item, rowIndex) => { - return ( - - - - - - - - ); - })} - - -
- - - - -
- {item.id} - - {item.title} - - - - {dayjs(item.date_discovered).format(RENDER_DATE_FORMAT)} - - {item.related_sboms.length} -
- - - ); - - return { - tableProps, - isFetching, - fetchError, - total: result.total, - table, - }; -}; diff --git a/client/src/app/pages/package-details/related-cves.tsx b/client/src/app/pages/package-details/related-cves.tsx index 47456da7..30071200 100644 --- a/client/src/app/pages/package-details/related-cves.tsx +++ b/client/src/app/pages/package-details/related-cves.tsx @@ -1,26 +1,29 @@ import React from "react"; import { NavLink } from "react-router-dom"; -import { ToolbarContent } from "@patternfly/react-core"; - import dayjs from "dayjs"; -import { - ConditionalTableBody, - FilterType, - useClientTableBatteries, -} from "@carlosthe19916-latest/react-table-batteries"; +import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; import { RENDER_DATE_FORMAT } from "@app/Constants"; import { CVEBase } from "@app/api/models"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; +import { SimplePagination } from "@app/components/SimplePagination"; +import { + ConditionalTableBody, + TableHeaderContentWithControls, +} from "@app/components/TableControls"; +import { useLocalTableControls } from "@app/hooks/table-controls"; interface RelatedCVEsProps { cves: CVEBase[]; } export const RelatedCVEs: React.FC = ({ cves }) => { - const tableControls = useClientTableBatteries({ + const tableControls = useLocalTableControls({ + tableName: "cves-table", idProperty: "id", items: cves, isLoading: false, @@ -31,85 +34,95 @@ export const RelatedCVEs: React.FC = ({ cves }) => { datePublished: "Date published", }, hasActionsColumn: true, - filter: { - isEnabled: true, - filterCategories: [ - { - key: "filterText", - title: "Filter text", - placeholderText: "Search", - type: FilterType.search, - getItemValue: (item) => item.id, - }, - ], - }, - sort: { - isEnabled: true, - sortableColumns: [], - }, - pagination: { isEnabled: true }, + isSortEnabled: false, + isPaginationEnabled: true, + initialItemsPerPage: 10, + isExpansionEnabled: false, + isFilterEnabled: true, + filterCategories: [ + { + categoryKey: "filterText", + title: "Filter text", + placeholderText: "Search", + type: FilterType.search, + getItemValue: (item) => item.id, + }, + ], }); const { currentPageItems, numRenderedColumns, - components: { - Table, - Thead, - Tr, - Th, - Tbody, - Td, - Toolbar, - FilterToolbar, - PaginationToolbarItem, - Pagination, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, }, } = tableControls; return ( <> - + - - - + + - + - +
- - + + {currentPageItems?.map((item, rowIndex) => { return ( - - + - - - @@ -118,10 +131,11 @@ export const RelatedCVEs: React.FC = ({ cves }) => { })}
- - - +
+ + + +
+
{item.id} + {item.description} + + {dayjs(item.date_discovered).format(RENDER_DATE_FORMAT)}
- ); diff --git a/client/src/app/pages/package-details/related-sboms.tsx b/client/src/app/pages/package-details/related-sboms.tsx index 3c8aec60..08ede2ad 100644 --- a/client/src/app/pages/package-details/related-sboms.tsx +++ b/client/src/app/pages/package-details/related-sboms.tsx @@ -1,22 +1,25 @@ import React from "react"; import { NavLink } from "react-router-dom"; -import { ToolbarContent } from "@patternfly/react-core"; +import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; +import { SBOMBase } from "@app/api/models"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; +import { SimplePagination } from "@app/components/SimplePagination"; import { ConditionalTableBody, - FilterType, - useClientTableBatteries, -} from "@carlosthe19916-latest/react-table-batteries"; - -import { SBOMBase } from "@app/api/models"; + TableHeaderContentWithControls, +} from "@app/components/TableControls"; +import { useLocalTableControls } from "@app/hooks/table-controls"; interface RelatedSBOMsProps { sboms: SBOMBase[]; } export const RelatedSBOMs: React.FC = ({ sboms }) => { - const tableControls = useClientTableBatteries({ + const tableControls = useLocalTableControls({ + tableName: "sboms-table", idProperty: "id", items: sboms, isLoading: false, @@ -27,92 +30,95 @@ export const RelatedSBOMs: React.FC = ({ sboms }) => { packageTree: "Package tree", }, hasActionsColumn: true, - filter: { - isEnabled: true, - filterCategories: [ - { - key: "filterText", - title: "Filter text", - placeholderText: "Search", - type: FilterType.search, - getItemValue: (item) => item.name, - }, - ], - }, - sort: { - isEnabled: true, - sortableColumns: [], - }, - pagination: { isEnabled: true }, - expansion: { - isEnabled: false, - variant: "single", - }, + isSortEnabled: false, + isPaginationEnabled: true, + initialItemsPerPage: 10, + isExpansionEnabled: false, + isFilterEnabled: true, + filterCategories: [ + { + categoryKey: "filterText", + title: "Filter text", + placeholderText: "Search", + type: FilterType.search, + getItemValue: (item) => item.name, + }, + ], }); const { currentPageItems, numRenderedColumns, - components: { - Table, - Thead, - Tr, - Th, - Tbody, - Td, - Toolbar, - FilterToolbar, - PaginationToolbarItem, - Pagination, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, }, } = tableControls; return ( <> - + - - - + + - + - +
- - + + {currentPageItems?.map((item, rowIndex) => { return ( - - + - - - @@ -121,10 +127,11 @@ export const RelatedSBOMs: React.FC = ({ sboms }) => { })}
- - - +
+ + + +
+
{item?.name} + {item?.version} + {item?.supplier} + TODO: Package Tree
- ); diff --git a/client/src/app/pages/package-list/package-list.tsx b/client/src/app/pages/package-list/package-list.tsx index 09ecb041..6c5ced03 100644 --- a/client/src/app/pages/package-list/package-list.tsx +++ b/client/src/app/pages/package-list/package-list.tsx @@ -1,21 +1,122 @@ import React from "react"; +import { NavLink } from "react-router-dom"; import { + Label, PageSection, PageSectionVariants, Text, TextContent, + Toolbar, ToolbarContent, + ToolbarItem, } from "@patternfly/react-core"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; -import { usePackageList } from "./usePackageList"; +import { TablePersistenceKeyPrefixes } from "@app/Constants"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; +import { SimplePagination } from "@app/components/SimplePagination"; +import { + ConditionalTableBody, + TableHeaderContentWithControls, +} from "@app/components/TableControls"; +import { + getHubRequestParams, + useTableControlProps, + useTableControlState, +} from "@app/hooks/table-controls"; +import { useSelectionState } from "@app/hooks/useSelectionState"; +import { useFetchPackages } from "@app/queries/packages"; + +import { CVEGalleryCount } from "../advisory-list/components/CVEsGaleryCount"; export const PackageList: React.FC = () => { - const { tableProps, table } = usePackageList(); + const tableControlState = useTableControlState({ + tableName: "packages", + persistenceKeyPrefix: TablePersistenceKeyPrefixes.packages, + columnNames: { + name: "Name", + namespace: "Namespace", + version: "Version", + type: "Type", + path: "Path", + qualifiers: "Qualifiers", + cve: "CVEs", + }, + isSortEnabled: true, + sortableColumns: [], + initialItemsPerPage: 10, + isFilterEnabled: true, + filterCategories: [ + { + categoryKey: "filterText", + title: "Filter text", + placeholderText: "Search", + type: FilterType.search, + }, + { + categoryKey: "type", + title: "Type", + placeholderText: "Type", + type: FilterType.multiselect, + selectOptions: [ + { label: "maven", value: "Maven" }, + { label: "rpm", value: "RPM" }, + { label: "npm", value: "NPM" }, + { label: "oci", value: "OCI" }, + ], + }, + { + categoryKey: "qualifier:arch", + title: "Architecture", + placeholderText: "Architecture", + type: FilterType.multiselect, + selectOptions: [ + { label: "x86_64", value: "AMD 64Bit" }, + { label: "aarch64", value: "ARM 64bit" }, + { label: "ppc64le", value: "PowerPC" }, + { label: "s390x", value: "S390" }, + ], + }, + ], + }); + + const { + result: { data: advisories, total: totalItemCount }, + isFetching, + fetchError, + } = useFetchPackages( + getHubRequestParams({ + ...tableControlState, + }) + ); + + const tableControls = useTableControlProps({ + ...tableControlState, + idProperty: "id", + currentPageItems: advisories, + totalItemCount, + isLoading: isFetching, + selectionState: useSelectionState({ + items: advisories, + isEqual: (a, b) => a.id === b.id, + }), + }); const { - components: { Toolbar, FilterToolbar, PaginationToolbarItem, Pagination }, - } = tableProps; + numRenderedColumns, + currentPageItems, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, + }, + } = tableControls; return ( <> @@ -30,22 +131,92 @@ export const PackageList: React.FC = () => { backgroundColor: "var(--pf-v5-global--BackgroundColor--100)", }} > - + - - - + + - + - {table} + + + + + + + + + {currentPageItems.map((item) => { + return ( + + + + + + + + + + + ); + })} + +
+ + + + + + + +
+ + {item.id} + + + {item.version} + + {item.type} + + {item.path} + + {Object.entries(item.qualifiers || {}).map( + ([k, v], index) => ( + + ) + )} + + +
+ diff --git a/client/src/app/pages/package-list/usePackageList.tsx b/client/src/app/pages/package-list/usePackageList.tsx deleted file mode 100644 index 858a11d0..00000000 --- a/client/src/app/pages/package-list/usePackageList.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import React from "react"; -import { NavLink } from "react-router-dom"; - -import { Label } from "@patternfly/react-core"; - -import { - ConditionalTableBody, - FilterType, - useTablePropHelpers, - useTableState, -} from "@carlosthe19916-latest/react-table-batteries"; - -import { getHubRequestParams } from "@app/hooks/table-controls"; - -import { TablePersistenceKeyPrefixes } from "@app/Constants"; -import { useFetchPackages } from "@app/queries/packages"; -import { CVEGalleryCount } from "../advisory-list/components/CVEsGaleryCount"; - -export const usePackageList = () => { - const tableState = useTableState({ - persistenceKeyPrefix: TablePersistenceKeyPrefixes.sboms, - columnNames: { - name: "Name", - namespace: "Namespace", - version: "Version", - type: "Type", - path: "Path", - qualifiers: "Qualifiers", - cve: "CVEs", - }, - filter: { - isEnabled: true, - filterCategories: [ - { - key: "filterText", - title: "Filter text", - placeholderText: "Search", - type: FilterType.search, - }, - { - key: "type", - title: "Type", - placeholderText: "Type", - type: FilterType.multiselect, - selectOptions: [ - { key: "maven", value: "Maven" }, - { key: "rpm", value: "RPM" }, - { key: "npm", value: "NPM" }, - { key: "oci", value: "OCI" }, - ], - }, - { - key: "qualifier:arch", - title: "Architecture", - placeholderText: "Architecture", - type: FilterType.multiselect, - selectOptions: [ - { key: "x86_64", value: "AMD 64Bit" }, - { key: "aarch64", value: "ARM 64bit" }, - { key: "ppc64le", value: "PowerPC" }, - { key: "s390x", value: "S390" }, - ], - }, - ], - }, - sort: { - isEnabled: true, - sortableColumns: [], - }, - pagination: { isEnabled: true }, - }); - - const { filter, cacheKey } = tableState; - const hubRequestParams = React.useMemo(() => { - return getHubRequestParams({ - ...tableState, - filterCategories: filter.filterCategories, - hubSortFieldKeys: { - created: "created", - }, - }); - }, [cacheKey]); - - const { isFetching, result, fetchError } = useFetchPackages(hubRequestParams); - - const tableProps = useTablePropHelpers({ - ...tableState, - idProperty: "id", - isLoading: isFetching, - currentPageItems: result.data, - totalItemCount: result.total, - }); - - const { - currentPageItems, - numRenderedColumns, - components: { Table, Thead, Tr, Th, Tbody, Td, Pagination }, - } = tableProps; - - const table = ( - <> - - - - - - - - {currentPageItems?.map((item, rowIndex) => { - return ( - - - - - - - - - - ); - })} - - -
- - - - - - -
- - {item.id} - - - {item.namespace} - - {item.version} - - {item.type} - - {item.path} - - {Object.entries(item.qualifiers || {}).map( - ([k, v], index) => ( - - ) - )} - - -
- - - ); - - return { - tableProps, - isFetching, - fetchError, - total: result.total, - table, - }; -}; diff --git a/client/src/app/pages/sbom-details/cves.tsx b/client/src/app/pages/sbom-details/cves.tsx index 9ff19475..4c4658e9 100644 --- a/client/src/app/pages/sbom-details/cves.tsx +++ b/client/src/app/pages/sbom-details/cves.tsx @@ -1,21 +1,30 @@ import React from "react"; -import { ToolbarContent } from "@patternfly/react-core"; +import dayjs from "dayjs"; + +import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core"; import { ExpandableRowContent, Td as PFTd, Tr as PFTr, + Table, + Tbody, + Td, + Th, + Thead, + Tr, } from "@patternfly/react-table"; -import { useFetchCVEsBySbomId } from "@app/queries/sboms"; +import { RENDER_DATE_FORMAT } from "@app/Constants"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; +import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; +import { SimplePagination } from "@app/components/SimplePagination"; import { ConditionalTableBody, - FilterType, - useClientTableBatteries, -} from "@carlosthe19916-latest/react-table-batteries"; -import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; -import dayjs from "dayjs"; -import { RENDER_DATE_FORMAT } from "@app/Constants"; + TableHeaderContentWithControls, +} from "@app/components/TableControls"; +import { useLocalTableControls } from "@app/hooks/table-controls"; +import { useFetchCVEsBySbomId } from "@app/queries/sboms"; interface CVEsProps { sbomId: string; @@ -24,7 +33,8 @@ interface CVEsProps { export const CVEs: React.FC = ({ sbomId }) => { const { cves, isFetching, fetchError } = useFetchCVEsBySbomId(sbomId); - const tableControls = useClientTableBatteries({ + const tableControls = useLocalTableControls({ + tableName: "cves-table", idProperty: "id", items: cves, isLoading: isFetching, @@ -35,69 +45,64 @@ export const CVEs: React.FC = ({ sbomId }) => { datePublished: "Date published", packages: "Packages", }, - filter: { - isEnabled: true, - filterCategories: [ - { - key: "filterText", - title: "Filter tex", - type: FilterType.search, - placeholderText: "Search...", - getItemValue: (item) => item.id, - }, - ], - }, - sort: { - isEnabled: true, - sortableColumns: [], - }, - expansion: { - isEnabled: true, - variant: "single", - }, + isSortEnabled: false, + isPaginationEnabled: true, + initialItemsPerPage: 10, + isExpansionEnabled: true, + expandableVariant: "single", + isFilterEnabled: true, + filterCategories: [ + { + categoryKey: "filterText", + title: "Filter tex", + type: FilterType.search, + placeholderText: "Search...", + getItemValue: (item) => item.id, + }, + ], }); const { currentPageItems, numRenderedColumns, - components: { - Table, - Thead, - Tr, - Th, - Tbody, - Td, - Toolbar, - FilterToolbar, - PaginationToolbarItem, - Pagination, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, }, - expansion: { isCellExpanded }, + expansionDerivedState: { isCellExpanded }, } = tableControls; return ( <> - + - - - + + - + - +
- - + + = ({ sbomId }) => { {currentPageItems?.map((item, rowIndex) => { return ( - - + - - - - @@ -142,10 +167,11 @@ export const CVEs: React.FC = ({ sbomId }) => { })}
- - - - +
+ + + + +
+
{item.id} + {item.description} + + {dayjs(item.date_discovered).format(RENDER_DATE_FORMAT)} + TODO packages affected
- ); diff --git a/client/src/app/pages/sbom-details/packages.tsx b/client/src/app/pages/sbom-details/packages.tsx index b169bd60..59c591e7 100644 --- a/client/src/app/pages/sbom-details/packages.tsx +++ b/client/src/app/pages/sbom-details/packages.tsx @@ -1,18 +1,31 @@ import React from "react"; -import { Label, ToolbarContent } from "@patternfly/react-core"; +import { + Label, + Toolbar, + ToolbarContent, + ToolbarItem, +} from "@patternfly/react-core"; import { ExpandableRowContent, Td as PFTd, Tr as PFTr, + Table, + Tbody, + Td, + Th, + Thead, + Tr, } from "@patternfly/react-table"; -import { useFetchPackagesBySbomId } from "@app/queries/sboms"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; +import { SimplePagination } from "@app/components/SimplePagination"; import { ConditionalTableBody, - FilterType, - useClientTableBatteries, -} from "@carlosthe19916-latest/react-table-batteries"; + TableHeaderContentWithControls, +} from "@app/components/TableControls"; +import { useLocalTableControls } from "@app/hooks/table-controls"; +import { useFetchPackagesBySbomId } from "@app/queries/sboms"; interface PackagesProps { sbomId: string; @@ -21,7 +34,8 @@ interface PackagesProps { export const Packages: React.FC = ({ sbomId }) => { const { packages, isFetching, fetchError } = useFetchPackagesBySbomId(sbomId); - const tableControls = useClientTableBatteries({ + const tableControls = useLocalTableControls({ + tableName: "packages-table", idProperty: "id", items: packages, isLoading: isFetching, @@ -34,71 +48,65 @@ export const Packages: React.FC = ({ sbomId }) => { qualifiers: "Qualifiers", cves: "CVEs", }, - filter: { - isEnabled: true, - filterCategories: [ - { - key: "filterText", - title: "Filter tex", - type: FilterType.search, - placeholderText: "Search...", - getItemValue: (item) => item.name, - }, - ], - }, - sort: { - isEnabled: true, - sortableColumns: [], - }, - expansion: { - isEnabled: true, - variant: "single", - }, + isPaginationEnabled: true, + initialItemsPerPage: 10, + isExpansionEnabled: true, + expandableVariant: "single", + isFilterEnabled: true, + filterCategories: [ + { + categoryKey: "filterText", + title: "Filter tex", + type: FilterType.search, + placeholderText: "Search...", + getItemValue: (item) => item.name, + }, + ], }); const { currentPageItems, numRenderedColumns, - components: { - Table, - Thead, - Tr, - Th, - Tbody, - Td, - Toolbar, - FilterToolbar, - PaginationToolbarItem, - Pagination, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, }, - expansion: { isCellExpanded }, + expansionDerivedState: { isCellExpanded }, } = tableControls; return ( <> - + - - - + + - + - +
- - + + = ({ sbomId }) => { {currentPageItems?.map((item, rowIndex) => { return ( - - + - - - - - - @@ -154,10 +186,11 @@ export const Packages: React.FC = ({ sbomId }) => { })}
- - - - - - +
+ + + + + + +
+
{item.name} + {item.namespace} + {item.version} + {item.type} + {item.path} + {item.qualifiers && Object.entries(item.qualifiers || {}).map( ([k, v], index) => ( @@ -134,7 +162,11 @@ export const Packages: React.FC = ({ sbomId }) => { ) )} + TODO list of CVEs
- ); diff --git a/client/src/app/pages/sbom-list/sbom-list.tsx b/client/src/app/pages/sbom-list/sbom-list.tsx index 3928f9e0..ee34857b 100644 --- a/client/src/app/pages/sbom-list/sbom-list.tsx +++ b/client/src/app/pages/sbom-list/sbom-list.tsx @@ -1,21 +1,106 @@ import React from "react"; +import { NavLink } from "react-router-dom"; + +import dayjs from "dayjs"; import { + Button, PageSection, PageSectionVariants, Text, TextContent, + Toolbar, ToolbarContent, + ToolbarItem, } from "@patternfly/react-core"; +import DownloadIcon from "@patternfly/react-icons/dist/esm/icons/download-icon"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; -import { useSbomList } from "./useSbomList"; +import { + RENDER_DATE_FORMAT, + TablePersistenceKeyPrefixes, +} from "@app/Constants"; +import { CveGallery } from "@app/components/CveGallery"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; +import { SimplePagination } from "@app/components/SimplePagination"; +import { + ConditionalTableBody, + TableHeaderContentWithControls, +} from "@app/components/TableControls"; +import { + getHubRequestParams, + useTableControlProps, + useTableControlState, +} from "@app/hooks/table-controls"; +import { useDownload } from "@app/hooks/useDownload"; +import { useSelectionState } from "@app/hooks/useSelectionState"; +import { useFetchSBOMs } from "@app/queries/sboms"; export const SbomList: React.FC = () => { - const { tableProps, table } = useSbomList(); + const tableControlState = useTableControlState({ + tableName: "sboms", + persistenceKeyPrefix: TablePersistenceKeyPrefixes.sboms, + columnNames: { + name: "Name", + version: "Version", + supplier: "Supplier", + createdOn: "Created on", + packages: "Packages", + cves: "CVEs", + download: "Download", + }, + isSortEnabled: true, + sortableColumns: ["createdOn"], + initialItemsPerPage: 10, + isFilterEnabled: true, + filterCategories: [ + { + categoryKey: "filterText", + title: "Filter text", + placeholderText: "Search", + type: FilterType.search, + }, + ], + }); + + const { + result: { data: advisories, total: totalItemCount }, + isFetching, + fetchError, + } = useFetchSBOMs( + getHubRequestParams({ + ...tableControlState, + }) + ); + + const tableControls = useTableControlProps({ + ...tableControlState, + idProperty: "id", + currentPageItems: advisories, + totalItemCount, + isLoading: isFetching, + selectionState: useSelectionState({ + items: advisories, + isEqual: (a, b) => a.id === b.id, + }), + }); const { - components: { Toolbar, FilterToolbar, PaginationToolbarItem, Pagination }, - } = tableProps; + numRenderedColumns, + currentPageItems, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, + }, + } = tableControls; + + const { downloadSBOM } = useDownload(); return ( <> @@ -30,22 +115,91 @@ export const SbomList: React.FC = () => { backgroundColor: "var(--pf-v5-global--BackgroundColor--100)", }} > - + - - - + + - + - {table} + + + + + + + + + {currentPageItems.map((item) => { + return ( + + + + + + + + + + + + ); + })} + +
+ + + + + + +
+ {item.name} + + {item.version} + + {item.supplier} + + {dayjs(item.created_on).format(RENDER_DATE_FORMAT)} + + {item.related_packages.count} + + + + +
+ diff --git a/client/src/app/pages/sbom-list/useSbomList.tsx b/client/src/app/pages/sbom-list/useSbomList.tsx deleted file mode 100644 index be29a79e..00000000 --- a/client/src/app/pages/sbom-list/useSbomList.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import React from "react"; -import { NavLink } from "react-router-dom"; - -import dayjs from "dayjs"; - -import { - ConditionalTableBody, - FilterType, - useTablePropHelpers, - useTableState, -} from "@carlosthe19916-latest/react-table-batteries"; -import { Button } from "@patternfly/react-core"; -import DownloadIcon from "@patternfly/react-icons/dist/esm/icons/download-icon"; - -import { getHubRequestParams } from "@app/hooks/table-controls"; - -import { - RENDER_DATE_FORMAT, - TablePersistenceKeyPrefixes, -} from "@app/Constants"; -import { CveGallery } from "@app/components/CveGallery"; -import { useDownload } from "@app/hooks/useDownload"; -import { useFetchSBOMs } from "@app/queries/sboms"; - -export const useSbomList = () => { - const tableState = useTableState({ - persistenceKeyPrefix: TablePersistenceKeyPrefixes.packages, - columnNames: { - name: "Name", - version: "Version", - supplier: "Supplier", - createdOn: "Created on", - packages: "Packages", - cves: "CVEs", - download: "Download", - }, - filter: { - isEnabled: true, - filterCategories: [ - { - key: "filterText", - title: "Filter text", - placeholderText: "Search", - type: FilterType.search, - }, - ], - }, - sort: { - isEnabled: true, - sortableColumns: ["createdOn"], - }, - pagination: { isEnabled: true }, - }); - - const { filter, cacheKey } = tableState; - const hubRequestParams = React.useMemo(() => { - return getHubRequestParams({ - ...tableState, - filterCategories: filter.filterCategories, - hubSortFieldKeys: { - createdOn: "created", - }, - }); - }, [cacheKey]); - - const { isFetching, result, fetchError } = useFetchSBOMs(hubRequestParams); - - const tableProps = useTablePropHelpers({ - ...tableState, - idProperty: "id", - isLoading: isFetching, - currentPageItems: result.data, - totalItemCount: result.total, - }); - - const { - currentPageItems, - numRenderedColumns, - components: { Table, Thead, Tr, Th, Tbody, Td, Pagination }, - } = tableProps; - - const { downloadSBOM } = useDownload(); - - const table = ( - <> - - - - - - - - {currentPageItems?.map((item, rowIndex) => { - return ( - - - - - - - - - - ); - })} - - -
- - - - - - -
- {item.name} - - {item.version} - - {item.supplier} - - {dayjs(item.created_on).format(RENDER_DATE_FORMAT)} - - {item.related_packages.count} - - - - -
- - - ); - - return { - tableProps, - isFetching, - fetchError, - total: result.total, - table, - }; -}; diff --git a/client/src/app/queries/advisories.ts b/client/src/app/queries/advisories.ts index c0e28ccf..49e60a51 100644 --- a/client/src/app/queries/advisories.ts +++ b/client/src/app/queries/advisories.ts @@ -8,6 +8,13 @@ import { getAdvisorySourceById, } from "@app/api/rest"; +export interface IAdvisoriesQueryParams { + filterText?: string; + offset?: number; + limit?: number; + sort_by?: string; +} + export const AdvisoriesQueryKey = "advisories"; export const useFetchAdvisories = (params: HubRequestParams = {}) => { diff --git a/client/src/mocks/stub-new-work/advisories.ts b/client/src/mocks/stub-new-work/advisories.ts index 1d31ab61..43feeb48 100644 --- a/client/src/mocks/stub-new-work/advisories.ts +++ b/client/src/mocks/stub-new-work/advisories.ts @@ -54,7 +54,9 @@ export const mockAdvisoryArray: Advisory[] = [ export const handlers = [ rest.get(AppRest.ADVISORIES, (req, res, ctx) => { - return res(ctx.json(mockAdvisoryArray)); + return res( + ctx.json({ items: mockAdvisoryArray, total: mockAdvisoryArray.length }) + ); }), rest.get(`${AppRest.ADVISORIES}/:id`, (req, res, ctx) => { const { id } = req.params; diff --git a/client/src/mocks/stub-new-work/cves.ts b/client/src/mocks/stub-new-work/cves.ts index 752abf51..43562806 100644 --- a/client/src/mocks/stub-new-work/cves.ts +++ b/client/src/mocks/stub-new-work/cves.ts @@ -18,7 +18,7 @@ export const mockCVEArray: CVE[] = mockAdvisoryArray.flatMap(({ cves }) => { export const handlers = [ rest.get(AppRest.CVES, (req, res, ctx) => { - return res(ctx.json(mockCVEArray)); + return res(ctx.json({ items: mockCVEArray, total: mockCVEArray.length })); }), rest.get(`${AppRest.CVES}/:id`, (req, res, ctx) => { const { id } = req.params; diff --git a/client/src/mocks/stub-new-work/packages.ts b/client/src/mocks/stub-new-work/packages.ts index f9d87524..4815e324 100644 --- a/client/src/mocks/stub-new-work/packages.ts +++ b/client/src/mocks/stub-new-work/packages.ts @@ -5,7 +5,9 @@ import { mockPackageArray } from "./sboms"; export const handlers = [ rest.get(AppRest.PACKAGES, (req, res, ctx) => { - return res(ctx.json(mockPackageArray)); + return res( + ctx.json({ items: mockPackageArray, total: mockPackageArray.length }) + ); }), rest.get(`${AppRest.PACKAGES}/:id`, (req, res, ctx) => { const { id } = req.params; diff --git a/client/src/mocks/stub-new-work/sboms.ts b/client/src/mocks/stub-new-work/sboms.ts index 9f1d1d8a..bf212747 100644 --- a/client/src/mocks/stub-new-work/sboms.ts +++ b/client/src/mocks/stub-new-work/sboms.ts @@ -44,7 +44,7 @@ export const mockPackageArray: Package[] = [ export const handlers = [ rest.get(AppRest.SBOMS, (req, res, ctx) => { - return res(ctx.json(mockSBOMArray)); + return res(ctx.json({ items: mockSBOMArray, total: mockSBOMArray.length })); }), rest.get(`${AppRest.SBOMS}/:id`, (req, res, ctx) => { const { id } = req.params;