From 8392ac32244e1b0355dd854cc31c5997fb12e00a Mon Sep 17 00:00:00 2001 From: Dan Labrecque Date: Wed, 16 Aug 2023 10:39:53 -0400 Subject: [PATCH 01/10] Added toolbar select component for settings source types and status https://issues.redhat.com/browse/COST-3307 --- locales/data.json | 65 +++++++++++++ locales/translations.json | 4 +- src/locales/messages.ts | 20 +++- .../components/dataToolbar/basicToolbar.tsx | 88 +++++++++++++++--- .../components/dataToolbar/customSelect.tsx | 93 +++++++++++++++++++ .../components/dataToolbar/dataToolbar.tsx | 10 +- .../components/dataToolbar/utils/category.tsx | 20 ++-- .../components/dataToolbar/utils/common.ts | 23 +++-- .../components/dataToolbar/utils/custom.tsx | 84 +++++++++++++++++ src/routes/settings/tagDetails/tagTable.tsx | 5 +- src/routes/settings/tagDetails/tagToolbar.tsx | 63 +++++++++++-- src/routes/utils/filter.ts | 1 + 12 files changed, 431 insertions(+), 45 deletions(-) create mode 100644 src/routes/components/dataToolbar/customSelect.tsx create mode 100644 src/routes/components/dataToolbar/utils/custom.tsx diff --git a/locales/data.json b/locales/data.json index c96ab9eae..9f86a7dc9 100644 --- a/locales/data.json +++ b/locales/data.json @@ -7460,6 +7460,12 @@ "value": "value" } ], + "filterByValuesAriaLabel": [ + { + "type": 0, + "value": "Values" + } + ], "filterByWorkloadTypeAriaLabel": [ { "type": 0, @@ -11573,6 +11579,65 @@ "value": "Source type" } ], + "sourceTypes": [ + { + "options": { + "aws": { + "value": [ + { + "type": 0, + "value": "Amazon Web Services" + } + ] + }, + "azure": { + "value": [ + { + "type": 0, + "value": "Microsoft Azure" + } + ] + }, + "gcp": { + "value": [ + { + "type": 0, + "value": "Google Cloud Platform" + } + ] + }, + "ibm": { + "value": [ + { + "type": 0, + "value": "IBM Cloud" + } + ] + }, + "oci": { + "value": [ + { + "type": 0, + "value": "Oracle Cloud Infrastructure" + } + ] + }, + "ocp": { + "value": [ + { + "type": 0, + "value": "OpenShift" + } + ] + }, + "other": { + "value": [] + } + }, + "type": 5, + "value": "value" + } + ], "sources": [ { "type": 0, diff --git a/locales/translations.json b/locales/translations.json index f8ea59e3a..cd565d8dd 100644 --- a/locales/translations.json +++ b/locales/translations.json @@ -303,6 +303,7 @@ "filterByTagValueButtonAriaLabel": "Filter button for tag value", "filterByValuePlaceholder": "Filter by value", "filterByValues": "{value, select, account {Account} aws_category {Cost category} cluster {Cluster} container {Container} gcp_project {GCP project} group {Group} name {Name} node {Node} org_unit_id {Organizational unit} payer_tenant_id {Account} product_service {Service} project {Project} region {Region} resource_location {Region} service {Service} service_name {Service} status {Status} source_type {Source type} subscription_guid {Account} tag {Tag} workload {Workload name} workload_type {Workload type} other {}}", + "filterByValuesAriaLabel": "Values", "filterByWorkloadTypeAriaLabel": "Workload types", "forDate": "{value} for {dateRange}", "gcp": "Google Cloud Platform", @@ -463,7 +464,7 @@ "percentSymbol": "%", "percentTotalCost": "{value} {units} ({percent} %)", "perspective": "Perspective", - "perspectiveValues": "{value, select, aws {Amazon Web Services} aws_ocp {Amazon Web Services filtered by OpenShift} azure {Microsoft Azure} oci {Oracle Cloud Infrastructure} azure_ocp {Microsoft Azure filtered by OpenShift} gcp {Google Cloud Platform} gcp_ocp {Google Cloud Platform filtered by OpenShift} ibm {IBM Cloud} ibm_ocp {IBM filtered by OpenShift} ocp {All OpenShift} ocp_cloud {All cloud filtered by OpenShift} rhel {All RHEL} other {}}", + "perspectiveValues": "{value, select, aws {Amazon Web Services} aws_ocp {Amazon Web Services filtered by OpenShift} azure {Microsoft Azure} azure_ocp {Microsoft Azure filtered by OpenShift} gcp {Google Cloud Platform} gcp_ocp {Google Cloud Platform filtered by OpenShift} ibm {IBM Cloud} ibm_ocp {IBM filtered by OpenShift} oci {Oracle Cloud Infrastructure} ocp {All OpenShift} ocp_cloud {All cloud filtered by OpenShift} rhel {All RHEL} other {}}", "platfomProjectaDesc": "Associate additional projects with OpenShift Platform project costs to charge for utilization of resources. Changes will be reflected in this month's cost calculations within 24 hrs. {learnMore}", "platform": "Platform", "platformDesc": "Distribute the cost of running the OpenShift services to projects", @@ -523,6 +524,7 @@ "settingsTitle": "Cost Management Settings", "sinceDate": "{dateRange}", "sourceType": "Source type", + "sourceTypes": "{value, select, aws {Amazon Web Services} azure {Microsoft Azure} oci {Oracle Cloud Infrastructure} gcp {Google Cloud Platform} ibm {IBM Cloud} ocp {OpenShift} other {}}", "sources": "Sources", "start": "Start", "status": "{value, select, pending {Pending} running {Running} failed {Failed} other {}}", diff --git a/src/locales/messages.ts b/src/locales/messages.ts index a28ff4257..04c4e9df1 100644 --- a/src/locales/messages.ts +++ b/src/locales/messages.ts @@ -1925,6 +1925,11 @@ export default defineMessages({ description: 'Filter by values', id: 'filterByValues', }, + filterByValuesAriaLabel: { + defaultMessage: 'Values', + description: 'Values', + id: 'filterByValuesAriaLabel', + }, filterByWorkloadTypeAriaLabel: { defaultMessage: 'Workload types', description: 'Workload types', @@ -2897,12 +2902,12 @@ export default defineMessages({ 'aws {Amazon Web Services} ' + 'aws_ocp {Amazon Web Services filtered by OpenShift} ' + 'azure {Microsoft Azure} ' + - 'oci {Oracle Cloud Infrastructure} ' + 'azure_ocp {Microsoft Azure filtered by OpenShift} ' + 'gcp {Google Cloud Platform} ' + 'gcp_ocp {Google Cloud Platform filtered by OpenShift} ' + 'ibm {IBM Cloud} ' + 'ibm_ocp {IBM filtered by OpenShift} ' + + 'oci {Oracle Cloud Infrastructure} ' + 'ocp {All OpenShift} ' + 'ocp_cloud {All cloud filtered by OpenShift} ' + 'rhel {All RHEL} ' + @@ -3215,6 +3220,19 @@ export default defineMessages({ description: 'Source type', id: 'sourceType', }, + sourceTypes: { + defaultMessage: + '{value, select, ' + + 'aws {Amazon Web Services} ' + + 'azure {Microsoft Azure} ' + + 'oci {Oracle Cloud Infrastructure} ' + + 'gcp {Google Cloud Platform} ' + + 'ibm {IBM Cloud} ' + + 'ocp {OpenShift} ' + + 'other {}}', + description: 'Source types', + id: 'sourceTypes', + }, sources: { defaultMessage: 'Sources', description: 'Sources', diff --git a/src/routes/components/dataToolbar/basicToolbar.tsx b/src/routes/components/dataToolbar/basicToolbar.tsx index 458146f21..35e695ed7 100644 --- a/src/routes/components/dataToolbar/basicToolbar.tsx +++ b/src/routes/components/dataToolbar/basicToolbar.tsx @@ -27,10 +27,18 @@ import { } from './utils/category'; import type { Filters } from './utils/common'; import { cleanInput, defaultFilters, getActiveFilters, getDefaultCategory, onDelete } from './utils/common'; +import { getCustomSelect, onCustomSelect } from './utils/custom'; + +export interface ToolbarChipGroupExt extends ToolbarChipGroup { + ariaLabelKey?: string; + placeholderKey?: string; + selectOptions?: ToolbarChipGroup[]; +} interface BasicToolbarOwnProps { actions?: React.ReactNode; - categoryOptions?: ToolbarChipGroup[]; // Options for category menu + categoryOptions?: ToolbarChipGroupExt[]; // Options for category menu + filters?: Filters; groupBy?: string; // Sync category selection with groupBy value isAllSelected?: boolean; isBulkSelectDisabled?: boolean; @@ -50,6 +58,7 @@ interface BasicToolbarOwnProps { showBulkSelectAll?: boolean; // Show bulk select all option showFilter?: boolean; // Show export icon style?: React.CSSProperties; + useActiveFilters?: boolean; } interface BasicToolbarState { @@ -84,7 +93,7 @@ export class BasicToolbarBase extends React.Component { - const filters = getActiveFilters(query); - return categoryOptions !== prevProps.categoryOptions || prevProps.groupBy !== groupBy + const result = { + ...((categoryOptions !== prevProps.categoryOptions || prevProps.groupBy !== groupBy) && { + categoryInput: '', + currentCategory: getDefaultCategory(categoryOptions, groupBy, query), + }), + }; + // Active filters overrides Filter.toString + return useActiveFilters ? { - categoryInput: '', - currentCategory: getDefaultCategory(categoryOptions, groupBy, query), - filters, + ...result, + filters: getActiveFilters(query), } - : { - filters, - }; + : result; }); } } @@ -206,10 +218,13 @@ export class BasicToolbarBase extends React.Component { + public getCategoryInputComponent = (categoryOption: ToolbarChipGroupExt) => { const { isDisabled, resourcePathsType } = this.props; const { categoryInput, currentCategory, filters } = this.state; + if (categoryOption.selectOptions) { + return null; + } return getCategoryInput({ categoryInput, categoryOption, @@ -307,6 +322,56 @@ export class BasicToolbarBase extends React.Component { + const { isDisabled } = this.props; + const { currentCategory, filters } = this.state; + + if (!categoryOption.selectOptions) { + return null; + } + return getCustomSelect({ + categoryOption, + currentCategory, + filters, + handleOnDelete: this.handleOnDelete, + handleOnSelect: this.handleOnCustomSelect, + isDisabled, + options: categoryOption.selectOptions, + }); + }; + + private handleOnCustomSelect = (event, selection) => { + const { onFilterAdded, onFilterRemoved } = this.props; + const { currentCategory, filters: currentFilters } = this.state; + + const checked = event.target.checked; + const { filter, filters } = onCustomSelect({ + currentCategory, + currentFilters, + event, + selection, + }); + + this.setState( + { + filters, + }, + () => { + if (checked) { + if (onFilterAdded) { + onFilterAdded(filter); + } + } else { + if (onFilterRemoved) { + onFilterRemoved(filter); + } + } + } + ); + }; + public render() { const { actions, categoryOptions, pagination, showBulkSelect, showFilter, style } = this.props; const options = categoryOptions ? categoryOptions : getDefaultCategoryOptions(); @@ -326,6 +391,7 @@ export class BasicToolbarBase extends React.Component {this.getCategorySelectComponent()} {options && options.map(option => this.getCategoryInputComponent(option))} + {options && options.map(option => this.getCustomSelectComponent(option))} )} diff --git a/src/routes/components/dataToolbar/customSelect.tsx b/src/routes/components/dataToolbar/customSelect.tsx new file mode 100644 index 000000000..888738024 --- /dev/null +++ b/src/routes/components/dataToolbar/customSelect.tsx @@ -0,0 +1,93 @@ +import type { SelectOptionObject, ToolbarChipGroup } from '@patternfly/react-core'; +import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; +import messages from 'locales/messages'; +import React from 'react'; +import type { WrappedComponentProps } from 'react-intl'; +import { injectIntl } from 'react-intl'; +import type { Filter } from 'routes/utils/filter'; +import type { RouterComponentProps } from 'utils/router'; +import { withRouter } from 'utils/router'; + +interface CustomSelectOwnProps extends RouterComponentProps, WrappedComponentProps { + filters?: Filter[]; + isDisabled?: boolean; + onSelect(event, selection); + options: ToolbarChipGroup[]; +} + +interface CustomSelectStateProps { + // TBD... +} + +interface CustomSelectDispatchProps { + // TBD... +} + +interface CustomSelectState { + isExpanded?: boolean; +} + +export interface SelectOptionObjectExt extends SelectOptionObject { + toString(): string; // label + value?: string; +} + +type CustomSelectProps = CustomSelectOwnProps & CustomSelectStateProps & CustomSelectDispatchProps; + +class CustomSelectBase extends React.Component { + protected defaultState: CustomSelectState = { + isExpanded: false, + }; + public state: CustomSelectState = { ...this.defaultState }; + + private onCustomSelectToggle = isOpen => { + this.setState({ + isExpanded: isOpen, + }); + }; + + private getSelectOptions = (): SelectOptionObjectExt[] => { + const { options } = this.props; + + const selectOptions: SelectOptionObjectExt[] = []; + + options.map(option => { + selectOptions.push({ + toString: () => option.name, + value: option.key, + }); + }); + return selectOptions; + }; + + public render() { + const { filters, intl, isDisabled, onSelect } = this.props; + const { isExpanded } = this.state; + + const selectOptions = this.getSelectOptions(); + const selections = filters?.map(filter => { + return selectOptions.find((option: SelectOptionObjectExt) => option.value === filter.value); + }); + + return ( + + ); + } +} + +const CustomSelect = injectIntl(withRouter(CustomSelectBase)); + +export { CustomSelect }; diff --git a/src/routes/components/dataToolbar/dataToolbar.tsx b/src/routes/components/dataToolbar/dataToolbar.tsx index 3554a0be5..7af2c13d6 100644 --- a/src/routes/components/dataToolbar/dataToolbar.tsx +++ b/src/routes/components/dataToolbar/dataToolbar.tsx @@ -768,6 +768,9 @@ export class DataToolbarBase extends React.Component option.key !== awsCategoryKey && option.key !== tagKey && option.key !== orgUnitIdKey + ); // Todo: clearAllFilters workaround https://github.com/patternfly/patternfly-react/issues/4222 return ( @@ -791,12 +794,7 @@ export class DataToolbarBase extends React.Component this.getTagValueSelect(option))} {this.getOrgUnitSelectComponent()} - {options && - options - .filter( - option => option.key !== awsCategoryKey && option.key !== tagKey && option.key !== orgUnitIdKey - ) - .map(option => this.getCategoryInputComponent(option))} + {filteredOptions.map(option => this.getCategoryInputComponent(option))} )} diff --git a/src/routes/components/dataToolbar/utils/category.tsx b/src/routes/components/dataToolbar/utils/category.tsx index 088772bb7..76f4622f1 100644 --- a/src/routes/components/dataToolbar/utils/category.tsx +++ b/src/routes/components/dataToolbar/utils/category.tsx @@ -19,6 +19,7 @@ import { intl } from 'components/i18n'; import messages from 'locales/messages'; import { cloneDeep } from 'lodash'; import React from 'react'; +import type { ToolbarChipGroupExt } from 'routes/components/dataToolbar/basicToolbar'; import { WorkloadType } from 'routes/components/dataToolbar/workloadType'; import { ResourceTypeahead } from 'routes/components/resourceTypeahead'; import type { Filter } from 'routes/utils/filter'; @@ -32,10 +33,6 @@ export interface CategoryOption extends SelectOptionObject { value?: string; } -export interface ToolbarChipGroupExt extends ToolbarChipGroup { - placeholder?: string; -} - // Category input export const getCategoryInput = ({ @@ -64,7 +61,8 @@ export const getCategoryInput = ({ resourcePathsType?: ResourcePathsType; }) => { const _hasFilters = hasFilters(filters); - const placeholder = categoryOption.placeholder || categoryOption.key; + const ariaLabelKey = categoryOption.ariaLabelKey || categoryOption.key; + const placeholderKey = categoryOption.placeholderKey || categoryOption.key; return ( ) : isResourceTypeValid(resourcePathsType, categoryOption.key as ResourceType) ? ( handleOnCategoryInputSelect(value, categoryOption.key)} - placeholder={intl.formatMessage(messages.filterByPlaceholder, { value: placeholder })} + placeholder={intl.formatMessage(messages.filterByPlaceholder, { value: placeholderKey })} resourcePathsType={resourcePathsType} resourceType={categoryOption.key as ResourceType} /> @@ -99,17 +97,17 @@ export const getCategoryInput = ({ name={`category-input-${categoryOption.key}`} id={`category-input-${categoryOption.key}`} type="search" - aria-label={intl.formatMessage(messages.filterByInputAriaLabel, { value: placeholder })} + aria-label={intl.formatMessage(messages.filterByInputAriaLabel, { value: ariaLabelKey })} onChange={handleOnCategoryInputChange} value={categoryInput} - placeholder={intl.formatMessage(messages.filterByPlaceholder, { value: placeholder })} + placeholder={intl.formatMessage(messages.filterByPlaceholder, { value: placeholderKey })} onKeyDown={evt => handleOnCategoryInput(evt, categoryOption.key)} - size={intl.formatMessage(messages.filterByPlaceholder, { value: placeholder }).length} + size={intl.formatMessage(messages.filterByPlaceholder, { value: placeholderKey }).length} />