diff --git a/client/package.json b/client/package.json index 4872140f87..11e0475873 100644 --- a/client/package.json +++ b/client/package.json @@ -24,7 +24,7 @@ "@dnd-kit/sortable": "^7.0.2", "@hookform/resolvers": "^2.9.11", "@hot-loader/react-dom": "^17.0.2", - "@migtools/lib-ui": "^9.0.3", + "@migtools/lib-ui": "^10.0.1", "@patternfly/patternfly": "^5.0.2", "@patternfly/react-charts": "^7.1.0", "@patternfly/react-code-editor": "^5.1.0", diff --git a/client/src/app/Constants.ts b/client/src/app/Constants.ts index f672a0faaf..59d8974062 100644 --- a/client/src/app/Constants.ts +++ b/client/src/app/Constants.ts @@ -236,8 +236,9 @@ export enum LocalStorageKey { } // URL param prefixes: should be short, must be unique for each table that uses one -export enum TableURLParamKeyPrefix { +export enum TablePersistenceKeyPrefix { issues = "i", + dependencies = "d", issuesAffectedApps = "ia", issuesAffectedFiles = "if", issuesRemainingIncidents = "ii", diff --git a/client/src/app/components/TableControls/TableRowContentWithControls.tsx b/client/src/app/components/TableControls/TableRowContentWithControls.tsx index f34f132efd..9c1ad5fc9b 100644 --- a/client/src/app/components/TableControls/TableRowContentWithControls.tsx +++ b/client/src/app/components/TableControls/TableRowContentWithControls.tsx @@ -1,16 +1,23 @@ import React from "react"; import { Td } from "@patternfly/react-table"; -import { useTableControlProps } from "@app/hooks/table-controls"; +import { ITableControls } from "@app/hooks/table-controls"; export interface ITableRowContentWithControlsProps< TItem, TColumnKey extends string, - TSortableColumnKey extends TColumnKey + TSortableColumnKey extends TColumnKey, + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, > { - expandableVariant?: "single" | "compound" | null; - isSelectable?: boolean; - propHelpers: ReturnType< - typeof useTableControlProps + isExpansionEnabled?: boolean; + expandableVariant?: "single" | "compound"; + isSelectionEnabled?: boolean; + propHelpers: ITableControls< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix >["propHelpers"]; item: TItem; rowIndex: number; @@ -20,11 +27,12 @@ export interface ITableRowContentWithControlsProps< export const TableRowContentWithControls = < TItem, TColumnKey extends string, - TSortableColumnKey extends TColumnKey + TSortableColumnKey extends TColumnKey, >({ - expandableVariant = null, - isSelectable = false, - propHelpers: { getSingleExpandTdProps, getSelectCheckboxTdProps }, + isExpansionEnabled = false, + expandableVariant, + isSelectionEnabled = false, + propHelpers: { getSingleExpandButtonTdProps, getSelectCheckboxTdProps }, item, rowIndex, children, @@ -32,10 +40,10 @@ export const TableRowContentWithControls = < ITableRowContentWithControlsProps >) => ( <> - {expandableVariant === "single" ? ( - + {isExpansionEnabled && expandableVariant === "single" ? ( + ) : null} - {isSelectable ? ( + {isSelectionEnabled ? ( ) : null} {children} diff --git a/client/src/app/components/answer-table/answer-table.tsx b/client/src/app/components/answer-table/answer-table.tsx index c5d5c76485..0ca99f0382 100644 --- a/client/src/app/components/answer-table/answer-table.tsx +++ b/client/src/app/components/answer-table/answer-table.tsx @@ -37,14 +37,12 @@ const AnswerTable: React.FC = ({ choice: "Answer choice", weight: "Weight", }, - hasActionsColumn: false, - hasPagination: false, variant: "compact", }); const { currentPageItems, numRenderedColumns, - propHelpers: { tableProps, getThProps, getTdProps }, + propHelpers: { tableProps, getThProps, getTrProps, getTdProps }, } = tableControls; const getIconByRisk = (risk: string): React.ReactElement => { @@ -118,7 +116,7 @@ const AnswerTable: React.FC = ({ {currentPageItems?.map((answer, rowIndex) => { return ( <> - + + @@ -98,7 +100,7 @@ const QuestionsTable: React.FC<{ )?.name || ""; return ( <> - + thing.name || "", + }, + ], + // Because isSortEnabled, TypeScript will require these sort-related properties: + sortableColumns: ["name", "description"], + getSortValues: (thing) => ({ + name: thing.name || "", + description: thing.description || "", + }), + initialSort: { columnKey: "name", direction: "asc" }, + isLoading, +}); + +// Here we destructure some of the properties from `tableControls` for rendering. +// Later we also spread the entire `tableControls` object onto components whose props include subsets of it. +const { + currentPageItems, // These items have already been paginated. + // `numRenderedColumns` is based on the number of columnNames and additional columns needed for + // rendering controls related to features like selection, expansion, etc. + // It is used as the colSpan when rendering a full-table-wide cell. + numRenderedColumns, + // The objects and functions in `propHelpers` correspond to the props needed for specific PatternFly or Tackle + // components and are provided to reduce prop-drilling and make the rendering code as short as possible. + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, + }, +} = tableControls; + +return ( + <> + + + + {/* You can render whatever other custom toolbar items you may need here! */} + + + + + +
+ + + + + + + + + No things available + + + } + numRenderedColumns={numRenderedColumns} + > + + {currentPageItems?.map((thing, rowIndex) => ( + + + + + + + ))} + + +
+ + +
+ {thing.name} + + {thing.description} +
+ + <> +); +``` + +### Example table with server-side filtering/sorting/pagination (and state persistence) + +The usage is similar here, but some client-specific arguments are no longer required (like `getSortValues` and the `getItemValue` property of the filter category) and we break up the arguments object passed to `useLocalTableControls` into two separate objects passed to `useTableControlState` and `useTableControlProps` based on when they are needed. Note that the object passed to the latter contains all the properties of the object returned by the former in addition to things derived from the fetched API data. All of the arguments passed to both `useTableControlState` and `useTableControlProps` as well as the return values from both are included in the `tableControls` object returned by `useTableControlProps` (and by `useLocalTableControls` above). This way, we have one big object we can pass around to any components or functions that need any of the configuration, state, derived state, or props present on it, and we can destructure/reference them from a central place no matter where they came from. + +Note also: the destructuring of `tableControls` and returned JSX is not included in this example code because **_it is identical to the example above_**. The only differences between client-paginated and server-paginated tables are in the hook calls; the `tableControls` object and its usage are the same for all tables. + +This example also shows a powerful optional capability of these hooks: the `persistTo` argument. This can be passed to either `useTableControlState` or `useLocalTableControls` and it allows us to store the current pagination/sort/filter state in a custom location and use that as the source of truth. The supported `persistTo` values are `"state"` (default), `"urlParams"` (recommended), `"localStorage"` or `"sessionStorage"`. For more on each persistence target see [Features](#features). + +Here we use `persistTo: "urlParams"` which will use URL query parameters as the source of truth for the state of all this table's features. We also pass an optional `persistenceKeyPrefix` which distinguishes this persisted state from any other state that may be persisted in the URL by other tables on the same page (it can be omitted if there is only one table on the page). It should be a short string because it is included as a prefix on every URL param name. We'll use `"t"` for the table containing our Thing objects. + +Because our state is persisted in the page URL, we can reload the browser or press the Back and Forward buttons without losing our current filter, sort, and pagination selections. We can even bookmark the page and all that state will be restored when loading the bookmark! + +```tsx +const tableControlState = useTableControlState({ + persistTo: "urlParams", + persistenceKeyPrefix: "t", + columnNames: { + name: "Name", + description: "Description", + }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + filterCategories: [ + { + key: "name", + title: "Name", + type: FilterType.search, + placeholderText: "Filter by name...", + }, + ], + sortableColumns: ["name", "description"], + initialSort: { columnKey: "name", direction: "asc" }, +}); + +const hubRequestParams = getHubRequestParams({ + ...tableControlState, // Includes filterState, sortState and paginationState + hubSortFieldKeys: { + // The keys required for sorting on the server, in case they aren't the same as our columns here + name: "name", + description: "description", + }, +}); + +// `useFetchThings` is an example of a custom hook that calls a react-query `useQuery` +// and the `serializeRequestParamsForHub` helper. +// Any API fetch implementation could be used here as long as it will re-fetch when `hubRequestParams` changes. +// The `data` returned here has been paginated, filtered and sorted on the server. +const { data, totalItemCount, isLoading, isError } = + useFetchThings(hubRequestParams); + +const tableControls = useTableControlProps({ + ...tableControlState, // Includes filterState, sortState and paginationState + idProperty: "id", + currentPageItems: data, + totalItemCount, + isLoading, +}); + +// Everything else (destructuring `tableControls` and returning JSX) is the same as the client-side example! +``` + +### Kitchen sink example with per-feature state persistence and all features enabled + +Here's an example of another server-computed table with all of the table-controls features enabled (see [Features](#features)). Note that if you wanted to make this example client-computed, you would pass all the new feature-specific properties seen here to `useLocalTableControls` instead of `useTableControlState`. + +New features added here in addition to filtering, sorting and pagination are: + +- Expansion - Each row has an expand toggle button to the left of its data (automatically injected by the `TableRowContentWithControls` component), which opens additional detail below the row. (this is the "single" expand variant, compound expansion is also supported). The `expandableVariant` option is required because `isExpansionEnabled` is true. + - This makes the `getExpandedContentTdProps` propHelper and the `expansionDerivedState` object available on the `tableControls` object. + - Each row is now contained in a `` component which pairs the existing `` with another `` containing that row's ``. +- Active item - Rows have hover styles and are clickable (handled automatically by `getTrProps`). Clicking a row marks that row's item as "active", which can be used to open a drawer or whatever else is needed on the page. This is enabled by `isActiveItemEnabled`, which does not require any additional options. + - This makes the `activeItemDerivedState` object available on the `tableControls` object. + +> ⚠️ TECH DEBT NOTE: The selection feature is currently not enabled in this example because it is about to significantly change with a refactor. Currently to use selection you have to use the outdated `useSelectionState` from lib-ui and pass its return values to `useTableControlProps`. Once selection is moved into table-controls, it will be configurable alongside the other features in `useTableControlState` and added to this example. + +> ⚠️ TECH DEBT NOTE: We should also add a compound-expand example, but that can maybe wait for the proper extension-seed docs in table-batteries after the code is moved there. + +Here we'll also show an alternate way of using `persistTo`: separate persistence targets per feature. Let's say that for this table, we want the user's filters to persist in `localStorage` where they will be restored no matter what the user does, but we want the sort, pagination and other state to reset when we leave the page. We can do this by passing an object to `persistTo` instead of a string. We specify the default persistence target as React state with `default: "state"`, and override it for the filters with `filter: "localStorage"`. + +```tsx +const tableControlState = useTableControlState({ + persistTo: { + default: "state", + filter: "localStorage", + }, + persistenceKeyPrefix: "t", + columnNames: { + name: "Name", + description: "Description", + }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + isExpansionEnabled: true, + isActiveItemEnabled: true, + filterCategories: [ + { + key: "name", + title: "Name", + type: FilterType.search, + placeholderText: "Filter by name...", + }, + ], + sortableColumns: ["name", "description"], + initialSort: { columnKey: "name", direction: "asc" }, + expandableVariant: "single", +}); + +const hubRequestParams = getHubRequestParams({ + ...tableControlState, + hubSortFieldKeys: { + name: "name", + description: "description", + }, +}); + +const { data, totalItemCount, isLoading, isError } = + useFetchThings(hubRequestParams); + +const tableControls = useTableControlProps({ + ...tableControlState, + idProperty: "id", + currentPageItems: data, + totalItemCount, + isLoading, +}); + +const { + currentPageItems, + numRenderedColumns, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, + getExpandedContentTdProps, + }, + activeItemDerivedState: { activeItem, clearActiveItem }, + expansionDerivedState: { isCellExpanded }, +} = tableControls; + +return ( + <> + + + + + + + + + + + + + + + + + + No things available + + + } + numRenderedColumns={numRenderedColumns} + > + + {currentPageItems?.map((thing, rowIndex) => ( + + + + + + + + {isCellExpanded(thing) && ( + + + + )} + + ))} + + +
+ + +
+ {thing.name} + + {thing.description} +
+ + + Some extra detail about thing {thing.name} goes here! + +
+ + {/* Stub example of something custom you might render based on the `activeItem`. Source not included. */} + + <> +); +``` + +### Should I Use Client or Server Logic? + +If the API endpoints you're using support server-side pagination parameters, it is generally a good idea to use them for better performance and scalability. If you do use server-side pagination, you'll need to also use server-side filtering and sorting. + +If the endpoints do not support these parameters or you need to have the entire collection of items in memory at once for some other reason, you'll need a client-paginated table. It is also slightly easier to implement a client-paginated table. + +### Which Hooks/Functions Do I Need? + +In most cases, you'll only need to use these higher-level hooks and helpers to build a table: + +- For client-paginated tables: `useLocalTableControls` is all you need. + - Internally it uses `useTableControlState`, `useTableControlProps` and the `getLocalTableControlDerivedState` helper. The config arguments object is a combination of the arguments required by `useTableControlState` and `useTableControlProps`. + - The return value (an object we generally name `tableControls`) has everything you need to render your table. Give it a `console.log` to see what is available. +- For server-paginated tables: `useTableControlState`, `getHubRequestParams`, and `useTableControlProps`. + - Choose whether you want to use React state, URL params or localStorage/sessionStorage as the source of truth, and call `useTableControlState` with the appropriate `persistTo` option and optional `persistenceKeyPrefix` (to namespace persisted state for multiple tables on the same page). + - `persistTo` can be `"state" | "urlParams" | "localStorage" | "sessionStorage"`, and defaults to `"state"` if omitted (falls back to regular React state). + - You can also use a different type of storage for the state of each feature by passing an object for `persistTo`. See the [Kitchen sink example](#kitchen-sink-example-with-per-feature-state-persistence-and-all-features-enabled). + - Take the object returned by that hook (generally named `tableControlState`) and pass it to the `getHubRequestParams` function (you may need to spread it and add additional properties like `hubSortFieldKeys`). (⚠️ TECH DEBT NOTE: This is Konveyor-specific) + - Call your API query hooks, using the `hubRequestParams` as needed. + - Call `useTableControlProps` and pass it an object spreading all properties from `tableControlState` along with additional config arguments. Some of these arguments will be derived from your API data, such as `currentPageItems`, `totalItemCount` and `isLoading`. Others are simply passed here rather than above because they are used only for rendering and not required for state management. + - The return value (the same `tableControls` object returned by `useLocalTableControls`) has everything you need to render your table. Give it a `console.log` to see what is available. + +If desired, you can use the lower-level feature-specific hooks (see [Features](#features)) on their own (for example, if you really only need pagination and you're not rendering a full table). However, if you are using more than one or two of them you may want to consider using these higher-level hooks even if you don't need all the features. You can omit the config arguments for any features you don't need and then just don't use the relevant `propHelpers`. + +## Features + +The functionality and state of the table-controls hooks is broken down into the following features. Each of these features represents a slice of the logical concerns for a table UI. + +Note that the filtering, sorting and pagination features are special because they must be performed in a specific order to work correctly: filter and sort data, then paginate it. Using the higher-level hooks like `useLocalTableControls` or `useTableControlState` + `useTableControlProps` will take care of this for you (see [Usage](#usage)), but if you are handling filtering/sorting/pagination yourself with the lower-level hooks you'll need to be mindful of this order. + +The state used by each feature (provided by `use[Feature]State` hooks) can be stored either in React state (default), in the browser's URL query parameters (recommended), or in the browser's `localStorage` or `sessionStorage`. If URL params are used, the user's current filters, sort, pagination state, expanded/active rows and more are preserved when reloading the browser, using the browser Back and Forward buttons, or loading a bookmark. The storage target for each feature is specified with the `persistTo` property. The supported `persistTo` values are: + +- `"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. + +All of the hooks and helpers described in this section are used internally by the higher-level hooks and helpers, and do not need to be used directly (see [Usage](#usage)). + +### Filtering + +Items are filtered according to user-selected filter key/value pairs. + +- Keys and filter types (search, select, etc) are defined by the `filterCategories` array config argument. The `key` properties of each of these `FilterCategory` objects are the source of truth for the inferred generic type `TFilterCategoryKeys` (For more, see the JSDoc comments in the `types.ts` file). +- Filter state is provided by `useFilterState`. +- For client-side filtering, the filter logic is provided by `getLocalFilterDerivedState` (based on the `getItemValue` callback defined on each `FilterCategory` object, which is not required when using server-side filtering). +- For server-side filtering, filter state is serialized for the API by `getFilterHubRequestParams`. +- Filter-related component props are provided by `useFilterPropHelpers`. +- Filter inputs and chips are rendered by the `FilterToolbar` component. + +> ⚠️ TECH DEBT NOTE: The `FilterToolbar` component and `FilterCategory` type predate the table-controls pattern (they are tackle2-ui legacy code) and are not located in this directory. The abstraction there may be a little too opaque and it does not take full advantage of TypeScript generics. We may want to adjust that code to better fit these patterns and move it here. + +### Sorting + +Items are sorted according to the user-selected sort column and direction. + +- Sortable columns are defined by a `sortableColumns` array of `TColumnKey` values (see [Unique Identifiers](#unique-identifiers)). +- Sort state is provided by `useSortState`. +- For client-side sorting, the sort logic is provided by `getLocalSortDerivedState` (based on the `getSortValues` config argument, which is not required when using server-side sorting). +- For server-side sorting, sort state is serialized for the API by `getSortHubRequestParams`. +- Sort-related component props are provided by `useSortPropHelpers`. +- Sort inputs are rendered by the table's `Th` PatternFly component. + +### Pagination + +Items are paginated according to the user-selected page number and items-per-page count. + +- The only config argument for pagination is the optional `initialItemsPerPage` which defaults to 10. +- Pagination state is provided by `usePaginationState`. +- For client-side pagination, the pagination logic is provided by `getLocalPaginationDerivedState`. +- For server-side pagination, pagination state is serialized for the API by `getPaginationHubRequestParams`. +- Pagination-related component props are provided by `usePaginationPropHelpers`. +- A `useEffect` call which prevents invalid state after an item is deleted is provided by `usePaginationEffects`. This is called internally by `usePaginationPropHelpers`. +- Pagination inputs are rendered by our `SimplePagination` component which is a thin wrapper around the PatternFly `Pagination` component. + +> ⚠️ TECH DEBT NOTE: The `SimplePagination` component also predates the table-controls pattern (legacy tackle2-ui code). We probably don't even need it and should remove it. + +> ⚠️ TECH DEBT NOTE: Is usePaginationPropHelpers the best place to call usePaginationEffects? Should we make the consumer call it explicitly? + +### Expansion + +Item details can be expanded, either with a "single expansion" variant where an entire row is expanded to show more detail or a "compound expansion" variant where an individual cell in a row (one at a time per row) is expanded. This is tracked in state by a mapping of item ids (derived from the `idProperty` config argument) to either a boolean value (for single expansion) or a `columnKey` value (for compound expansion). See [Unique Identifiers](#unique-identifiers) for more on `idProperty` and `columnKey`. + +- Single or compound expansion is defined by the optional `expandableVariant` config argument which defaults to `"single"`. +- Expansion state is provided by `useExpansionState`. +- Expansion shorthand functions are provided by `getExpansionDerivedState`. +- Expansion is never managed server-side. +- Expansion-related component props are provided by `useExpansionPropHelpers`. +- Expansion inputs are rendered by the table's `Td` PatternFly component and expanded content is managed at the consumer level by conditionally rendering a second row with full colSpan inside a PatternFly `Tbody` component. The `numRenderedColumns` value returned by `useTableControlProps` can be used for the correct colSpan here. + +### Active Item + +A row can be clicked to mark its item as "active", which usually opens a drawer on the page to show more detail. Note that this is distinct from expansion (toggle arrows) and selection (checkboxes) and these features can all be used together. Active item state is simply a single id value (number or string) for the active item, derived from the `idProperty` config argument (see [Unique Identifiers](#unique-identifiers)). + +- The active item feature requires no config arguments. +- Active item state is provided by `useActiveItemState`. +- Active item shorthand functions are provided by `getActiveItemDerivedState`. +- Active-item-related component props are provided by `useActiveItemPropHelpers`. +- A `useEffect` call which prevents invalid state after an item is deleted is provided by `useActiveItemEffects`. This is called internally in `useActiveItemPropHelpers`. + +> ⚠️ TECH DEBT NOTE: Is useActiveItemPropHelpers the best place to call useActiveItemEffects? Should we make the consumer call it explicitly? + +### Selection + +Items can be selected with checkboxes on each row or with a bulk select control that provides actions like "select all", "select none" and "select page". The list of selected item ids in state can be used to perform bulk actions like Delete. + +> ⚠️ TECH DEBT NOTE: Currently, selection state has not yet been refactored to be a part of the table-controls pattern and we are still relying on [the old `useSelectionState` from lib-ui](https://migtools.github.io/lib-ui/?path=/docs/hooks-useselectionstate--checkboxes) which dates back to older migtools projects. The return value of this legacy `useSelectionState` is required by `useTableControlProps`. Mike is working on a refactor to bring selection state hooks into this directory. + +## Important Data Structure Notes + +### Item Objects, Not Row Objects + +None of the code here treats "rows" as their own data structure. The content and style of a row is a presentational detail that should be limited to the JSX where rows are rendered. In implementations which use arrays of row objects (like the deprecated PatternFly table) those objects tend to duplicate API data with a different structure and the code must reason about two different representations of the data. Instead, this code works directly with arrays of "items" (the API data objects themselves) and makes all of an item's API object properties available where they might be needed without extra lookups. The consumer maps over item objects and derives row components from them only at render time. + +An item object has the generic type `TItem`, which is inferred by TypeScript either from the type of the `items` array passed into `useLocalTableControls` (for client-paginated tables) or from the `currentPageItems` array passed into `useTableControlProps` (for server-paginated tables). For more, see the JSDoc comments in the `types.ts` file. + +> ℹ️ CAVEAT: For server-paginated tables the item data is not in scope until after the API query hook is called, but the `useTableControlState` hook must be called _before_ API queries because its return values are needed to serialize filter/sort/pagination params for the API. This means the inferred `TItem` type is not available when passing arguments to `useTableControlState`. `TItem` resolves to `unknown` in this scope, which is usually fine since the arguments there don't need to know what type of items they are working with. If the item type is needed for any of these arguments it can be explicitly passed as a type param. However... +> +> ⚠️ TECH DEBT NOTE: TypeScript generic type param lists (example: `fn(args);`) are all-or-nothing (you must either omit the list and infer all generics for a function or pass them all explicitly). This means if you need to pass an explicit type for `TItem`, all other type params which are normally inferred must also be explicitly passed (including all of the `TColumnKey`s and `TFilterCategoryKey`s). This makes for some redundant code, although TypeScript will still enforce that it is all consistent. There is a possible upcoming TypeScript language feature which would allow partial inference in type param lists and may alleviate this in the future. See TypeScript pull requests [#26349](https://github.com/microsoft/TypeScript/pull/26349) and [#54047](https://github.com/microsoft/TypeScript/pull/54047), and our issue [#1456](https://github.com/konveyor/tackle2-ui/issues/1456). + +### Unique Identifiers + +#### Column keys + +Table columns are identified by unique keys which are statically inferred from the keys of the `columnNames` object (used in many places via the inferred generic type `TColumnKey`. See the JSDoc comments in the `types.ts` file). Any state which keeps track of something by column (such as which columns are sorted and which columns are expanded in a compound-expandable row) uses these column keys as identifiers, and the user-facing column names can be looked up from the `columnNames` object anywhere a `columnKey` is present. Valid column keys are enforced via TypeScript generics; if a `columnKey` value is used that is not present in `columnNames`, you should get a type error. + +#### Item IDs + +Item objects must contain some unique identifier which is either a string or number. The property key of this identifier is a required config argument called `idProperty`, which will usually be `"id"`. If no unique identifier is present in the API data, an artificial one can be injected before passing the data into these hooks, which can be done in the useQuery `select` callback (see instances where we have used `"_ui_unique_id"`). Any state which keeps track of something by item (i.e. by row) makes use of `item[idProperty]` as an identifier. Examples of this include selected rows, expanded rows and active rows. Valid `idProperty` values are also enforced by TypeScript generics; if an `idProperty` is provided that is not a property on the `TItem` type, you should get a type error. + +> ⚠️ TECH DEBT NOTE: Things specific to `useQuery` and `_ui_unique_id` here are Konveyor-specific notes that should be removed after moving this to table-batteries. + +--- + +## Future Features and Improvements + +- Tech debt notes above should be addressed. +- We should add full API docs for all the hooks, helpers and components generated from the JSDoc comments. +- It would be nice to support inline editable rows with a clean abstraction that fits into this pattern. 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 0000000000..25747ca141 --- /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 0000000000..afa4d418ff --- /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 0000000000..bbaa2ca8e0 --- /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 0000000000..ad7038d936 --- /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 0000000000..f0e3f3626f --- /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/active-row/getActiveRowDerivedState.ts b/client/src/app/hooks/table-controls/active-row/getActiveRowDerivedState.ts deleted file mode 100644 index 8757c95ae5..0000000000 --- a/client/src/app/hooks/table-controls/active-row/getActiveRowDerivedState.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { KeyWithValueType } from "@app/utils/type-utils"; -import { IActiveRowState } from "./useActiveRowState"; - -export interface IActiveRowDerivedStateArgs { - currentPageItems: TItem[]; - idProperty: KeyWithValueType; - activeRowState: IActiveRowState; -} - -// Note: This is not named `getLocalActiveRowDerivedState` because it is always local, -// and it is still used when working with server-managed tables. -export const getActiveRowDerivedState = ({ - currentPageItems, - idProperty, - activeRowState: { activeRowId, setActiveRowId }, -}: IActiveRowDerivedStateArgs) => ({ - activeRowItem: - currentPageItems.find((item) => item[idProperty] === activeRowId) || null, - setActiveRowItem: (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]? - setActiveRowId(itemId); - }, - clearActiveRow: () => setActiveRowId(null), -}); diff --git a/client/src/app/hooks/table-controls/active-row/index.ts b/client/src/app/hooks/table-controls/active-row/index.ts deleted file mode 100644 index 73881002b9..0000000000 --- a/client/src/app/hooks/table-controls/active-row/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./useActiveRowState"; -export * from "./getActiveRowDerivedState"; -export * from "./useActiveRowEffects"; diff --git a/client/src/app/hooks/table-controls/active-row/useActiveRowEffects.ts b/client/src/app/hooks/table-controls/active-row/useActiveRowEffects.ts deleted file mode 100644 index c07af0d82e..0000000000 --- a/client/src/app/hooks/table-controls/active-row/useActiveRowEffects.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as React from "react"; -import { getActiveRowDerivedState } from "./getActiveRowDerivedState"; -import { IActiveRowState } from "./useActiveRowState"; - -interface IUseActiveRowEffectsArgs { - isLoading?: boolean; - activeRowState: IActiveRowState; - activeRowDerivedState: ReturnType>; -} - -export const useActiveRowEffects = ({ - isLoading, - activeRowState: { activeRowId }, - activeRowDerivedState: { activeRowItem, clearActiveRow }, -}: IUseActiveRowEffectsArgs) => { - React.useEffect(() => { - // If some state change (e.g. refetch, pagination) causes the active row to disappear, - // remove its id from state so the drawer won't automatically reopen if the row comes back. - if (!isLoading && activeRowId && !activeRowItem) { - clearActiveRow(); - } - }, [isLoading, activeRowId, activeRowItem]); -}; diff --git a/client/src/app/hooks/table-controls/active-row/useActiveRowState.ts b/client/src/app/hooks/table-controls/active-row/useActiveRowState.ts deleted file mode 100644 index 478edf7cfb..0000000000 --- a/client/src/app/hooks/table-controls/active-row/useActiveRowState.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as React from "react"; -import { useUrlParams } from "../../useUrlParams"; -import { IExtraArgsForURLParamHooks } from "../types"; -import { parseMaybeNumericString } from "@app/utils/utils"; - -export interface IActiveRowState { - activeRowId: string | number | null; - setActiveRowId: (id: string | number | null) => void; -} - -export const useActiveRowState = (): IActiveRowState => { - const [activeRowId, setActiveRowId] = React.useState( - null - ); - return { activeRowId, setActiveRowId }; -}; - -export const useActiveRowUrlParams = < - TURLParamKeyPrefix extends string = string ->({ - urlParamKeyPrefix, -}: IExtraArgsForURLParamHooks = {}): IActiveRowState => { - const [activeRowId, setActiveRowId] = useUrlParams({ - keyPrefix: urlParamKeyPrefix, - keys: ["activeRow"], - defaultValue: null as string | number | null, - serialize: (activeRowId) => ({ - activeRow: activeRowId !== null ? String(activeRowId) : null, - }), - deserialize: ({ activeRow }) => parseMaybeNumericString(activeRow), - }); - return { activeRowId, setActiveRowId }; -}; diff --git a/client/src/app/hooks/table-controls/expansion/getExpansionDerivedState.ts b/client/src/app/hooks/table-controls/expansion/getExpansionDerivedState.ts index d36325e8ef..5c0511cbd2 100644 --- a/client/src/app/hooks/table-controls/expansion/getExpansionDerivedState.ts +++ b/client/src/app/hooks/table-controls/expansion/getExpansionDerivedState.ts @@ -1,29 +1,69 @@ 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; } -// Note: This is not named `getLocalExpansionDerivedState` because it is always local, -// and it is still used when working with server-managed tables. +/** + * 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) => { - // isCellExpanded: - // - If called with a columnKey, returns whether that specific cell is expanded - // - If called without a columnKey, returns whether the row is expanded at all +}: IExpansionDerivedStateArgs): IExpansionDerivedState< + TItem, + TColumnKey +> => { const isCellExpanded = (item: TItem, columnKey?: TColumnKey) => { return columnKey ? expandedCells[String(item[idProperty])] === columnKey : !!expandedCells[String(item[idProperty])]; }; - // setCellExpanded: - // - If called with a columnKey, sets that column expanded or collapsed (use for compound-expand) - // - If called without a columnKey, sets the entire row as expanded or collapsed (use for single-expand) const setCellExpanded = ({ item, isExpanding = true, diff --git a/client/src/app/hooks/table-controls/expansion/index.ts b/client/src/app/hooks/table-controls/expansion/index.ts index 64c88055c4..495c40182a 100644 --- a/client/src/app/hooks/table-controls/expansion/index.ts +++ b/client/src/app/hooks/table-controls/expansion/index.ts @@ -1,2 +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 0000000000..9292af10fe --- /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 index 0080710258..ac3a873bbf 100644 --- a/client/src/app/hooks/table-controls/expansion/useExpansionState.ts +++ b/client/src/app/hooks/table-controls/expansion/useExpansionState.ts @@ -1,54 +1,117 @@ -import React from "react"; -import { useUrlParams } from "../../useUrlParams"; +import { usePersistentState } from "@app/hooks/usePersistentState"; import { objectKeys } from "@app/utils/utils"; -import { IExtraArgsForURLParamHooks } from "../types"; +import { IFeaturePersistenceArgs } from "../types"; +import { DiscriminatedArgs } from "@app/utils/type-utils"; -// TExpandedCells maps item[idProperty] values to either: -// - The key of an expanded column in that row, if the table is compound-expandable -// - The `true` literal value (the entire row is expanded), if non-compound-expandable +/** + * 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 { - expandedCells: Record; - setExpandedCells: ( - newExpandedCells: Record - ) => void; + /** + * 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; } -export const useExpansionState = < - TColumnKey extends string ->(): IExpansionState => { - const [expandedCells, setExpandedCells] = React.useState< - TExpandedCells - >({}); - return { expandedCells, setExpandedCells }; -}; +/** + * 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"; + } +>; -export const useExpansionUrlParams = < +/** + * 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, - TURLParamKeyPrefix extends string = string ->({ - urlParamKeyPrefix, -}: IExtraArgsForURLParamHooks = {}): IExpansionState => { - const [expandedCells, setExpandedCells] = useUrlParams({ - keyPrefix: urlParamKeyPrefix, - keys: ["expandedCells"], - defaultValue: {} as TExpandedCells, - serialize: (expandedCellsObj) => { - if (objectKeys(expandedCellsObj).length === 0) - return { expandedCells: null }; - return { expandedCells: JSON.stringify(expandedCellsObj) }; - }, - deserialize: ({ expandedCells: expandedCellsStr }) => { - try { - return JSON.parse(expandedCellsStr || "{}"); - } catch (e) { - return {}; - } - }, + 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/filtering/getFilterHubRequestParams.ts b/client/src/app/hooks/table-controls/filtering/getFilterHubRequestParams.ts index 778e50d9ff..78b74fb721 100644 --- a/client/src/app/hooks/table-controls/filtering/getFilterHubRequestParams.ts +++ b/client/src/app/hooks/table-controls/filtering/getFilterHubRequestParams.ts @@ -6,7 +6,11 @@ import { } from "@app/components/FilterToolbar"; import { IFilterState } from "./useFilterState"; -// If we have multiple UI filters using the same hub field, we need to AND them and pass them to the hub as one filter. +/** + * Helper function for getFilterHubRequestParams + * Given a new filter, determines whether there is an existing filter for that hub field and either creates one or merges this filter with the existing one. + * - If we have multiple UI filters using the same hub field, we need to AND them and pass them to the hub as one filter. + */ const pushOrMergeFilter = ( existingFilters: HubFilter[], newFilter: HubFilter @@ -46,18 +50,33 @@ const pushOrMergeFilter = ( } }; +/** + * Args for getFilterHubRequestParams + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + */ export interface IGetFilterHubRequestParamsArgs< TItem, - TFilterCategoryKey extends string = string + TFilterCategoryKey extends string = 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[]; implicitFilters?: HubFilter[]; } +/** + * Given the state for the filter feature and additional arguments, returns params the hub API needs to apply the current filters. + * - Makes up part of the object returned by getHubRequestParams + * @see getHubRequestParams + */ export const getFilterHubRequestParams = < TItem, - TFilterCategoryKey extends string = string + TFilterCategoryKey extends string = string, >({ filterState, filterCategories, @@ -128,9 +147,17 @@ export const getFilterHubRequestParams = < return { filters }; }; +/** + * Helper function for serializeFilterForHub + * - Given a string or number, returns it as a string with quotes (`"`) around it. + * - Adds an escape character before any existing quote (`"`) characters in the string. + */ export const wrapInQuotesAndEscape = (value: string | number): string => `"${String(value).replace('"', '\\"')}"`; +/** + * Converts a single filter object (HubFilter, the higher-level inspectable type) to the query string filter format used by the hub API + */ export const serializeFilterForHub = (filter: HubFilter): string => { const { field, operator, value } = filter; const joinedValue = @@ -144,6 +171,12 @@ export const serializeFilterForHub = (filter: HubFilter): string => { return `${field}${operator}${joinedValue}`; }; +/** + * Converts the values returned by getFilterHubRequestParams into the URL query strings expected by the hub API + * - Appends converted URL params to the given `serializedParams` object for use in the hub API request + * - Constructs part of the object returned by serializeRequestParamsForHub + * @see serializeRequestParamsForHub + */ export const serializeFilterRequestParamsForHub = ( deserializedParams: HubRequestParams, serializedParams: URLSearchParams diff --git a/client/src/app/hooks/table-controls/filtering/getFilterProps.ts b/client/src/app/hooks/table-controls/filtering/getFilterProps.ts deleted file mode 100644 index 6eb3d65f58..0000000000 --- a/client/src/app/hooks/table-controls/filtering/getFilterProps.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - FilterCategory, - IFilterToolbarProps, -} from "@app/components/FilterToolbar"; -import { IFilterState } from "./useFilterState"; - -export interface IFilterPropsArgs { - filterState: IFilterState; - filterCategories?: FilterCategory[]; -} - -export const getFilterProps = ({ - filterState: { filterValues, setFilterValues }, - filterCategories = [], -}: IFilterPropsArgs): IFilterToolbarProps< - TItem, - TFilterCategoryKey -> => ({ - filterCategories, - filterValues, - setFilterValues, -}); diff --git a/client/src/app/hooks/table-controls/filtering/getLocalFilterDerivedState.ts b/client/src/app/hooks/table-controls/filtering/getLocalFilterDerivedState.ts index 09ac315d30..261a4891a0 100644 --- a/client/src/app/hooks/table-controls/filtering/getLocalFilterDerivedState.ts +++ b/client/src/app/hooks/table-controls/filtering/getLocalFilterDerivedState.ts @@ -5,24 +5,44 @@ import { 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 + 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 + TFilterCategoryKey extends string, >({ items, filterCategories = [], filterState: { filterValues }, -}: ILocalFilterDerivedStateArgs & { - filterState: IFilterState; -}) => { +}: ILocalFilterDerivedStateArgs) => { const filteredItems = items.filter((item) => objectKeys(filterValues).every((categoryKey) => { const values = filterValues[categoryKey]; 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 0000000000..67811ac629 --- /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 index aeb3f6c370..545b3aec73 100644 --- a/client/src/app/hooks/table-controls/filtering/index.ts +++ b/client/src/app/hooks/table-controls/filtering/index.ts @@ -1,4 +1,5 @@ export * from "./useFilterState"; export * from "./getLocalFilterDerivedState"; -export * from "./getFilterProps"; +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 0000000000..8fff39fcc9 --- /dev/null +++ b/client/src/app/hooks/table-controls/filtering/useFilterPropHelpers.ts @@ -0,0 +1,67 @@ +import { + FilterCategory, + IFilterToolbarProps, +} from "@app/components/FilterToolbar"; +import { IFilterState } from "./useFilterState"; +import { ToolbarProps } from "@patternfly/react-core"; +import { useTranslation } from "react-i18next"; + +/** + * 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 { t } = useTranslation(); + + const { + filterState: { filterValues, setFilterValues }, + filterCategories = [], + } = args; + + /** + * Filter-related props for the PF Toolbar component + */ + const filterPropsForToolbar: ToolbarProps = { + collapseListedFiltersBreakpoint: "xl", + clearAllFilters: () => setFilterValues({}), + clearFiltersButtonText: t("actions.clearAllFilters"), + }; + + /** + * 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 index 08fb2af669..1b2b267d68 100644 --- a/client/src/app/hooks/table-controls/filtering/useFilterState.ts +++ b/client/src/app/hooks/table-controls/filtering/useFilterState.ts @@ -1,84 +1,91 @@ -import * as React from "react"; -import { useSessionStorage } from "@migtools/lib-ui"; -import { - FilterCategory, - FilterValue, - IFilterValues, -} from "@app/components/FilterToolbar"; -import { useUrlParams } from "../../useUrlParams"; -import { objectKeys } from "@app/utils/utils"; -import { IExtraArgsForURLParamHooks } from "../types"; +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"; +/** + * 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; } -export interface IFilterStateArgs { - filterStorageKey?: string; - filterCategories?: FilterCategory[]; -} - -export const useFilterState = ({ - filterStorageKey, -}: IFilterStateArgs< +/** + * 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 ->): IFilterState => { - const state = React.useState>({}); - const storage = useSessionStorage>( - filterStorageKey || "", - {} - ); - const [filterValues, setFilterValues] = filterStorageKey ? storage : state; - return { filterValues, setFilterValues }; -}; - -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. - }; -}; - -export const deserializeFilterUrlParams = < - TFilterCategoryKey extends string ->(serializedParams: { - filters?: string | null; -}): Partial> => { - try { - return JSON.parse(serializedParams.filters || "{}"); - } catch (e) { - return {}; + 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[]; } -}; +>; -export const useFilterUrlParams = < +/** + * 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, - TURLParamKeyPrefix extends string = string ->({ - urlParamKeyPrefix, -}: IExtraArgsForURLParamHooks = {}): IFilterState => { - const [filterValues, setFilterValues] = useUrlParams({ - keyPrefix: urlParamKeyPrefix, - keys: ["filters"], - defaultValue: {} as IFilterValues, - serialize: serializeFilterUrlParams, - deserialize: deserializeFilterUrlParams, + TPersistenceKeyPrefix extends string = string, +>( + args: IFilterStateArgs & + IFeaturePersistenceArgs +): IFilterState => { + const { isFilterEnabled, 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 [filterValues, setFilterValues] = usePersistentState< + IFilterValues, + TPersistenceKeyPrefix, + "filters" + >({ + isEnabled: !!isFilterEnabled, + 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: ["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 16abb96808..fc7042dfc9 100644 --- a/client/src/app/hooks/table-controls/getHubRequestParams.ts +++ b/client/src/app/hooks/table-controls/getHubRequestParams.ts @@ -18,10 +18,21 @@ import { 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 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. + * @see useTableControlState + * @see useTableControlProps + */ export const getHubRequestParams = < TItem, TSortableColumnKey extends string, - TFilterCategoryKey extends string = string + TFilterCategoryKey extends string = string, >( args: IGetFilterHubRequestParamsArgs & IGetSortHubRequestParamsArgs & @@ -32,6 +43,11 @@ export const getHubRequestParams = < ...getPaginationHubRequestParams(args), }); +/** + * Converts the HubRequestParams object created above into URLSearchParams (the browser API object for URL query parameters). + * - NOTE: This is Konveyor-specific. + * - Used internally by the application's useFetch[Resource] hooks + */ export const serializeRequestParamsForHub = ( deserializedParams: HubRequestParams ): URLSearchParams => { 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 0000000000..9128b1d598 --- /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: items.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 0dc3eb49f3..9145da1f37 100644 --- a/client/src/app/hooks/table-controls/index.ts +++ b/client/src/app/hooks/table-controls/index.ts @@ -1,10 +1,12 @@ export * from "./types"; export * from "./utils"; -export * from "./useLocalTableControlState"; +export * from "./useTableControlState"; export * from "./useTableControlProps"; +export * from "./getLocalTableControlDerivedState"; export * from "./useLocalTableControls"; -export * from "./useTableControlUrlParams"; 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 index 2761b3f7c8..307155ae3e 100644 --- a/client/src/app/hooks/table-controls/pagination/getLocalPaginationDerivedState.ts +++ b/client/src/app/hooks/table-controls/pagination/getLocalPaginationDerivedState.ts @@ -1,15 +1,32 @@ 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 & { - paginationState: IPaginationState; -}) => { +}: ILocalPaginationDerivedStateArgs) => { const pageStartIndex = (pageNumber - 1) * itemsPerPage; const currentPageItems = items.slice( pageStartIndex, diff --git a/client/src/app/hooks/table-controls/pagination/getPaginationHubRequestParams.ts b/client/src/app/hooks/table-controls/pagination/getPaginationHubRequestParams.ts index 168d8813b7..8103b2ed93 100644 --- a/client/src/app/hooks/table-controls/pagination/getPaginationHubRequestParams.ts +++ b/client/src/app/hooks/table-controls/pagination/getPaginationHubRequestParams.ts @@ -1,10 +1,22 @@ import { HubRequestParams } from "@app/api/models"; import { IPaginationState } from "./usePaginationState"; +/** + * Args for getPaginationHubRequestParams + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + */ export interface IGetPaginationHubRequestParamsArgs { + /** + * The "source of truth" state for the pagination feature (returned by usePaginationState) + */ paginationState?: IPaginationState; } +/** + * Given the state for the pagination feature and additional arguments, returns params the hub API needs to apply the current pagination. + * - Makes up part of the object returned by getHubRequestParams + * @see getHubRequestParams + */ export const getPaginationHubRequestParams = ({ paginationState, }: IGetPaginationHubRequestParamsArgs): Partial => { @@ -13,6 +25,12 @@ export const getPaginationHubRequestParams = ({ return { page: { pageNumber, itemsPerPage } }; }; +/** + * Converts the values returned by getPaginationHubRequestParams into the URL query strings expected by the hub API + * - Appends converted URL params to the given `serializedParams` object for use in the hub API request + * - Constructs part of the object returned by serializeRequestParamsForHub + * @see serializeRequestParamsForHub + */ export const serializePaginationRequestParamsForHub = ( deserializedParams: HubRequestParams, serializedParams: URLSearchParams diff --git a/client/src/app/hooks/table-controls/pagination/getPaginationProps.ts b/client/src/app/hooks/table-controls/pagination/getPaginationProps.ts deleted file mode 100644 index 9bbc7c31fd..0000000000 --- a/client/src/app/hooks/table-controls/pagination/getPaginationProps.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { PaginationProps } from "@patternfly/react-core"; -import { IPaginationState } from "./usePaginationState"; - -export interface IPaginationPropsArgs { - paginationState: IPaginationState; - totalItemCount: number; -} - -export const getPaginationProps = ({ - paginationState: { pageNumber, setPageNumber, itemsPerPage, setItemsPerPage }, - totalItemCount, -}: IPaginationPropsArgs): PaginationProps => ({ - itemCount: totalItemCount, - perPage: itemsPerPage, - page: pageNumber, - onSetPage: (event, pageNumber) => setPageNumber(pageNumber), - onPerPageSelect: (event, perPage) => { - setPageNumber(1); - setItemsPerPage(perPage); - }, -}); diff --git a/client/src/app/hooks/table-controls/pagination/index.ts b/client/src/app/hooks/table-controls/pagination/index.ts index 0a99dc24f2..b3d1706939 100644 --- a/client/src/app/hooks/table-controls/pagination/index.ts +++ b/client/src/app/hooks/table-controls/pagination/index.ts @@ -1,5 +1,5 @@ export * from "./usePaginationState"; export * from "./getLocalPaginationDerivedState"; -export * from "./getPaginationProps"; +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 index 38a3bd0d56..7fe9109c91 100644 --- a/client/src/app/hooks/table-controls/pagination/usePaginationEffects.ts +++ b/client/src/app/hooks/table-controls/pagination/usePaginationEffects.ts @@ -1,13 +1,26 @@ 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, @@ -15,7 +28,7 @@ export const usePaginationEffects = ({ // When items are removed, make sure the current page still exists const lastPageNumber = Math.max(Math.ceil(totalItemCount / itemsPerPage), 1); React.useEffect(() => { - if (pageNumber > lastPageNumber && !isLoading) { + 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 0000000000..1f1fd178b7 --- /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 index 7d6e91ff0b..6fd87c84b1 100644 --- a/client/src/app/hooks/table-controls/pagination/usePaginationState.ts +++ b/client/src/app/hooks/table-controls/pagination/usePaginationState.ts @@ -1,62 +1,133 @@ -import * as React from "react"; -import { useUrlParams } from "../../useUrlParams"; -import { IExtraArgsForURLParamHooks } from "../types"; +import { usePersistentState } from "@app/hooks/usePersistentState"; +import { IFeaturePersistenceArgs } from "../types"; +import { DiscriminatedArgs } from "@app/utils/type-utils"; -export interface IPaginationState { +/** + * The currently applied pagination parameters + */ +export interface IActivePagination { + /** + * The current page number on the user's pagination controls (counting from 1) + */ pageNumber: number; - setPageNumber: (pageNumber: number) => void; + /** + * The current "items per page" setting on the user's pagination controls (defaults to 10) + */ itemsPerPage: number; - setItemsPerPage: (numItems: number) => void; } -export interface IPaginationStateArgs { - initialItemsPerPage?: 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; } -export const usePaginationState = ({ - initialItemsPerPage = 10, -}: IPaginationStateArgs): IPaginationState => { - const [pageNumber, baseSetPageNumber] = React.useState(1); - const setPageNumber = (num: number) => baseSetPageNumber(num >= 1 ? num : 1); - const [itemsPerPage, setItemsPerPage] = React.useState(initialItemsPerPage); - return { pageNumber, setPageNumber, itemsPerPage, setItemsPerPage }; -}; +/** + * 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; -export const usePaginationUrlParams = < - TURLParamKeyPrefix extends string = string ->({ - initialItemsPerPage = 10, - urlParamKeyPrefix, -}: IPaginationStateArgs & - IExtraArgsForURLParamHooks): IPaginationState => { - const defaultValue = { pageNumber: 1, itemsPerPage: initialItemsPerPage }; - const [paginationState, setPaginationState] = useUrlParams({ - keyPrefix: urlParamKeyPrefix, - keys: ["pageNumber", "itemsPerPage"], + 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, - serialize: ({ pageNumber, itemsPerPage }) => ({ - pageNumber: pageNumber ? String(pageNumber) : undefined, - itemsPerPage: itemsPerPage ? String(itemsPerPage) : undefined, - }), - deserialize: ({ pageNumber, itemsPerPage }) => - pageNumber && itemsPerPage - ? { - pageNumber: parseInt(pageNumber, 10), - itemsPerPage: parseInt(itemsPerPage, 10), - } - : 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 setPageNumber = (pageNumber: number) => - setPaginationState({ pageNumber: pageNumber >= 1 ? pageNumber : 1 }); + const { pageNumber, itemsPerPage } = paginationState || defaultValue; + const setPageNumber = (num: number) => + setPaginationState({ + pageNumber: num >= 1 ? num : 1, + itemsPerPage: paginationState?.itemsPerPage || initialItemsPerPage, + }); const setItemsPerPage = (itemsPerPage: number) => - setPaginationState({ itemsPerPage }); - - const { pageNumber, itemsPerPage } = paginationState; - return { - pageNumber: pageNumber || 1, - itemsPerPage: itemsPerPage || initialItemsPerPage, - setPageNumber, - setItemsPerPage, - }; + 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 index 6903650718..b63befb5ab 100644 --- a/client/src/app/hooks/table-controls/sorting/getLocalSortDerivedState.ts +++ b/client/src/app/hooks/table-controls/sorting/getLocalSortDerivedState.ts @@ -1,26 +1,50 @@ import i18n from "@app/i18n"; 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 + 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 + TSortableColumnKey extends string, >({ items, getSortValues, sortState: { activeSort }, -}: ILocalSortDerivedStateArgs & { - sortState: ISortState; -}) => { +}: ILocalSortDerivedStateArgs) => { if (!getSortValues || !activeSort) { return { sortedItems: items }; } diff --git a/client/src/app/hooks/table-controls/sorting/getSortHubRequestParams.ts b/client/src/app/hooks/table-controls/sorting/getSortHubRequestParams.ts index b807963c4d..155b053b17 100644 --- a/client/src/app/hooks/table-controls/sorting/getSortHubRequestParams.ts +++ b/client/src/app/hooks/table-controls/sorting/getSortHubRequestParams.ts @@ -1,13 +1,29 @@ import { HubRequestParams } from "@app/api/models"; import { ISortState } from "./useSortState"; +/** + * Args for getSortHubRequestParams + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + */ export interface IGetSortHubRequestParamsArgs< - TSortableColumnKey extends string + TSortableColumnKey extends string, > { + /** + * The "source of truth" state for the sort feature (returned by usePaginationState) + */ 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. + */ hubSortFieldKeys?: Record; } +/** + * Given the state for the sort feature and additional arguments, returns params the hub API needs to apply the current sort. + * - Makes up part of the object returned by getHubRequestParams + * @see getHubRequestParams + */ export const getSortHubRequestParams = ({ sortState, hubSortFieldKeys, @@ -22,6 +38,12 @@ export const getSortHubRequestParams = ({ }; }; +/** + * Converts the values returned by getSortHubRequestParams into the URL query strings expected by the hub API + * - Appends converted URL params to the given `serializedParams` object for use in the hub API request + * - Constructs part of the object returned by serializeRequestParamsForHub + * @see serializeRequestParamsForHub + */ export const serializeSortRequestParamsForHub = ( deserializedParams: HubRequestParams, serializedParams: URLSearchParams diff --git a/client/src/app/hooks/table-controls/sorting/getSortProps.ts b/client/src/app/hooks/table-controls/sorting/getSortProps.ts deleted file mode 100644 index 7c91d08c78..0000000000 --- a/client/src/app/hooks/table-controls/sorting/getSortProps.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ThProps } from "@patternfly/react-table"; -import { ISortState } from "./useSortState"; - -// Args that are part of IUseTableControlPropsArgs (the args for useTableControlProps) -export interface ISortPropsArgs< - TColumnKey extends string, - TSortableColumnKey extends TColumnKey -> { - sortState: ISortState; -} - -// Additional args that need to be passed in on a per-column basis -export interface IUseSortPropsArgs< - TColumnKey extends string, - TSortableColumnKey extends TColumnKey -> extends ISortPropsArgs { - columnKeys: TColumnKey[]; - columnKey: TSortableColumnKey; -} - -export const getSortProps = < - TColumnKey extends string, - TSortableColumnKey extends TColumnKey ->({ - sortState: { activeSort, setActiveSort }, - columnKeys, - columnKey, -}: IUseSortPropsArgs): Pick< - ThProps, - "sort" -> => ({ - sort: { - columnIndex: columnKeys.indexOf(columnKey), - sortBy: { - index: activeSort - ? columnKeys.indexOf(activeSort.columnKey as TSortableColumnKey) - : undefined, - direction: activeSort?.direction, - }, - onSort: (event, index, direction) => { - setActiveSort({ - columnKey: columnKeys[index] as TSortableColumnKey, - direction, - }); - }, - }, -}); diff --git a/client/src/app/hooks/table-controls/sorting/index.ts b/client/src/app/hooks/table-controls/sorting/index.ts index 37cbe2dd5e..cf37beb770 100644 --- a/client/src/app/hooks/table-controls/sorting/index.ts +++ b/client/src/app/hooks/table-controls/sorting/index.ts @@ -1,4 +1,4 @@ export * from "./useSortState"; export * from "./getLocalSortDerivedState"; -export * from "./getSortProps"; +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 0000000000..a8fa132f80 --- /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 index 8ada4d5de1..87fc61904c 100644 --- a/client/src/app/hooks/table-controls/sorting/useSortState.ts +++ b/client/src/app/hooks/table-controls/sorting/useSortState.ts @@ -1,61 +1,117 @@ -import * as React from "react"; -import { useUrlParams } from "../../useUrlParams"; -import { IExtraArgsForURLParamHooks } from "../types"; +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; } -export interface ISortStateArgs { - sortableColumns?: TSortableColumnKey[]; - initialSort?: IActiveSort | null; -} +/** + * 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; + } + >; -const getDefaultSort = ( - sortableColumns: TSortableColumnKey[] -): IActiveSort | null => - sortableColumns[0] +/** + * 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; -export const useSortState = ({ - sortableColumns = [], - initialSort = getDefaultSort(sortableColumns), -}: ISortStateArgs): ISortState => { - const [activeSort, setActiveSort] = React.useState(initialSort); - return { activeSort, setActiveSort }; -}; - -export const useSortUrlParams = < - TSortableColumnKey extends string, - TURLParamKeyPrefix extends string = string ->({ - sortableColumns = [], - initialSort = getDefaultSort(sortableColumns), - urlParamKeyPrefix, -}: ISortStateArgs & - IExtraArgsForURLParamHooks): ISortState => { - const [activeSort, setActiveSort] = useUrlParams({ - keyPrefix: urlParamKeyPrefix, - keys: ["sortColumn", "sortDirection"], + // 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, - 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, + 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 index 1695b1e6b1..256166ac2e 100644 --- a/client/src/app/hooks/table-controls/types.ts +++ b/client/src/app/hooks/table-controls/types.ts @@ -1,117 +1,453 @@ -import { TableProps } from "@patternfly/react-table"; +import { TableProps, TdProps, ThProps, TrProps } from "@patternfly/react-table"; import { ISelectionStateArgs, useSelectionState } from "@migtools/lib-ui"; -import { DisallowCharacters, KeyWithValueType } from "@app/utils/type-utils"; +import { DisallowCharacters, DiscriminatedArgs } from "@app/utils/type-utils"; import { IFilterStateArgs, ILocalFilterDerivedStateArgs, - IFilterPropsArgs, + IFilterPropHelpersExternalArgs, + IFilterState, } from "./filtering"; import { ILocalSortDerivedStateArgs, - ISortPropsArgs, + ISortPropHelpersExternalArgs, + ISortState, ISortStateArgs, } from "./sorting"; import { IPaginationStateArgs, ILocalPaginationDerivedStateArgs, - IPaginationPropsArgs, + IPaginationPropHelpersExternalArgs, + IPaginationState, } from "./pagination"; -import { IExpansionDerivedStateArgs } from "./expansion"; -import { IActiveRowDerivedStateArgs } from "./active-row"; +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"; // 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"; -// TODO when calling useTableControlUrlParams, the TItem type is not inferred and some of the params have it inferred as `unknown`. -// this currently doesn't seem to matter since TItem becomes inferred later when currentPageItems is in scope, -// but we should see if we can fix that (maybe not depend on TItem in the extended types here, or find a way -// to pass TItem while still letting the rest of the generics be inferred. -// This may be resolved in a newer TypeScript version after https://github.com/microsoft/TypeScript/pull/54047 is merged! +/** + * 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 args -// - Used by both useLocalTableControlState and useTableControlUrlParams -// - Does not require any state or query values in scope -export interface ITableControlCommonArgs< +/** + * 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 -> extends IFilterStateArgs, - ISortStateArgs, - IPaginationStateArgs { - columnNames: Record; // An ordered mapping of unique keys to human-readable column name strings - isSelectable?: boolean; - hasPagination?: boolean; - expandableVariant?: "single" | "compound" | null; - hasActionsColumn?: boolean; - variant?: TableProps["variant"]; -} + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = 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; +} & IFilterStateArgs & + ISortStateArgs & + IPaginationStateArgs & { + isSelectionEnabled?: boolean; // TODO move this into useSelectionState when we move it from lib-ui + } & IExpansionStateArgs & + IActiveItemStateArgs & + ITablePersistenceArgs; -// URL-param-specific args -// - Extra args needed for useTableControlUrlParams and each concern-specific use*UrlParams hook -// - Does not require any state or query values in scope -export interface IExtraArgsForURLParamHooks< - TURLParamKeyPrefix extends string = string -> { - urlParamKeyPrefix?: DisallowCharacters; -} +/** + * 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; +}; + +/** + * 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 -// Data-dependent args -// - Used by both useLocalTableControlState and useTableControlProps -// - Requires query values and defined TItem type in scope but not state values -export interface ITableControlDataDependentArgs { - isLoading?: boolean; - idProperty: KeyWithValueType; - forceNumRenderedColumns?: number; -} +/** + * 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 in the entire un-filtered, un-paginated table (the size of the entire API collection being tabulated). + */ + totalItemCount: number; +}; -// Derived state option args -// - Used by only useLocalTableControlState (client-side filtering/sorting/pagination) -// - Requires state and query values in scope -export type IUseLocalTableControlStateArgs< +/** + * 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 -> = ITableControlCommonArgs< + TFilterCategoryKey extends string = string, + TPersistenceKeyPrefix extends string = string, +> = IUseTableControlStateArgs< TItem, TColumnKey, TSortableColumnKey, - TFilterCategoryKey + TFilterCategoryKey, + TPersistenceKeyPrefix > & - ITableControlDataDependentArgs & - ILocalFilterDerivedStateArgs & - IFilterStateArgs & - ILocalSortDerivedStateArgs & - ILocalPaginationDerivedStateArgs & - Pick, "initialSelected" | "isItemSelectable">; + 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>; + }; -// Rendering args -// - Used by only useTableControlProps -// - Requires state and query values in scope -// - Combines all args above with either: -// - The return values of useLocalTableControlState -// - The return values of useTableControlUrlParams and args derived from server-side filtering/sorting/pagination -export interface IUseTableControlPropsArgs< +/** + * 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 -> extends ITableControlCommonArgs< + 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 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; + }; +}; + +/** + * 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 - >, - ITableControlDataDependentArgs, - IFilterPropsArgs, - ISortPropsArgs, - IPaginationPropsArgs, - IExpansionDerivedStateArgs, - IActiveRowDerivedStateArgs { - currentPageItems: TItem[]; - selectionState: ReturnType>; // TODO make this optional? fold it in? -} + > & + 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/useLocalTableControlState.ts b/client/src/app/hooks/table-controls/useLocalTableControlState.ts deleted file mode 100644 index 3dd671b58a..0000000000 --- a/client/src/app/hooks/table-controls/useLocalTableControlState.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { useSelectionState } from "@migtools/lib-ui"; -import { - getLocalFilterDerivedState, - useFilterState, - useFilterUrlParams, -} from "./filtering"; -import { - useSortState, - getLocalSortDerivedState, - useSortUrlParams, -} from "./sorting"; -import { - getLocalPaginationDerivedState, - usePaginationState, - usePaginationUrlParams, -} from "./pagination"; -import { useExpansionState, useExpansionUrlParams } from "./expansion"; -import { useActiveRowState, useActiveRowUrlParams } from "./active-row"; -import { - IExtraArgsForURLParamHooks, - IUseLocalTableControlStateArgs, - IUseTableControlPropsArgs, -} from "./types"; - -export const useLocalTableControlState = < - TItem, - TColumnKey extends string, - TSortableColumnKey extends TColumnKey, ->( - args: IUseLocalTableControlStateArgs -): IUseTableControlPropsArgs => { - const { - items, - filterCategories = [], - sortableColumns = [], - getSortValues, - initialSort = null, - hasPagination = true, - initialItemsPerPage = 10, - idProperty, - initialSelected, - isItemSelectable, - } = args; - - const filterState = useFilterState(args); - const { filteredItems } = getLocalFilterDerivedState({ - items, - filterCategories, - filterState, - }); - - const selectionState = useSelectionState({ - items: filteredItems, - isEqual: (a, b) => a[idProperty] === b[idProperty], - initialSelected, - isItemSelectable, - }); - - const sortState = useSortState({ sortableColumns, initialSort }); - const { sortedItems } = getLocalSortDerivedState({ - sortState, - items: filteredItems, - getSortValues, - }); - - const paginationState = usePaginationState({ - initialItemsPerPage, - }); - const { currentPageItems } = getLocalPaginationDerivedState({ - paginationState, - items: sortedItems, - }); - - const expansionState = useExpansionState(); - - const activeRowState = useActiveRowState(); - - return { - ...args, - filterState, - expansionState, - selectionState, - sortState, - paginationState, - activeRowState, - totalItemCount: items.length, - currentPageItems: hasPagination ? currentPageItems : sortedItems, - }; -}; - -// TODO refactor useUrlParams so it can be used conditionally (e.g. useStateOrUrlParams) so we don't have to duplicate all this. -// this would mean all use[Feature]UrlParams hooks could be consolidated into use[Feature]State with a boolean option for whether to use URL params. - -export const useLocalTableControlUrlParams = < - TItem, - TColumnKey extends string, - TSortableColumnKey extends TColumnKey, - TURLParamKeyPrefix extends string = string, ->( - args: IUseLocalTableControlStateArgs & - IExtraArgsForURLParamHooks -): IUseTableControlPropsArgs => { - const { - items, - filterCategories = [], - sortableColumns = [], - getSortValues, - initialSort = null, - hasPagination = true, - initialItemsPerPage = 10, - idProperty, - initialSelected, - isItemSelectable, - } = args; - - const filterState = useFilterUrlParams(args); - const { filteredItems } = getLocalFilterDerivedState({ - items, - filterCategories, - filterState, - }); - - const selectionState = useSelectionState({ - items: filteredItems, - isEqual: (a, b) => a[idProperty] === b[idProperty], - initialSelected, - isItemSelectable, - }); - - const sortState = useSortUrlParams({ ...args, sortableColumns, initialSort }); - const { sortedItems } = getLocalSortDerivedState({ - sortState, - items: filteredItems, - getSortValues, - }); - - const paginationState = usePaginationUrlParams({ - ...args, - initialItemsPerPage, - }); - const { currentPageItems } = getLocalPaginationDerivedState({ - paginationState, - items: sortedItems, - }); - - const expansionState = useExpansionUrlParams(args); - - const activeRowState = useActiveRowUrlParams(args); - - return { - ...args, - filterState, - expansionState, - selectionState, - sortState, - paginationState, - activeRowState, - totalItemCount: items.length, - currentPageItems: hasPagination ? currentPageItems : sortedItems, - }; -}; diff --git a/client/src/app/hooks/table-controls/useLocalTableControls.ts b/client/src/app/hooks/table-controls/useLocalTableControls.ts index 4e5efc6762..c24d0d70c6 100644 --- a/client/src/app/hooks/table-controls/useLocalTableControls.ts +++ b/client/src/app/hooks/table-controls/useLocalTableControls.ts @@ -1,27 +1,46 @@ -import { - useLocalTableControlState, - useLocalTableControlUrlParams, -} from "./useLocalTableControlState"; import { useTableControlProps } from "./useTableControlProps"; -import { - IExtraArgsForURLParamHooks, - IUseLocalTableControlStateArgs, -} from "./types"; +import { ITableControls, IUseLocalTableControlsArgs } from "./types"; +import { getLocalTableControlDerivedState } from "./getLocalTableControlDerivedState"; +import { useTableControlState } from "./useTableControlState"; +import { useSelectionState } from "@migtools/lib-ui"; +/** + * 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: IUseLocalTableControlStateArgs -) => useTableControlProps(useLocalTableControlState(args)); - -export const useLocalTableControlsWithUrlParams = < + args: IUseLocalTableControlsArgs< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix + > +): ITableControls< TItem, - TColumnKey extends string, - TSortableColumnKey extends TColumnKey, - TURLParamKeyPrefix extends string = string, ->( - args: IUseLocalTableControlStateArgs & - IExtraArgsForURLParamHooks -) => useTableControlProps(useLocalTableControlUrlParams(args)); + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix +> => { + const state = useTableControlState(args); + const derivedState = getLocalTableControlDerivedState({ ...args, ...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], + }), + }); +}; diff --git a/client/src/app/hooks/table-controls/useTableControlProps.ts b/client/src/app/hooks/table-controls/useTableControlProps.ts index bd0c05cba5..6169d1f491 100644 --- a/client/src/app/hooks/table-controls/useTableControlProps.ts +++ b/client/src/app/hooks/table-controls/useTableControlProps.ts @@ -1,31 +1,55 @@ import { useTranslation } from "react-i18next"; -import { ToolbarItemProps, ToolbarProps } from "@patternfly/react-core"; -import { TableProps, TdProps, ThProps, TrProps } from "@patternfly/react-table"; import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; -import { IToolbarBulkSelectorProps } from "@app/components/ToolbarBulkSelector"; import { objectKeys } from "@app/utils/utils"; -import { IUseTableControlPropsArgs } from "./types"; -import { getFilterProps } from "./filtering"; -import { getSortProps } from "./sorting"; -import { getPaginationProps, usePaginationEffects } from "./pagination"; -import { getActiveRowDerivedState, useActiveRowEffects } from "./active-row"; +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"; -import { getExpansionDerivedState } from "./expansion"; +/** + * 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 + TFilterCategoryKey, + TPersistenceKeyPrefix > -) => { +): ITableControls< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix +> => { + type PropHelpers = ITableControls< + TItem, + TColumnKey, + TSortableColumnKey, + TFilterCategoryKey, + TPersistenceKeyPrefix + >["propHelpers"]; + const { t } = useTranslation(); // Note: To avoid repetition, not all args are destructured here since the entire @@ -34,8 +58,6 @@ export const useTableControlProps = < const { currentPageItems, forceNumRenderedColumns, - filterState: { setFilterValues }, - expansionState: { expandedCells }, selectionState: { selectAll, areAllSelected, @@ -45,12 +67,13 @@ export const useTableControlProps = < isItemSelected, }, columnNames, - sortableColumns = [], - isSelectable = false, - expandableVariant = null, hasActionsColumn = false, variant, - idProperty, + isFilterEnabled, + isSortEnabled, + isSelectionEnabled, + isExpansionEnabled, + isActiveItemEnabled, } = args; const columnKeys = objectKeys(columnNames); @@ -59,39 +82,35 @@ export const useTableControlProps = < // We need to account for those when dealing with props based on column index and colSpan. let numColumnsBeforeData = 0; let numColumnsAfterData = 0; - if (isSelectable) numColumnsBeforeData++; - if (expandableVariant === "single") numColumnsBeforeData++; + if (isSelectionEnabled) numColumnsBeforeData++; + if (isExpansionEnabled && args.expandableVariant === "single") + numColumnsBeforeData++; if (hasActionsColumn) numColumnsAfterData++; const numRenderedColumns = forceNumRenderedColumns || columnKeys.length + numColumnsBeforeData + numColumnsAfterData; - const expansionDerivedState = getExpansionDerivedState(args); - const { isCellExpanded, setCellExpanded } = expansionDerivedState; - - const activeRowDerivedState = getActiveRowDerivedState(args); - useActiveRowEffects({ ...args, activeRowDerivedState }); - const { activeRowItem, setActiveRowItem, clearActiveRow } = - activeRowDerivedState; - - const toolbarProps: Omit = { + 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 : "", - collapseListedFiltersBreakpoint: "xl", - clearAllFilters: () => setFilterValues({}), - clearFiltersButtonText: t("actions.clearAllFilters"), - }; - - const filterToolbarProps = getFilterProps(args); - - const paginationProps = getPaginationProps(args); - usePaginationEffects(args); - - const paginationToolbarItemProps: ToolbarItemProps = { - variant: "pagination", - align: { default: "alignRight" }, + ...(isFilterEnabled && filterPropsForToolbar), }; - const toolbarBulkSelectorProps: IToolbarBulkSelectorProps = { + // TODO move this to a useSelectionPropHelpers when we move selection from lib-ui + const toolbarBulkSelectorProps: PropHelpers["toolbarBulkSelectorProps"] = { onSelectAll: selectAll, areAllSelected, selectedRows: selectedItems, @@ -100,62 +119,49 @@ export const useTableControlProps = < onSelectMultiple: selectMultiple, }; - const tableProps: Omit = { + const tableProps: PropHelpers["tableProps"] = { variant, - isExpandable: !!expandableVariant, + isExpandable: isExpansionEnabled && !!args.expandableVariant, }; - const getThProps = ({ - columnKey, - }: { - columnKey: TColumnKey; - }): Omit => ({ - ...(sortableColumns.includes(columnKey as TSortableColumnKey) - ? getSortProps({ - ...args, - columnKeys, - columnKey: columnKey as TSortableColumnKey, - }) - : {}), + const getThProps: PropHelpers["getThProps"] = ({ columnKey }) => ({ + ...(isSortEnabled && + getSortThProps({ columnKey: columnKey as TSortableColumnKey })), children: columnNames[columnKey], }); - const getClickableTrProps = ({ - onRowClick, - item, - }: { - onRowClick?: TrProps["onRowClick"]; // Extra callback if necessary - setting the active row is built in - item?: TItem; // Can be omitted if using this just for the click handler and not for active rows - }): Omit => ({ - isSelectable: true, - isClickable: true, - isRowSelected: item && item[idProperty] === activeRowItem?.[idProperty], - onRowClick: (event) => - handlePropagatedRowClick(event, () => { - if (item && activeRowItem?.[idProperty] !== item[idProperty]) { - setActiveRowItem(item); - } else { - clearActiveRow(); - } - onRowClick?.(event); - }), - }); + const getTrProps: PropHelpers["getTrProps"] = ({ item, onRowClick }) => { + const activeItemTrProps = getActiveItemTrProps({ item }); + return { + ...(isActiveItemEnabled && activeItemTrProps), + onRowClick: (event) => + handlePropagatedRowClick(event, () => { + activeItemTrProps.onRowClick?.(event); + onRowClick?.(event); + }), + }; + }; - const getTdProps = ({ - columnKey, - }: { - columnKey: TColumnKey; - }): Omit => ({ - dataLabel: columnNames[columnKey], - }); + 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, + })), + }; + }; - const getSelectCheckboxTdProps = ({ + // 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, - }: { - item: TItem; - rowIndex: number; - }): Omit => ({ + }) => ({ select: { rowIndex, onSelect: (_event, isSelecting) => { @@ -165,87 +171,26 @@ export const useTableControlProps = < }, }); - const getSingleExpandTdProps = ({ - item, - rowIndex, - }: { - item: TItem; - rowIndex: number; - }): Omit => ({ - expand: { - rowIndex, - isExpanded: isCellExpanded(item), - onToggle: () => - setCellExpanded({ - item, - isExpanding: !isCellExpanded(item), - }), - expandId: `expandable-row-${item[idProperty]}`, - }, - }); - - const getCompoundExpandTdProps = ({ - item, - rowIndex, - columnKey, - }: { - item: TItem; - rowIndex: number; - columnKey: TColumnKey; - }): Omit => ({ - ...getTdProps({ columnKey }), - compoundExpand: { - isExpanded: isCellExpanded(item, columnKey), - onToggle: () => - setCellExpanded({ - item, - isExpanding: !isCellExpanded(item, columnKey), - columnKey, - }), - expandId: `compound-expand-${item[idProperty]}-${columnKey}`, - rowIndex, - columnIndex: columnKeys.indexOf(columnKey), - }, - }); - - 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 { ...args, numColumnsBeforeData, numColumnsAfterData, numRenderedColumns, + expansionDerivedState, + activeItemDerivedState, propHelpers: { toolbarProps, - toolbarBulkSelectorProps, - filterToolbarProps, - paginationProps, - paginationToolbarItemProps, tableProps, getThProps, - getClickableTrProps, + getTrProps, getTdProps, + filterToolbarProps: propsForFilterToolbar, + paginationProps, + paginationToolbarItemProps, + toolbarBulkSelectorProps, getSelectCheckboxTdProps, - getCompoundExpandTdProps, - getSingleExpandTdProps, + getSingleExpandButtonTdProps, getExpandedContentTdProps, }, - expansionDerivedState, - activeRowDerivedState, }; }; 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 0000000000..830a933270 --- /dev/null +++ b/client/src/app/hooks/table-controls/useTableControlState.ts @@ -0,0 +1,77 @@ +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"; + +/** + * 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"), + }); + return { + ...args, + filterState, + sortState, + paginationState, + expansionState, + activeItemState, + }; +}; diff --git a/client/src/app/hooks/table-controls/useTableControlUrlParams.ts b/client/src/app/hooks/table-controls/useTableControlUrlParams.ts deleted file mode 100644 index 7f8db39a73..0000000000 --- a/client/src/app/hooks/table-controls/useTableControlUrlParams.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { IExtraArgsForURLParamHooks, ITableControlCommonArgs } from "./types"; -import { useFilterUrlParams } from "./filtering"; -import { useSortUrlParams } from "./sorting"; -import { usePaginationUrlParams } from "./pagination"; -import { useActiveRowUrlParams } from "./active-row"; -import { useExpansionUrlParams } from "./expansion"; - -export const useTableControlUrlParams = < - TItem, - TColumnKey extends string, - TSortableColumnKey extends TColumnKey, - TFilterCategoryKey extends string = string, - TURLParamKeyPrefix extends string = string, ->( - args: ITableControlCommonArgs< - TItem, - TColumnKey, - TSortableColumnKey, - TFilterCategoryKey - > & - IExtraArgsForURLParamHooks -) => { - // Must pass type params because they can't all be inferred from the required args of useFilterUrlParams - const filterState = useFilterUrlParams< - TFilterCategoryKey, // Must pass this because no required args here have categories to infer from - TURLParamKeyPrefix - >(args); - const sortState = useSortUrlParams(args); // Type params inferred from args - const paginationState = usePaginationUrlParams(args); // Type params inferred from args - // Must pass type params because they can't all be inferred from the required args of useExpansionUrlParams - const expansionState = useExpansionUrlParams( - args - ); - const activeRowState = useActiveRowUrlParams(args); // Type params inferred from args - return { - ...args, - filterState, - sortState, - paginationState, - expansionState, - activeRowState, - }; -}; diff --git a/client/src/app/hooks/table-controls/utils.ts b/client/src/app/hooks/table-controls/utils.ts index 60b625f506..3f1da5994d 100644 --- a/client/src/app/hooks/table-controls/utils.ts +++ b/client/src/app/hooks/table-controls/utils.ts @@ -1,13 +1,18 @@ 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 + E extends React.KeyboardEvent | React.MouseEvent, >( event: E | undefined, onRowClick: (event: E) => void ) => { - // Check if there is a clickable element between the event target and the row such as a - // checkbox, button or link. Don't trigger the row click if those are clicked. // 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 => { diff --git a/client/src/app/hooks/useLegacyFilterState.ts b/client/src/app/hooks/useLegacyFilterState.ts index b9e48a4dd0..6224418f4a 100644 --- a/client/src/app/hooks/useLegacyFilterState.ts +++ b/client/src/app/hooks/useLegacyFilterState.ts @@ -4,23 +4,33 @@ import { useFilterState, } from "./table-controls/filtering"; -// NOTE: This was refactored to return generic state data and decouple the client-side-filtering piece to another helper function. -// See useFilterState for the new version, which should probably be used instead of this everywhere eventually. -// See useLocalFilterDerivedState and getFilterProps for the pieces that were removed here. - +/** + * @deprecated The return value of useLegacyFilterState which predates table-controls/table-batteries and is deprecated. + * @see useLegacyFilterState + */ export interface IFilterStateHook { filterValues: IFilterValues; setFilterValues: (values: IFilterValues) => void; filteredItems: TItem[]; } +/** + * @deprecated This hook predates table-controls/table-batteries and still hasn't been refactored away from all of our tables. + * This deprecated hook now depends on the table-controls version but wraps it with an API compatible with the legacy usage. + * It was refactored to return generic state data and decouple the client-side-filtering piece to another helper function. + * See useFilterState in table-controls for the new version, which should probably be used instead of this everywhere eventually. + * See getLocalFilterDerivedState and useFilterPropHelpers for the pieces that were removed here. + * @see useFilterState + * @see getLocalFilterDerivedState + * @see getFilterProps + */ export const useLegacyFilterState = ( items: TItem[], - filterCategories: FilterCategory[], - filterStorageKey?: string + filterCategories: FilterCategory[] ): IFilterStateHook => { const { filterValues, setFilterValues } = useFilterState({ - filterStorageKey, + isFilterEnabled: true, + filterCategories, }); const { filteredItems } = getLocalFilterDerivedState({ items, diff --git a/client/src/app/hooks/useLegacyPaginationState.ts b/client/src/app/hooks/useLegacyPaginationState.ts index d09d02c414..90b01d648d 100644 --- a/client/src/app/hooks/useLegacyPaginationState.ts +++ b/client/src/app/hooks/useLegacyPaginationState.ts @@ -1,37 +1,54 @@ import { PaginationProps } from "@patternfly/react-core"; import { getLocalPaginationDerivedState, - getPaginationProps, usePaginationState, usePaginationEffects, + usePaginationPropHelpers, } from "./table-controls"; -// NOTE: This was refactored to return generic state data and decouple the client-side-pagination piece to another helper function. -// See usePaginationState for the new version, which should probably be used instead of this everywhere eventually. -// See useLocalPaginationDerivedState and getPaginationProps for the pieces that were removed here. - +/** + * @deprecated Args for useLegacyPaginationState which predates table-controls/table-batteries and is deprecated. + * @see useLegacyPaginationState + */ export type PaginationStateProps = Pick< PaginationProps, "itemCount" | "perPage" | "page" | "onSetPage" | "onPerPageSelect" >; +/** + * @deprecated The return value of useLegacyPaginationState which predates table-controls/table-batteries and is deprecated. + * @see useLegacyPaginationState + */ export interface ILegacyPaginationStateHook { currentPageItems: T[]; setPageNumber: (pageNumber: number) => void; paginationProps: PaginationStateProps; } +/** + * @deprecated This hook predates table-controls/table-batteries and still hasn't been refactored away from all of our tables. + * This deprecated hook now depends on the table-controls version but wraps it with an API compatible with the legacy usage. + * It was refactored to return generic state data and decouple the client-side-pagination piece to another helper function. + * See usePaginationState for the new version, which should probably be used instead of this everywhere eventually. + * See getLocalPaginationDerivedState and usePaginationPropHelpers for the pieces that were removed here. + * @see usePaginationState + * @see getLocalPaginationDerivedState + * @see getPaginationProps + */ export const useLegacyPaginationState = ( items: T[], initialItemsPerPage: number ): ILegacyPaginationStateHook => { - const paginationState = usePaginationState({ initialItemsPerPage }); + const paginationState = usePaginationState({ + isPaginationEnabled: true, + initialItemsPerPage, + }); usePaginationEffects({ paginationState, totalItemCount: items.length }); const { currentPageItems } = getLocalPaginationDerivedState({ items, paginationState, }); - const paginationProps = getPaginationProps({ + const { paginationProps } = usePaginationPropHelpers({ totalItemCount: items.length, paginationState, }); diff --git a/client/src/app/hooks/useLegacySortState.ts b/client/src/app/hooks/useLegacySortState.ts index 34909a975f..31a4c8773a 100644 --- a/client/src/app/hooks/useLegacySortState.ts +++ b/client/src/app/hooks/useLegacySortState.ts @@ -2,15 +2,10 @@ import * as React from "react"; import { ISortBy, SortByDirection } from "@patternfly/react-table"; import i18n from "@app/i18n"; -// NOTE: This was refactored to expose an API based on columnKey instead of column index, -// and to return generic state data and decouple the client-side-sorting piece to another helper function. -// See useSortState for the new version, which should probably be used instead of this everywhere eventually. -// See useLocalSortDerivedState and getSortProps for the parts that were removed here. - -// NOTE ALSO: useLegacyFilterState and useLegacyPagination state were able to have their logic factored out -// to reuse the new helpers, but useLegacySortState has to retain its incompatible logic because of -// the switch from using column indexes to columnKeys. - +/** + * @deprecated The return value of useLegacySortState which predates table-controls/table-batteries and is deprecated. + * @see useLegacySortState + */ export interface ILegacySortStateHook { sortBy: ISortBy; onSort: ( @@ -21,6 +16,18 @@ export interface ILegacySortStateHook { sortedItems: T[]; } +/** + * @deprecated This hook predates table-controls/table-batteries and still hasn't been refactored away from all of our tables. + * It was refactored to expose an API based on columnKey instead of column index, and to return generic state data and decouple + * the client-side-sorting piece to another helper function. + * See useSortState in table-controls for the new version, which should probably be used instead of this everywhere eventually. + * NOTE ALSO: useLegacyFilterState and useLegacyPagination state were able to have their logic factored out + * to reuse the new helpers, but useLegacySortState has to retain its incompatible logic because of + * the switch from using column indexes to `columnKey`s. + * @see useSortState + * @see getLocalSortDerivedState + * @see getSortProps + */ export const useLegacySortState = ( items: T[], getSortValues?: (item: T) => (string | number | boolean)[], diff --git a/client/src/app/hooks/usePersistentState.ts b/client/src/app/hooks/usePersistentState.ts new file mode 100644 index 0000000000..ccd50c452a --- /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 "@migtools/lib-ui"; +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 index d0d9354685..18a9d44e90 100644 --- a/client/src/app/hooks/useUrlParams.ts +++ b/client/src/app/hooks/useUrlParams.ts @@ -16,16 +16,17 @@ import { useLocation, useHistory } from "react-router-dom"; // 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. -type TSerializedParams = Partial< +export type TSerializedParams = Partial< Record >; export interface IUseUrlParamsArgs< + TDeserializedParams, + TPersistenceKeyPrefix extends string, TURLParamKey extends string, - TKeyPrefix extends string, - TDeserializedParams > { - keyPrefix?: DisallowCharacters; + isEnabled?: boolean; + persistenceKeyPrefix?: DisallowCharacters; keys: DisallowCharacters[]; defaultValue: TDeserializedParams; serialize: ( @@ -36,37 +37,38 @@ export interface IUseUrlParamsArgs< ) => TDeserializedParams; } -export type TURLParamStateTuple = readonly [ +export type TURLParamStateTuple = [ TDeserializedParams, - (newParams: Partial) => void + (newParams: Partial) => void, ]; export const useUrlParams = < - TURLParamKey extends string, + TDeserializedParams, TKeyPrefix extends string, - TDeserializedParams + TURLParamKey extends string, >({ - keyPrefix, + isEnabled = true, + persistenceKeyPrefix, keys, defaultValue, serialize, deserialize, }: IUseUrlParamsArgs< - TURLParamKey, + TDeserializedParams, TKeyPrefix, - TDeserializedParams + TURLParamKey >): TURLParamStateTuple => { type TPrefixedURLParamKey = TURLParamKey | `${TKeyPrefix}:${TURLParamKey}`; const history = useHistory(); const withPrefix = (key: TURLParamKey): TPrefixedURLParamKey => - keyPrefix ? `${keyPrefix}:${key}` : key; + persistenceKeyPrefix ? `${persistenceKeyPrefix}:${key}` : key; const withPrefixes = ( serializedParams: TSerializedParams ): TSerializedParams => - keyPrefix + persistenceKeyPrefix ? objectKeys(serializedParams).reduce( (obj, key) => ({ ...obj, @@ -96,18 +98,25 @@ export const useUrlParams = < // 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. - const serializedParams = keys.reduce( - (obj, key) => ({ - ...obj, - [key]: urlParams.get(withPrefix(key)), - }), - {} as TSerializedParams - ); - const allParamsEmpty = keys.every((key) => !serializedParams[key]); - const params = allParamsEmpty ? defaultValue : deserialize(serializedParams); + + 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]; diff --git a/client/src/app/layout/SidebarApp/SidebarApp.tsx b/client/src/app/layout/SidebarApp/SidebarApp.tsx index 6388cd4b61..8c660a3045 100644 --- a/client/src/app/layout/SidebarApp/SidebarApp.tsx +++ b/client/src/app/layout/SidebarApp/SidebarApp.tsx @@ -70,7 +70,10 @@ export const SidebarApp: React.FC = () => { ]; const [selectedPersona, setSelectedPersona] = - useLocalStorage(LocalStorageKey.selectedPersona, null); + useLocalStorage({ + key: LocalStorageKey.selectedPersona, + defaultValue: null, + }); useEffect(() => { if (!selectedPersona) { diff --git a/client/src/app/pages/applications/applications-table-analyze/applications-table-analyze.tsx b/client/src/app/pages/applications/applications-table-analyze/applications-table-analyze.tsx index ab51251729..49a6685c59 100644 --- a/client/src/app/pages/applications/applications-table-analyze/applications-table-analyze.tsx +++ b/client/src/app/pages/applications/applications-table-analyze/applications-table-analyze.tsx @@ -136,7 +136,7 @@ export const ApplicationsTableAnalyze: React.FC = () => { }), variant: "success", }); - clearActiveRow(); + clearActiveItem(); setApplicationsToDelete([]); }; @@ -196,6 +196,11 @@ export const ApplicationsTableAnalyze: React.FC = () => { analysis: "Analysis", tags: "Tags", }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + isSelectionEnabled: true, + isActiveItemEnabled: true, sortableColumns: ["name", "description", "businessService", "tags"], initialSort: { columnKey: "name", direction: "asc" }, getSortValues: (app) => ({ @@ -324,7 +329,6 @@ export const ApplicationsTableAnalyze: React.FC = () => { ], initialItemsPerPage: 10, hasActionsColumn: true, - isSelectable: true, }); const { @@ -337,11 +341,11 @@ export const ApplicationsTableAnalyze: React.FC = () => { paginationProps, tableProps, getThProps, + getTrProps, getTdProps, toolbarBulkSelectorProps, - getClickableTrProps, }, - activeRowDerivedState: { activeRowItem, clearActiveRow }, + activeItemDerivedState: { activeItem, clearActiveItem }, selectionState: { selectedItems: selectedRows }, } = tableControls; @@ -587,10 +591,7 @@ export const ApplicationsTableAnalyze: React.FC = () => { > {currentPageItems.map((application, rowIndex) => ( - + { paginationProps={paginationProps} /> { }), variant: "success", }); - clearActiveRow(); + clearActiveItem(); setApplicationsToDelete([]); }; @@ -243,6 +243,10 @@ export const ApplicationsTable: React.FC = () => { review: "Review", tags: "Tags", }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + isActiveItemEnabled: true, sortableColumns: ["name", "description", "businessService", "tags"], initialSort: { columnKey: "name", direction: "asc" }, getSortValues: (app) => ({ @@ -371,7 +375,7 @@ export const ApplicationsTable: React.FC = () => { ], initialItemsPerPage: 10, hasActionsColumn: true, - isSelectable: true, + isSelectionEnabled: true, }); const queryClient = useQueryClient(); @@ -386,11 +390,11 @@ export const ApplicationsTable: React.FC = () => { paginationProps, tableProps, getThProps, + getTrProps, getTdProps, toolbarBulkSelectorProps, - getClickableTrProps, }, - activeRowDerivedState: { activeRowItem, clearActiveRow }, + activeItemDerivedState: { activeItem, clearActiveItem }, selectionState: { selectedItems: selectedRows }, } = tableControls; @@ -617,7 +621,7 @@ export const ApplicationsTable: React.FC = () => { return ( { paginationProps={paginationProps} /> { onConfirm={() => { history.push( formatPath(Paths.applicationAssessmentActions, { - applicationId: activeRowItem?.id, + applicationId: activeItem?.id, }) ); setArchetypeRefsToOverride(null); diff --git a/client/src/app/pages/archetypes/archetypes-page.tsx b/client/src/app/pages/archetypes/archetypes-page.tsx index 94a14b9541..0f78ade644 100644 --- a/client/src/app/pages/archetypes/archetypes-page.tsx +++ b/client/src/app/pages/archetypes/archetypes-page.tsx @@ -38,7 +38,7 @@ import { TableHeaderContentWithControls, TableRowContentWithControls, } from "@app/components/TableControls"; -import { useLocalTableControlsWithUrlParams } from "@app/hooks/table-controls"; +import { useLocalTableControls } from "@app/hooks/table-controls"; import { useDeleteArchetypeMutation, useFetchArchetypes, @@ -56,7 +56,7 @@ import { formatPath, getAxiosErrorMessage } from "@app/utils/utils"; import { AxiosError } from "axios"; import { Paths } from "@app/Paths"; import { SimplePagination } from "@app/components/SimplePagination"; -import { TableURLParamKeyPrefix } from "@app/Constants"; +import { TablePersistenceKeyPrefix } from "@app/Constants"; const Archetypes: React.FC = () => { const { t } = useTranslation(); @@ -98,8 +98,9 @@ const Archetypes: React.FC = () => { onError ); - const tableControls = useLocalTableControlsWithUrlParams({ - urlParamKeyPrefix: TableURLParamKeyPrefix.archetypes, + const tableControls = useLocalTableControls({ + persistTo: "urlParams", + persistenceKeyPrefix: TablePersistenceKeyPrefix.archetypes, idProperty: "id", items: archetypes, isLoading: isFetching, @@ -113,6 +114,11 @@ const Archetypes: React.FC = () => { applications: t("terms.applications"), }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + isActiveItemEnabled: true, + filterCategories: [ { key: "name", @@ -134,8 +140,6 @@ const Archetypes: React.FC = () => { name: archetype.name ?? "", }), initialSort: { columnKey: "name", direction: "asc" }, - - hasPagination: true, }); const { currentPageItems, @@ -146,11 +150,11 @@ const Archetypes: React.FC = () => { paginationToolbarItemProps, paginationProps, tableProps, - getClickableTrProps, getThProps, + getTrProps, getTdProps, }, - activeRowDerivedState: { activeRowItem, clearActiveRow }, + activeItemDerivedState: { activeItem, clearActiveItem }, } = tableControls; // TODO: RBAC access checks need to be added. Only Architect (and Administrator) personas @@ -272,10 +276,7 @@ const Archetypes: React.FC = () => { > {currentPageItems?.map((archetype, rowIndex) => ( - + { {/* Create modal */} diff --git a/client/src/app/pages/assessment-management/assessment-settings/assessment-settings-page.tsx b/client/src/app/pages/assessment-management/assessment-settings/assessment-settings-page.tsx index 2ceb25cdac..9734e319ad 100644 --- a/client/src/app/pages/assessment-management/assessment-settings/assessment-settings-page.tsx +++ b/client/src/app/pages/assessment-management/assessment-settings/assessment-settings-page.tsx @@ -113,8 +113,9 @@ const AssessmentSettings: React.FC = () => { rating: "Rating", createTime: "Date imported", }, - isSelectable: false, - expandableVariant: null, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, hasActionsColumn: true, filterCategories: [ { @@ -136,7 +137,6 @@ const AssessmentSettings: React.FC = () => { createTime: questionnaire.createTime || "", }), initialSort: { columnKey: "name", direction: "asc" }, - hasPagination: true, isLoading: isFetching, }); const { @@ -149,6 +149,7 @@ const AssessmentSettings: React.FC = () => { paginationProps, tableProps, getThProps, + getTrProps, getTdProps, }, } = tableControls; @@ -264,7 +265,7 @@ const AssessmentSettings: React.FC = () => { return ( - + = ({ columnNames: { questionnaires: tableName, }, - hasActionsColumn: false, - hasPagination: false, variant: "compact", }); const { currentPageItems, numRenderedColumns, - propHelpers: { tableProps, getThProps, getTdProps }, + propHelpers: { tableProps, getThProps, getTrProps, getTdProps }, } = tableControls; return ( <> @@ -88,7 +86,10 @@ const QuestionnairesTable: React.FC = ({ ); return ( - + { jobFunction: "Job function", groupCount: "Group count", }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + isExpansionEnabled: true, expandableVariant: "single", hasActionsColumn: true, filterCategories: [ @@ -165,7 +169,6 @@ export const Stakeholders: React.FC = () => { jobFunction: item.jobFunction?.name || "", }), initialSort: { columnKey: "name", direction: "asc" }, - hasPagination: true, isLoading: isFetching, }); @@ -179,6 +182,7 @@ export const Stakeholders: React.FC = () => { paginationProps, tableProps, getThProps, + getTrProps, getTdProps, getExpandedContentTdProps, }, @@ -225,7 +229,7 @@ export const Stakeholders: React.FC = () => { - +
@@ -263,7 +267,7 @@ export const Stakeholders: React.FC = () => { key={stakeholder.id} isExpanded={isCellExpanded(stakeholder)} > - + { const allAffectedApplicationsFilterCategories = useSharedAffectedApplicationFilterCategories(); - const tableControlState = useTableControlUrlParams({ + const tableControlState = useTableControlState({ + persistTo: "urlParams", + persistenceKeyPrefix: TablePersistenceKeyPrefix.dependencies, columnNames: { name: "Dependency name", foundIn: "Found in", @@ -46,6 +49,10 @@ export const Dependencies: React.FC = () => { sha: "SHA", version: "Version", }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + isActiveItemEnabled: true, sortableColumns: ["name", "foundIn", "labels"], initialSort: { columnKey: "name", direction: "asc" }, filterCategories: [ @@ -134,10 +141,10 @@ export const Dependencies: React.FC = () => { paginationProps, tableProps, getThProps, + getTrProps, getTdProps, - getClickableTrProps, }, - activeRowDerivedState: { activeRowItem, clearActiveRow, setActiveRowItem }, + activeItemDerivedState: { activeItem, clearActiveItem, setActiveItem }, } = tableControls; return ( @@ -187,7 +194,7 @@ export const Dependencies: React.FC = () => { {currentPageItems?.map((dependency, rowIndex) => { return ( - + { className={spacing.pl_0} variant="link" onClick={(_) => { - if ( - activeRowItem && - activeRowItem === dependency - ) { - clearActiveRow(); + if (activeItem && activeItem === dependency) { + clearActiveItem(); } else { - setActiveRowItem(dependency); + setActiveItem(dependency); } }} > @@ -254,8 +258,8 @@ export const Dependencies: React.FC = () => { setActiveRowItem(null)} + dependency={activeItem || null} + onCloseClick={() => setActiveItem(null)} > ); diff --git a/client/src/app/pages/dependencies/dependency-apps-table.tsx b/client/src/app/pages/dependencies/dependency-apps-table.tsx index 83989e9207..9c2376a738 100644 --- a/client/src/app/pages/dependencies/dependency-apps-table.tsx +++ b/client/src/app/pages/dependencies/dependency-apps-table.tsx @@ -6,11 +6,11 @@ import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; import { useSelectionState } from "@migtools/lib-ui"; import { AnalysisDependency } from "@app/api/models"; import { - getHubRequestParams, + useTableControlState, useTableControlProps, - useTableControlUrlParams, + getHubRequestParams, } from "@app/hooks/table-controls"; -import { TableURLParamKeyPrefix } from "@app/Constants"; +import { TablePersistenceKeyPrefix } from "@app/Constants"; import { ConditionalTableBody, TableHeaderContentWithControls, @@ -33,14 +33,18 @@ export const DependencyAppsTable: React.FC = ({ const { businessServices } = useFetchBusinessServices(); const { tags } = useFetchTags(); - const tableControlState = useTableControlUrlParams({ - urlParamKeyPrefix: TableURLParamKeyPrefix.dependencyApplications, + const tableControlState = useTableControlState({ + persistTo: "urlParams", + persistenceKeyPrefix: TablePersistenceKeyPrefix.dependencyApplications, columnNames: { name: "Application", version: "Version", // management (3rd party or not boolean... parsed from labels) relationship: "Relationship", }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, sortableColumns: ["name", "version"], initialSort: { columnKey: "name", direction: "asc" }, filterCategories: [ @@ -123,6 +127,7 @@ export const DependencyAppsTable: React.FC = ({ paginationProps, tableProps, getThProps, + getTrProps, getTdProps, }, } = tableControls; @@ -167,7 +172,10 @@ export const DependencyAppsTable: React.FC = ({ > {currentPageAppDependencies?.map((appDependency, rowIndex) => ( - + { kind: `${t("terms.instance")} type`, connection: "Connection", }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, filterCategories: [ { key: "name", @@ -131,7 +134,6 @@ export const JiraTrackers: React.FC = () => { url: tracker.url || "", }), sortableColumns: ["name", "url"], - hasPagination: true, isLoading: isFetching, }); const { @@ -144,6 +146,7 @@ export const JiraTrackers: React.FC = () => { paginationProps, tableProps, getThProps, + getTrProps, getTdProps, }, } = tableControls; @@ -240,7 +243,7 @@ export const JiraTrackers: React.FC = () => { > {currentPageItems?.map((tracker, rowIndex) => ( - + { new URLSearchParams(useLocation().search).get("issueTitle") || "Active rule"; - const tableControlState = useTableControlUrlParams({ - urlParamKeyPrefix: TableURLParamKeyPrefix.issuesAffectedApps, + const tableControlState = useTableControlState({ + persistTo: "urlParams", + persistenceKeyPrefix: TablePersistenceKeyPrefix.issuesAffectedApps, columnNames: { name: "Name", description: "Description", @@ -61,6 +62,10 @@ export const AffectedApplications: React.FC = () => { effort: "Effort", incidents: "Incidents", }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + isActiveItemEnabled: true, sortableColumns: ["name", "businessService", "effort", "incidents"], initialSort: { columnKey: "name", direction: "asc" }, filterCategories: useSharedAffectedApplicationFilterCategories(), @@ -118,10 +123,10 @@ export const AffectedApplications: React.FC = () => { paginationProps, tableProps, getThProps, + getTrProps, getTdProps, - getClickableTrProps, }, - activeRowDerivedState: { activeRowItem, clearActiveRow }, + activeItemDerivedState: { activeItem, clearActiveItem }, } = tableControls; return ( @@ -188,10 +193,7 @@ export const AffectedApplications: React.FC = () => { > {currentPageAppReports?.map((appReport, rowIndex) => ( - + { ); diff --git a/client/src/app/pages/issues/helpers.ts b/client/src/app/pages/issues/helpers.ts index 23ae1b64aa..6cec0692c2 100644 --- a/client/src/app/pages/issues/helpers.ts +++ b/client/src/app/pages/issues/helpers.ts @@ -15,7 +15,7 @@ import { } from "@app/hooks/table-controls"; import { trimAndStringifyUrlParams } from "@app/hooks/useUrlParams"; import { Paths } from "@app/Paths"; -import { TableURLParamKeyPrefix } from "@app/Constants"; +import { TablePersistenceKeyPrefix } from "@app/Constants"; import { IssueFilterGroups } from "./issues"; import { useFetchBusinessServices } from "@app/queries/businessservices"; import { useFetchTags } from "@app/queries/tags"; @@ -116,7 +116,7 @@ export const getAffectedAppsUrl = ({ .replace("/:ruleset/", `/${encodeURIComponent(ruleReport.ruleset)}/`) .replace("/:rule/", `/${encodeURIComponent(ruleReport.rule)}/`); const prefix = (key: string) => - `${TableURLParamKeyPrefix.issuesAffectedApps}:${key}`; + `${TablePersistenceKeyPrefix.issuesAffectedApps}:${key}`; return `${baseUrl}?${trimAndStringifyUrlParams({ newPrefixedSerializedParams: { @@ -143,7 +143,7 @@ export const getBackToAllIssuesUrl = ({ new URLSearchParams(fromIssuesParams) ); // Pull the filters param out of that - const prefix = (key: string) => `${TableURLParamKeyPrefix.issues}:${key}`; + const prefix = (key: string) => `${TablePersistenceKeyPrefix.issues}:${key}`; const filterValuesToRestore = deserializeFilterUrlParams({ filters: prefixedParamsToRestore[prefix("filters")], }); @@ -183,7 +183,7 @@ export const getIssuesSingleAppSelectedLocation = ( const existingFiltersParam = fromLocation && new URLSearchParams(fromLocation.search).get( - `${TableURLParamKeyPrefix.issues}:filters` + `${TablePersistenceKeyPrefix.issues}:filters` ); return { pathname: Paths.issuesSingleAppSelected.replace( diff --git a/client/src/app/pages/issues/issue-detail-drawer/file-incidents-detail-modal/file-all-incidents-table.tsx b/client/src/app/pages/issues/issue-detail-drawer/file-incidents-detail-modal/file-all-incidents-table.tsx index 5a83401bb6..07638c4d30 100644 --- a/client/src/app/pages/issues/issue-detail-drawer/file-incidents-detail-modal/file-all-incidents-table.tsx +++ b/client/src/app/pages/issues/issue-detail-drawer/file-incidents-detail-modal/file-all-incidents-table.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; import { useSelectionState } from "@migtools/lib-ui"; -import { TableURLParamKeyPrefix } from "@app/Constants"; +import { TablePersistenceKeyPrefix } from "@app/Constants"; import { AnalysisFileReport } from "@app/api/models"; import { useFetchIncidents } from "@app/queries/issues"; import { SimplePagination } from "@app/components/SimplePagination"; @@ -11,9 +11,9 @@ import { TableRowContentWithControls, } from "@app/components/TableControls"; import { - getHubRequestParams, + useTableControlState, useTableControlProps, - useTableControlUrlParams, + getHubRequestParams, } from "@app/hooks/table-controls"; import ReactMarkdown from "react-markdown"; import { markdownPFComponents } from "@app/components/markdownPFComponents"; @@ -25,16 +25,18 @@ export interface IFileRemainingIncidentsTableProps { export const FileAllIncidentsTable: React.FC< IFileRemainingIncidentsTableProps > = ({ fileReport }) => { - const tableControlState = useTableControlUrlParams({ - urlParamKeyPrefix: TableURLParamKeyPrefix.issuesRemainingIncidents, + const tableControlState = useTableControlState({ + persistTo: "urlParams", + persistenceKeyPrefix: TablePersistenceKeyPrefix.issuesRemainingIncidents, columnNames: { line: "Line #", message: "Message", }, + isSortEnabled: true, + isPaginationEnabled: true, sortableColumns: ["line", "message"], initialSort: { columnKey: "line", direction: "asc" }, initialItemsPerPage: 10, - variant: "compact", }); const { @@ -59,6 +61,7 @@ export const FileAllIncidentsTable: React.FC< forceNumRenderedColumns: 3, totalItemCount, isLoading: isFetching, + variant: "compact", // TODO FIXME - we don't need selectionState but it's required by this hook? selectionState: useSelectionState({ items: currentPageIncidents, @@ -68,7 +71,13 @@ export const FileAllIncidentsTable: React.FC< const { numRenderedColumns, - propHelpers: { paginationProps, tableProps, getThProps, getTdProps }, + propHelpers: { + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, + }, } = tableControls; return ( @@ -98,7 +107,7 @@ export const FileAllIncidentsTable: React.FC< > {currentPageIncidents?.map((incident, rowIndex) => ( - + = ({ issue }) => { const { t } = useTranslation(); - const tableControlState = useTableControlUrlParams({ - urlParamKeyPrefix: TableURLParamKeyPrefix.issuesAffectedFiles, + const tableControlState = useTableControlState({ + persistTo: "urlParams", + persistenceKeyPrefix: TablePersistenceKeyPrefix.issuesAffectedFiles, columnNames: { file: "File", incidents: "Incidents", effort: "Effort", }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, sortableColumns: ["file", "incidents", "effort"], initialSort: { columnKey: "file", direction: "asc" }, filterCategories: [ @@ -61,7 +61,6 @@ export const IssueAffectedFilesTable: React.FC< }, ], initialItemsPerPage: 10, - variant: "compact", }); const { @@ -86,6 +85,7 @@ export const IssueAffectedFilesTable: React.FC< currentPageItems: currentPageFileReports, totalItemCount, isLoading: isFetching, + variant: "compact", // TODO FIXME - we don't need selectionState but it's required by this hook? selectionState: useSelectionState({ items: currentPageFileReports, @@ -102,6 +102,7 @@ export const IssueAffectedFilesTable: React.FC< paginationProps, tableProps, getThProps, + getTrProps, getTdProps, }, } = tableControls; @@ -145,7 +146,7 @@ export const IssueAffectedFilesTable: React.FC< > {currentPageFileReports?.map((fileReport, rowIndex) => ( - + = ({ mode }) => { const allIssuesSpecificFilterCategories = useSharedAffectedApplicationFilterCategories(); - const tableControlState = useTableControlUrlParams({ - urlParamKeyPrefix: TableURLParamKeyPrefix.issues, + const tableControlState = useTableControlState({ + persistTo: "urlParams", + persistenceKeyPrefix: TablePersistenceKeyPrefix.issues, columnNames: { description: "Issue", category: "Category", @@ -106,6 +107,10 @@ export const IssuesTable: React.FC = ({ mode }) => { affected: mode === "singleApp" ? "Affected files" : "Affected applications", }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + isExpansionEnabled: true, sortableColumns: ["description", "category", "effort", "affected"], initialSort: { columnKey: "description", direction: "asc" }, filterCategories: [ @@ -164,6 +169,7 @@ export const IssuesTable: React.FC = ({ mode }) => { // }, ], initialItemsPerPage: 10, + expandableVariant: "single", }); const hubRequestParams = getHubRequestParams({ @@ -214,7 +220,6 @@ export const IssuesTable: React.FC = ({ mode }) => { currentPageItems: currentPageReports, totalItemCount: totalReportCount, isLoading, - expandableVariant: "single", // TODO FIXME - we don't need selectionState but it's required by this hook? selectionState: useSelectionState({ items: currentPageReports, @@ -236,6 +241,7 @@ export const IssuesTable: React.FC = ({ mode }) => { paginationProps, tableProps, getThProps, + getTrProps, getTdProps, getExpandedContentTdProps, }, @@ -303,7 +309,7 @@ export const IssuesTable: React.FC = ({ mode }) => { -
+
@@ -344,7 +350,7 @@ export const IssuesTable: React.FC = ({ mode }) => { key={report._ui_unique_id} isExpanded={isCellExpanded(report)} > - + { activeKey={activeTabPath} onSelect={(_event, tabPath) => { const pageHasFilters = new URLSearchParams(location.search).has( - `${TableURLParamKeyPrefix.issues}:filters` + `${TablePersistenceKeyPrefix.issues}:filters` ); if (pageHasFilters) { setNavConfirmPath(tabPath as IssuesTabPath); diff --git a/client/src/app/pages/migration-waves/components/manage-applications-form.tsx b/client/src/app/pages/migration-waves/components/manage-applications-form.tsx index a1a68587fc..e0fbedb8eb 100644 --- a/client/src/app/pages/migration-waves/components/manage-applications-form.tsx +++ b/client/src/app/pages/migration-waves/components/manage-applications-form.tsx @@ -102,14 +102,18 @@ export const ManageApplicationsForm: React.FC = ({ const tableControls = useLocalTableControls({ idProperty: "name", items: availableApplications, - initialSelected: assignedApplications, columnNames: { name: "Application Name", description: "Description", businessService: "Business service", owner: "Owner", }, - isSelectable: true, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + isExpansionEnabled: true, + isSelectionEnabled: true, + initialSelected: assignedApplications, expandableVariant: "compound", hasActionsColumn: true, filterCategories: [ @@ -169,7 +173,6 @@ export const ManageApplicationsForm: React.FC = ({ owner: application.owner?.name || "", }), initialSort: { columnKey: "name", direction: "asc" }, - hasPagination: true, }); const { currentPageItems, @@ -183,6 +186,7 @@ export const ManageApplicationsForm: React.FC = ({ paginationProps, tableProps, getThProps, + getTrProps, getTdProps, }, expansionDerivedState: { isCellExpanded }, @@ -269,7 +273,7 @@ export const ManageApplicationsForm: React.FC = ({ key={application.id} isExpanded={isCellExpanded(application)} > - + = ({ email: "Email", groups: "Stakeholder groups", }, + isSortEnabled: true, + isPaginationEnabled: true, hasActionsColumn: true, getSortValues: (stakeholder) => ({ name: stakeholder.name || "", @@ -35,7 +37,6 @@ export const WaveStakeholdersTable: React.FC = ({ email: stakeholder.email, }), sortableColumns: ["name", "jobFunction", "role", "email"], - hasPagination: true, variant: "compact", }); const { @@ -47,6 +48,7 @@ export const WaveStakeholdersTable: React.FC = ({ paginationProps, tableProps, getThProps, + getTrProps, getTdProps, }, } = tableControls; @@ -85,7 +87,7 @@ export const WaveStakeholdersTable: React.FC = ({ > {currentPageItems?.map((stakeholder, rowIndex) => ( - + = ({ businessService: "Business service", owner: "Owner", }, + isSortEnabled: true, + isPaginationEnabled: true, hasActionsColumn: true, getSortValues: (app) => ({ appName: app.name || "", @@ -42,7 +44,6 @@ export const WaveApplicationsTable: React.FC = ({ owner: app.owner?.name || "", }), sortableColumns: ["appName", "businessService", "owner"], - hasPagination: true, variant: "compact", }); const { @@ -54,6 +55,7 @@ export const WaveApplicationsTable: React.FC = ({ paginationProps, tableProps, getThProps, + getTrProps, getTdProps, }, } = tableControls; @@ -91,7 +93,7 @@ export const WaveApplicationsTable: React.FC = ({ > {currentPageItems?.map((app, rowIndex) => ( - + = ({ status: "Status", issue: "Issue", }, + isSortEnabled: true, + isPaginationEnabled: true, hasActionsColumn: true, getSortValues: (app) => ({ appName: app.name || "", @@ -60,7 +62,6 @@ export const WaveStatusTable: React.FC = ({ issue: "", }), sortableColumns: ["appName", "status", "issue"], - hasPagination: true, variant: "compact", }); const { @@ -72,6 +73,7 @@ export const WaveStatusTable: React.FC = ({ paginationProps, tableProps, getThProps, + getTrProps, getTdProps, }, } = tableControls; @@ -129,7 +131,7 @@ export const WaveStatusTable: React.FC = ({ > {currentPageItems?.map((app, rowIndex) => ( - + { stakeholders: "Stakeholders", status: "Status", }, - isSelectable: true, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + isExpansionEnabled: true, + isSelectionEnabled: true, expandableVariant: "compound", hasActionsColumn: true, filterCategories: [ @@ -212,7 +216,6 @@ export const MigrationWaves: React.FC = () => { endDate: migrationWave.endDate || "", }), initialSort: { columnKey: "startDate", direction: "asc" }, - hasPagination: true, isLoading: isFetching, }); const { @@ -227,9 +230,9 @@ export const MigrationWaves: React.FC = () => { paginationProps, tableProps, getThProps, + getTrProps, getTdProps, getExpandedContentTdProps, - getCompoundExpandTdProps, }, expansionDerivedState: { isCellExpanded }, } = tableControls; @@ -374,7 +377,7 @@ export const MigrationWaves: React.FC = () => { key={migrationWave.id} isExpanded={isCellExpanded(migrationWave)} > - + {
{migrationWave?.applications?.length.toString()} {migrationWave.allStakeholders.length} {migrationWave.applications.length ? migrationWave.status diff --git a/client/src/app/utils/type-utils.ts b/client/src/app/utils/type-utils.ts index 164c37fafd..5a3692492e 100644 --- a/client/src/app/utils/type-utils.ts +++ b/client/src/app/utils/type-utils.ts @@ -4,5 +4,9 @@ export type KeyWithValueType = { export type DisallowCharacters< T extends string, - TInvalidCharacter extends string + TInvalidCharacter extends string, > = T extends `${string}${TInvalidCharacter}${string}` ? never : T; + +export type DiscriminatedArgs = + | ({ [key in TBoolDiscriminatorKey]: true } & TArgs) + | { [key in TBoolDiscriminatorKey]?: false }; diff --git a/package-lock.json b/package-lock.json index 71f4825c64..6ed9531d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "@dnd-kit/sortable": "^7.0.2", "@hookform/resolvers": "^2.9.11", "@hot-loader/react-dom": "^17.0.2", - "@migtools/lib-ui": "^9.0.3", + "@migtools/lib-ui": "^10.0.1", "@patternfly/patternfly": "^5.0.2", "@patternfly/react-charts": "^7.1.0", "@patternfly/react-code-editor": "^5.1.0", @@ -1539,9 +1539,9 @@ "dev": true }, "node_modules/@migtools/lib-ui": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@migtools/lib-ui/-/lib-ui-9.0.3.tgz", - "integrity": "sha512-ncs1eos8pTIEM1CGyZOXMjlzWoUjBfkyt0lsRJ59gFEUobl0oV+JlKQGD0hwAhr+F8518ZH5zeMBwagC6px5Dg==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@migtools/lib-ui/-/lib-ui-10.0.1.tgz", + "integrity": "sha512-CiFw0gz8tssRzSvQ9TjH1kUTg/TIOnTfXqsv6Hn7Yd/0oNW5hxdQYYeIaznNDTj7D/moPzV7U4oKW9BKYu2KKw==", "dependencies": { "@tanstack/react-query": "^4.26.1", "fast-deep-equal": "^3.1.3",