diff --git a/package-lock.json b/package-lock.json index 4d10f7bd..cfe9d46f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@redhat-cloud-services/tsc-transform-imports": "^1.0.5", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", + "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.11", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", @@ -3562,6 +3563,19 @@ "react-dom": "^18.0.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", diff --git a/package.json b/package.json index 019db595..e11746b2 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@redhat-cloud-services/tsc-transform-imports": "^1.0.5", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", + "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.11", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", diff --git a/src/routes/components/data-toolbar/DataToolbar.scss b/src/routes/components/data-toolbar/DataToolbar.scss index ec5e496d..e4c008a0 100644 --- a/src/routes/components/data-toolbar/DataToolbar.scss +++ b/src/routes/components/data-toolbar/DataToolbar.scss @@ -3,7 +3,7 @@ // Workaround for https://github.com/patternfly/patternfly-react/issues/4477 // and https://github.com/patternfly/patternfly-react/issues/6371 .selectOverride { - &.pf-v5-c-select { + .pf-v5-c-menu-toggle { min-width: 250px; } } diff --git a/src/routes/components/perspective/Perspective.tsx b/src/routes/components/perspective/Perspective.tsx index 9a918c65..bac4876f 100644 --- a/src/routes/components/perspective/Perspective.tsx +++ b/src/routes/components/perspective/Perspective.tsx @@ -1,9 +1,9 @@ import type { MessageDescriptor } from '@formatjs/intl/src/types'; import { Title } from '@patternfly/react-core'; -import type { SelectOptionObject } from '@patternfly/react-core/deprecated'; -import { Select, SelectOption, SelectVariant } from '@patternfly/react-core/deprecated'; -import React, { useState } from 'react'; +import React from 'react'; import { useIntl } from 'react-intl'; +import type { SelectWrapperOption } from 'routes/components/selectWrapper'; +import { SelectWrapper } from 'routes/components/selectWrapper'; import { styles } from './Perspective.styles'; @@ -14,20 +14,13 @@ export interface PerspectiveOption { value: string; } -interface PerspectiveOptionExt extends SelectOptionObject { - description?: string; - isDisabled?: boolean; - toString(): string; // label - value?: string; -} - interface PerspectiveOwnProps { currentItem?: string; id?: string; isDisabled?: boolean; label?: string; minWidth?: number | string; - onSelected(value: string); + onSelect(event, selection: SelectWrapperOption); options?: PerspectiveOption[]; } @@ -39,14 +32,13 @@ const Perspective: React.FC = ({ isDisabled, label, minWidth, - onSelected, + onSelect, options, }) => { - const [isSelectOpen, setIsSelectOpen] = useState(false); const intl = useIntl(); - const getSelectOptions = (): PerspectiveOptionExt[] => { - const selectOptions: PerspectiveOptionExt[] = []; + const getSelectOptions = (): SelectWrapperOption[] => { + const selectOptions: SelectWrapperOption[] = []; options.map(option => { selectOptions.push({ @@ -61,39 +53,38 @@ const Perspective: React.FC = ({ const getSelect = () => { const selectOptions = getSelectOptions(); - const selection = selectOptions.find((option: PerspectiveOptionExt) => option.value === currentItem); + const selection = selectOptions.find(option => option.value === currentItem); return ( - + <> + TEST + + ); - }; - - const handleOnSelect = (selection: SelectOptionObject) => { - if (onSelected) { - onSelected((selection as PerspectiveOption).value); - } - setIsSelectOpen(false); - }; - - const handleOnToggle = isOpen => { - setIsSelectOpen(isOpen); + // }; return ( diff --git a/src/routes/components/selectWrapper/index.ts b/src/routes/components/selectWrapper/index.ts new file mode 100644 index 00000000..145b71fd --- /dev/null +++ b/src/routes/components/selectWrapper/index.ts @@ -0,0 +1 @@ +export { default as SelectWrapper, SelectWrapperOption } from './selectWrapper'; diff --git a/src/routes/components/selectWrapper/select.styles.ts b/src/routes/components/selectWrapper/select.styles.ts new file mode 100644 index 00000000..0adf3439 --- /dev/null +++ b/src/routes/components/selectWrapper/select.styles.ts @@ -0,0 +1,8 @@ +import global_spacer_sm from '@patternfly/react-tokens/dist/js/global_spacer_sm'; +import type React from 'react'; + +export const styles = { + badge: { + marginLeft: global_spacer_sm.var, + }, +} as { [className: string]: React.CSSProperties }; diff --git a/src/routes/components/selectWrapper/selectWrapper.scss b/src/routes/components/selectWrapper/selectWrapper.scss new file mode 100644 index 00000000..c89b9bbd --- /dev/null +++ b/src/routes/components/selectWrapper/selectWrapper.scss @@ -0,0 +1,11 @@ +@import url("~@patternfly/patternfly/base/patternfly-variables.css"); + +// Workaround for missing "position" property +.selectWrapper { + .pf-v5-c-menu-toggle { + max-width: unset; + } + .pf-v5-c-menu-toggle__text { + width: max-content; + } +} diff --git a/src/routes/components/selectWrapper/selectWrapper.test.tsx b/src/routes/components/selectWrapper/selectWrapper.test.tsx new file mode 100644 index 00000000..0f3833ee --- /dev/null +++ b/src/routes/components/selectWrapper/selectWrapper.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { SelectWrapper } from './index'; + +test('primary selector', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const handleOnSelect = jest.fn(); + const selectOptions = [ + { toString: () => 'CPU', value: 'cpu' }, + { toString: () => 'Memory', value: 'memory' }, + { toString: () => 'Storage', value: 'storage' }, + ]; + render( + handleOnSelect(select.value)} + placeholder={'Resources'} + selection={selectOptions[0]} + selectOptions={selectOptions} + /> + ); + expect(screen.queryAllByText('CPU').length).toBe(1); + expect(screen.queryAllByText('Memory').length).toBe(0); + const button = screen.getByRole('button'); + await user.click(button); + const options = screen.getAllByRole('option'); + expect(options.length).toBe(3); + await user.click(options[1]); + expect(handleOnSelect.mock.calls).toEqual([['memory']]); +}); diff --git a/src/routes/components/selectWrapper/selectWrapper.tsx b/src/routes/components/selectWrapper/selectWrapper.tsx new file mode 100644 index 00000000..70de8c2d --- /dev/null +++ b/src/routes/components/selectWrapper/selectWrapper.tsx @@ -0,0 +1,108 @@ +import './selectWrapper.scss'; + +import { Icon, MenuToggle, Select, SelectList, SelectOption } from '@patternfly/react-core'; +import React, { useState } from 'react'; + +export interface SelectWrapperOption { + description?: string; // Option description + compareTo?: (option: SelectWrapperOption) => boolean; + isDisabled?: boolean; + toString?: () => string; // Option label + value?: string; // Option value +} + +interface SelectWrapperOwnProps { + ariaLabel?: string; + className?: string; + id?: string; + isDisabled?: boolean; + onSelect?: (event, value: SelectWrapperOption) => void; + placeholder?: string; + position?: 'right' | 'left' | 'center' | 'start' | 'end'; + selection?: string | SelectWrapperOption; + selectOptions?: SelectWrapperOption[]; + toggleIcon?: React.ReactNode; +} + +type SelectWrapperProps = SelectWrapperOwnProps; + +const SelectWrapper: React.FC = ({ + ariaLabel, + className, + id, + isDisabled, + onSelect = () => {}, + placeholder = null, + position, + selection, + selectOptions, + toggleIcon, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const getSelectOption = (option, index) => { + const isSelected = option.value === (typeof selection === 'string' ? selection : selection?.value); + + return ( + + {option.toString()} + + ); + }; + + const getPlaceholder = () => { + const label = typeof selection === 'string' ? selection : selection?.toString(); + return label ? label : placeholder; + }; + + const handleOnSelect = (evt, value) => { + onSelect(evt, value); + setIsOpen(false); + }; + + const handleOnToggle = () => { + setIsOpen(!isOpen); + }; + + const toggle = toggleRef => ( + {toggleIcon}} + isDisabled={isDisabled} + isExpanded={isOpen} + onClick={handleOnToggle} + ref={toggleRef} + > + {getPlaceholder()} + + ); + + return ( +
+ +
+ ); +}; + +export default SelectWrapper; diff --git a/src/routes/details/Details.tsx b/src/routes/details/Details.tsx index 95571b1e..b0b9bf49 100644 --- a/src/routes/details/Details.tsx +++ b/src/routes/details/Details.tsx @@ -10,6 +10,7 @@ import { useIntl } from 'react-intl'; import { useLocation, useNavigate } from 'react-router-dom'; import { ExportModal } from 'routes/components/export'; import { PageHeading } from 'routes/components/page-heading'; +import type { SelectWrapperOption } from 'routes/components/selectWrapper'; import { getDateRangeType, getGroupByType, @@ -189,11 +190,11 @@ const Details: React.FC = () => { /> ); - const handleOnDateRangeSelected = value => { - setDateRange(value, () => { + const handleOnDateRangeSelect = (_evt, selection: SelectWrapperOption) => { + setDateRange(selection.value, () => { const newQuery = { ...JSON.parse(JSON.stringify(query)), - dateRange: value, + dateRange: selection.value, }; navigate(getRouteForQuery(newQuery, true), { replace: true }); }); @@ -217,14 +218,14 @@ const Details: React.FC = () => { navigate(getRouteForQuery(newQuery, true), { replace: true }); }; - const handleOnGroupBySelected = value => { + const handleOnGroupBySelect = (_evt, selection: SelectWrapperOption) => { handleOnFilterRemoved(null); // Clear all setSecondaryGroupBy(GroupByType.none); - setGroupBy(value, () => { + setGroupBy(selection.value, () => { const newQuery = { ...JSON.parse(JSON.stringify(query)), groupBy: { - [value]: '*', + [selection.value]: '*', }, secondaryGroupBy: undefined, filter_by: undefined, @@ -243,12 +244,12 @@ const Details: React.FC = () => { navigate(filteredQuery, { replace: true }); }; - const handleOnSecondaryGroupBySelected = value => { - setSecondaryGroupBy(value, () => { + const handleOnSecondaryGroupBySelect = (_evt, selection: SelectWrapperOption) => { + setSecondaryGroupBy(selection.value, () => { const newQuery = { ...JSON.parse(JSON.stringify(query)), secondaryGroupBy: { - [value]: '*', + [selection.value]: '*', }, }; navigate(getRouteForQuery(newQuery, true), { replace: true }); @@ -280,12 +281,12 @@ const Details: React.FC = () => { navigate(filteredQuery, { replace: true }); }; - const handleOnSourceOfSpendSelected = value => { + const handleOnSourceOfSpendSelect = (_evt, selection: SelectWrapperOption) => { setSecondaryGroupBy(GroupByType.none); - setSourceOfSpend(value, () => { + setSourceOfSpend(selection.value, () => { const newQuery = { ...JSON.parse(JSON.stringify(query)), - sourceOfSpend: value, + sourceOfSpend: selection.value, secondaryGroupBy: undefined, }; navigate(getRouteForQuery(newQuery, true), { replace: true }); @@ -315,10 +316,10 @@ const Details: React.FC = () => { dateRange={dateRange} endDate={endDate} groupBy={groupBy} - onDateRangeSelected={handleOnDateRangeSelected} - onGroupBySelected={handleOnGroupBySelected} - onSecondaryGroupBySelected={handleOnSecondaryGroupBySelected} - onSourceOfSpendSelected={handleOnSourceOfSpendSelected} + onDateRangeSelect={handleOnDateRangeSelect} + onGroupBySelect={handleOnGroupBySelect} + onSecondaryGroupBySelect={handleOnSecondaryGroupBySelect} + onSourceOfSpendSelect={handleOnSourceOfSpendSelect} previousContractLineEndDate={previousContractLineEndDate} previousContractLineStartDate={previousContractLineStartDate} secondaryGroupBy={secondaryGroupBy} diff --git a/src/routes/details/DetailsHeaderToolbar.tsx b/src/routes/details/DetailsHeaderToolbar.tsx index b1b5c134..6eec082d 100644 --- a/src/routes/details/DetailsHeaderToolbar.tsx +++ b/src/routes/details/DetailsHeaderToolbar.tsx @@ -14,6 +14,7 @@ import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; import { Perspective } from 'routes/components/perspective'; import type { PerspectiveOption } from 'routes/components/perspective/Perspective'; +import type { SelectWrapperOption } from 'routes/components/selectWrapper'; import { GroupByType, SourceOfSpendType } from 'routes/details/types'; import { DateRangeType, getDateRange } from 'routes/utils/dateRange'; import type { RootState } from 'store'; @@ -31,10 +32,10 @@ interface DetailsHeaderToolbarOwnProps { groupBy?: string; hasPreviousData?: boolean; isExportDisabled?: boolean; - onDateRangeSelected(value: string); - onGroupBySelected(value: string); - onSecondaryGroupBySelected(value: string); - onSourceOfSpendSelected(value: string); + onDateRangeSelect(event, selection: SelectWrapperOption); + onGroupBySelect(event, selection: SelectWrapperOption); + onSecondaryGroupBySelect(event, selection: SelectWrapperOption); + onSourceOfSpendSelect(event, selection: SelectWrapperOption); secondaryGroupBy?: string; sourceOfSpend?: string; startDate?: Date; @@ -70,10 +71,10 @@ const DetailsHeaderToolbar: React.FC = ({ dateRange, endDate, groupBy, - onDateRangeSelected, - onGroupBySelected, - onSecondaryGroupBySelected, - onSourceOfSpendSelected, + onDateRangeSelect, + onGroupBySelect, + onSecondaryGroupBySelect, + onSourceOfSpendSelect, previousContractLineEndDate, previousContractLineStartDate, secondaryGroupBy, @@ -247,30 +248,6 @@ const DetailsHeaderToolbar: React.FC = ({ }); }; - const handleOnDateRangeSelected = value => { - if (onDateRangeSelected) { - onDateRangeSelected(value); - } - }; - - const handleOnGroupBySelected = value => { - if (onGroupBySelected) { - onGroupBySelected(value); - } - }; - - const handleOnSecondaryGroupBySelected = value => { - if (onSecondaryGroupBySelected) { - onSecondaryGroupBySelected(value); - } - }; - - const handleOnSourceOfSpendSelected = value => { - if (onSourceOfSpendSelected) { - onSourceOfSpendSelected(value); - } - }; - return ( @@ -280,7 +257,7 @@ const DetailsHeaderToolbar: React.FC = ({ id="sourceOfSpendType" label={intl.formatMessage(messages.sourceOfSpendLabel)} minWidth={200} - onSelected={handleOnSourceOfSpendSelected} + onSelect={onSourceOfSpendSelect} options={getSourceOfSpendOptions()} /> @@ -290,7 +267,7 @@ const DetailsHeaderToolbar: React.FC = ({ id="groupBy" label={intl.formatMessage(messages.groupByLabel)} minWidth={200} - onSelected={handleOnGroupBySelected} + onSelect={onGroupBySelect} options={getGroupByOptions(false)} /> @@ -300,7 +277,7 @@ const DetailsHeaderToolbar: React.FC = ({ id="secondaryGroupBy" label={intl.formatMessage(messages.secondaryGroupByLabel)} minWidth={200} - onSelected={handleOnSecondaryGroupBySelected} + onSelect={onSecondaryGroupBySelect} options={getGroupByOptions().filter(option => option.value !== groupBy)} /> @@ -310,7 +287,7 @@ const DetailsHeaderToolbar: React.FC = ({ id="dateRange" label={intl.formatMessage(messages.dateRangeLabel)} minWidth={225} - onSelected={handleOnDateRangeSelected} + onSelect={onDateRangeSelect} options={getDateRangeOptions()} /> diff --git a/src/routes/overview/components/actual-spend-breakdown/ActualSpendBreakdown.tsx b/src/routes/overview/components/actual-spend-breakdown/ActualSpendBreakdown.tsx index 492a9699..2e0fbc7c 100644 --- a/src/routes/overview/components/actual-spend-breakdown/ActualSpendBreakdown.tsx +++ b/src/routes/overview/components/actual-spend-breakdown/ActualSpendBreakdown.tsx @@ -1,6 +1,7 @@ import messages from 'locales/messages'; import React, { useState } from 'react'; import { Perspective } from 'routes/components/perspective'; +import type { SelectWrapperOption } from 'routes/components/selectWrapper'; import { isHcsDataVisibilitySummaryOnly, useUserAccessMapToProps } from 'utils/userAccess'; import { ActualSpendBreakdownSummary } from './ActualSpendBreakdownSummary'; @@ -42,21 +43,36 @@ const ActualSpendBreakdownBase: React.FC = ({ widgetI const { userAccess } = useUserAccessMapToProps(); const getPerspective = () => { - return ( - - ); + return ; }; const getResolution = () => { - return ; + return ; }; - const handleOnPerspectiveSelected = value => { - setPerspective(value); + const handleOnPerspectiveSelect = (_evt, selection: SelectWrapperOption) => { + switch (selection.value) { + case PerspectiveType.affiliate: + setPerspective(PerspectiveType.affiliate); + break; + case PerspectiveType.product: + setPerspective(PerspectiveType.product); + break; + case PerspectiveType.sourceOfSpend: + setPerspective(PerspectiveType.sourceOfSpend); + break; + } }; - const handleOnResolutionSelected = value => { - setResolution(value); + const handleOnResolutionSelect = (_evt, selection: SelectWrapperOption) => { + switch (selection.value) { + case ResolutionType.cumulative: + setResolution(ResolutionType.cumulative); + break; + case ResolutionType.monthly: + setResolution(ResolutionType.monthly); + break; + } }; return ( diff --git a/src/routes/overview/components/committed-spend-trend/CommittedSpendTrend.tsx b/src/routes/overview/components/committed-spend-trend/CommittedSpendTrend.tsx index 0f096864..0b9b00c9 100644 --- a/src/routes/overview/components/committed-spend-trend/CommittedSpendTrend.tsx +++ b/src/routes/overview/components/committed-spend-trend/CommittedSpendTrend.tsx @@ -3,6 +3,7 @@ import { cloneDeep } from 'lodash'; import React, { useEffect, useState } from 'react'; import { Perspective } from 'routes/components/perspective'; import type { PerspectiveOption } from 'routes/components/perspective/Perspective'; +import type { SelectWrapperOption } from 'routes/components/selectWrapper'; import { useAccountSummaryMapToProps } from 'routes/utils/accountSummary'; import { CommittedSpendTrendSummary } from './CommittedSpendTrendSummary'; @@ -49,16 +50,19 @@ const CommittedSpendTrend: React.FC = ({ widgetId }) = const getPerspective = () => { return ( - + ); }; - const handleOnPerspectiveSelected = value => { - setPerspective(value); + const handleOnPerspectiveSelect = (_evt, selection: SelectWrapperOption) => { + switch (selection.value) { + case PerspectiveType.actual: + setPerspective(PerspectiveType.actual); + break; + case PerspectiveType.previous_over_actual: + setPerspective(PerspectiveType.previous_over_actual); + break; + } }; useEffect(() => {