From 307e5d25c28c2c9d8514488a2de4d5e9606ae130 Mon Sep 17 00:00:00 2001 From: Gowtham Shanmugasundaram Date: Tue, 13 Feb 2024 01:09:21 +0530 Subject: [PATCH] Resource label selection view under configuration wizard step Signed-off-by: Gowtham Shanmugasundaram --- locales/en/plugin__odf-console.json | 20 + .../enroll-discovered-application.scss | 3 - .../discovered-application-wizard/footer.tsx | 25 +- .../utils/reducer.ts | 47 +- .../configuration-step.scss | 2 +- .../configuration-step/configuration-step.tsx | 27 +- .../configuration-step/recipe-selection.tsx | 2 +- .../resource-label-selection.tsx | 181 ++++++++ .../parsers/application-set-parser.spec.tsx | 6 +- packages/mco/utils/acm-search-quries.ts | 29 ++ packages/mco/utils/disaster-recovery.tsx | 18 + .../src/dropdown/multiselectdropdown.tsx | 14 +- .../src/dropdown/singleselectdropdown.tsx | 21 +- packages/shared/src/index.ts | 1 + .../src/label-expression-selector/index.ts | 1 + .../labelExpressionSelector.spec.tsx | 103 +++++ .../labelExpressionSelector.tsx | 430 ++++++++++++++++++ .../src/table/selectable-table.spec.tsx | 21 +- 18 files changed, 918 insertions(+), 33 deletions(-) create mode 100644 packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/resource-label-selection.tsx create mode 100644 packages/shared/src/label-expression-selector/index.ts create mode 100644 packages/shared/src/label-expression-selector/labelExpressionSelector.spec.tsx create mode 100644 packages/shared/src/label-expression-selector/labelExpressionSelector.tsx diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index ac0b151d6..5740299ba 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -72,6 +72,11 @@ "Select a recipe": "Select a recipe", "No recipe found": "No recipe found", "Required": "Required", + "Label expressions": "Label expressions", + "Protect all Kubernetes resources that match the selected resource label selector": "Protect all Kubernetes resources that match the selected resource label selector", + "PVC label selectors": "PVC label selectors", + "Protect all PVCs that match the selected resource label selector": "Protect all PVCs that match the selected resource label selector", + "Add PVC label selector": "Add PVC label selector", "Name": "Name", "{{count}} results found for {{clusterName}}_one": "{{count}} results found for {{clusterName}}", "{{count}} results found for {{clusterName}}_other": "{{count}} results found for {{clusterName}}", @@ -1221,6 +1226,21 @@ "Delete {{resourceLabel}}": "Delete {{resourceLabel}}", "Resource is being deleted.": "Resource is being deleted.", "You do not have permission to perform this action": "You do not have permission to perform this action", + "Expand to fix validation errors": "Expand to fix validation errors", + "unknown": "unknown", + "equals any of": "equals any of", + "equals": "equals", + "does not equal any of": "does not equal any of", + "does not equal": "does not equal", + "exists": "exists", + "does not exist": "does not exist", + "Expand to enter expression": "Expand to enter expression", + "Label": "Label", + "Select a label": "Select a label", + "Operator": "Operator", + "Values": "Values", + "Select the values": "Select the values", + "Add label expression": "Add label expression", "Delete {{kind}}?": "Delete {{kind}}?", "Are you sure you want to delete <2>{{resourceName}} in namespace <6>{{namespace}}?": "Are you sure you want to delete <2>{{resourceName}} in namespace <6>{{namespace}}?", "Are you sure you want to delete <2>{{resourceName}}?": "Are you sure you want to delete <2>{{resourceName}}?", diff --git a/packages/mco/components/discovered-application-wizard/enroll-discovered-application.scss b/packages/mco/components/discovered-application-wizard/enroll-discovered-application.scss index aa15e6f10..dbd75cb81 100644 --- a/packages/mco/components/discovered-application-wizard/enroll-discovered-application.scss +++ b/packages/mco/components/discovered-application-wizard/enroll-discovered-application.scss @@ -4,7 +4,4 @@ &__wizard--height { max-height: 74vh; } - &__dropdown--width { - width: 27rem; - } } diff --git a/packages/mco/components/discovered-application-wizard/footer.tsx b/packages/mco/components/discovered-application-wizard/footer.tsx index b34cffd0c..f2b24dd56 100644 --- a/packages/mco/components/discovered-application-wizard/footer.tsx +++ b/packages/mco/components/discovered-application-wizard/footer.tsx @@ -3,6 +3,7 @@ import { EnrollDiscoveredApplicationStepNames, EnrollDiscoveredApplicationSteps, } from '@odf/mco/constants'; +import { isLabelOnlyOperator } from '@odf/shared/label-expression-selector'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { WizardContextType, @@ -25,11 +26,25 @@ import { const validateNamespaceStep = (state: EnrollDiscoveredApplicationState) => !!state.namespace.clusterName && !!state.namespace.namespaces.length; -const validateConfigurationStep = (state: EnrollDiscoveredApplicationState) => - state.configuration.protectionMethod === ProtectionMethodType.RECIPE - ? !!state.configuration.recipe.recipeName && - !!state.configuration.recipe.recipeNamespace - : false; +const validateConfigurationStep = (state: EnrollDiscoveredApplicationState) => { + const { recipe, resourceLabels, protectionMethod } = state.configuration; + const { recipeName, recipeNamespace } = recipe; + const { k8sResourceLabelExpressions, pvcLabelExpressions } = resourceLabels; + const labelExpressions = [ + ...k8sResourceLabelExpressions, + ...pvcLabelExpressions, + ]; + const isLabelExpressionsFound = + k8sResourceLabelExpressions.length && pvcLabelExpressions.length; + return protectionMethod === ProtectionMethodType.RECIPE + ? !!recipeName && !!recipeNamespace + : isLabelExpressionsFound && + labelExpressions.every((selector) => + isLabelOnlyOperator(selector.operator) + ? !!selector.key + : !!selector.key && !!selector.values.length + ); +}; const canJumpToNextStep = ( state: EnrollDiscoveredApplicationState, diff --git a/packages/mco/components/discovered-application-wizard/utils/reducer.ts b/packages/mco/components/discovered-application-wizard/utils/reducer.ts index 7889819fb..ce841244e 100644 --- a/packages/mco/components/discovered-application-wizard/utils/reducer.ts +++ b/packages/mco/components/discovered-application-wizard/utils/reducer.ts @@ -1,4 +1,7 @@ -import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; +import { + K8sResourceCommon, + MatchExpression, +} from '@openshift-console/dynamic-plugin-sdk'; import * as _ from 'lodash-es'; export const NAME_NAMESPACE_SPLIT_CHAR = '/'; @@ -13,6 +16,8 @@ export enum EnrollDiscoveredApplicationStateType { SET_NAMESPACES = 'NAMESPACE/SET_NAMESPACES', SET_PROTECTION_METHOD = 'CONFIGURATION/SET_PROTECTION_METHOD', SET_RECIPE_NAME_NAMESPACE = 'CONFIGURATION/RECIPE/SET_RECIPE_NAME_NAMESPACE', + SET_K8S_RESOURCE_LABEL_EXPRESSIONS = 'CONFIGURATION/RESOURCE_LABEL/SET_K8S_RESOURCE_LABEL_EXPRESSIONS', + SET_PVC_LABEL_EXPRESSIONS = 'CONFIGURATION/RESOURCE_LABEL/SET_PVC_LABEL_EXPRESSIONS', } export type EnrollDiscoveredApplicationState = { @@ -31,6 +36,10 @@ export type EnrollDiscoveredApplicationState = { // recipe CR namespace recipeNamespace: string; }; + resourceLabels: { + k8sResourceLabelExpressions: MatchExpression[]; + pvcLabelExpressions: MatchExpression[]; + }; }; }; @@ -51,6 +60,10 @@ export const initialState: EnrollDiscoveredApplicationState = { recipeName: '', recipeNamespace: '', }, + resourceLabels: { + k8sResourceLabelExpressions: [], + pvcLabelExpressions: [], + }, }, }; @@ -71,6 +84,14 @@ export type EnrollDiscoveredApplicationAction = | { type: EnrollDiscoveredApplicationStateType.SET_RECIPE_NAME_NAMESPACE; payload: string; + } + | { + type: EnrollDiscoveredApplicationStateType.SET_K8S_RESOURCE_LABEL_EXPRESSIONS; + payload: MatchExpression[]; + } + | { + type: EnrollDiscoveredApplicationStateType.SET_PVC_LABEL_EXPRESSIONS; + payload: MatchExpression[]; }; export const reducer: EnrollReducer = (state, action) => { @@ -125,6 +146,30 @@ export const reducer: EnrollReducer = (state, action) => { }, }; } + case EnrollDiscoveredApplicationStateType.SET_K8S_RESOURCE_LABEL_EXPRESSIONS: { + return { + ...state, + configuration: { + ...state.configuration, + resourceLabels: { + ...state.configuration.resourceLabels, + k8sResourceLabelExpressions: action.payload, + }, + }, + }; + } + case EnrollDiscoveredApplicationStateType.SET_PVC_LABEL_EXPRESSIONS: { + return { + ...state, + configuration: { + ...state.configuration, + resourceLabels: { + ...state.configuration.resourceLabels, + pvcLabelExpressions: action.payload, + }, + }, + }; + } default: throw new TypeError(`${action} is not a valid reducer action`); } diff --git a/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/configuration-step.scss b/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/configuration-step.scss index 5b4d0b3fd..03005f642 100644 --- a/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/configuration-step.scss +++ b/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/configuration-step.scss @@ -1,6 +1,6 @@ @import '../../enroll-discovered-application.scss'; -.mco-configuration { +.mco-configuration-step { &__radio { border: var(--pf-global--BorderWidth--sm) solid var(--pf-global--BorderColor--100); border-radius: 10px; diff --git a/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/configuration-step.tsx b/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/configuration-step.tsx index 10c01a2f0..bad57868d 100644 --- a/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/configuration-step.tsx +++ b/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/configuration-step.tsx @@ -19,6 +19,7 @@ import { ProtectionMethodType, } from '../../utils/reducer'; import { RecipeSelection } from './recipe-selection'; +import { ResourceLabelSelection } from './resource-label-selection'; import './configuration-step.scss'; const RADIO_GROUP_NAME = 'k8s_object_protection_method'; @@ -31,7 +32,7 @@ export const Configuration: React.FC = ({ const { t } = useCustomTranslation(); const { namespaces, clusterName } = state.namespace; - const { protectionMethod, recipe } = state.configuration; + const { protectionMethod, recipe, resourceLabels } = state.configuration; const setProtectionMethod = (_unUsed, event) => { dispatch({ @@ -49,7 +50,7 @@ export const Configuration: React.FC = ({ )} = ({ /> - + = ({ checked={protectionMethod === ProtectionMethodType.RECIPE} /> - + = ({ dispatch={dispatch} /> )} + {protectionMethod === ProtectionMethodType.RESOURCE_LABEL && ( + + )} ); diff --git a/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/recipe-selection.tsx b/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/recipe-selection.tsx index 80528c498..0a7647a69 100644 --- a/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/recipe-selection.tsx +++ b/packages/mco/components/discovered-application-wizard/wizard-steps/configuration-step/recipe-selection.tsx @@ -102,7 +102,7 @@ export const RecipeSelection: React.FC = ({ )} + searchResultItem?.reduce( + (acc, item) => { + const labels = getLabelsFromSearchResult(item); + // Separate PVC labels from other K8s resource labels + const isPVCKind = item.kind === PersistentVolumeClaimModel.kind; + Object.entries(labels)?.forEach(([key, values]) => { + if (isPVCKind) { + const valuesSet = new Set([ + ...(acc['pvcLabels']?.[key] || []), + ...values, + ]); + acc['pvcLabels'][key] = Array.from(valuesSet); + } else { + const valuesSet = new Set([ + ...(acc['k8sResourceLabel']?.[key] || []), + ...values, + ]); + acc['k8sResourceLabel'][key] = Array.from(valuesSet); + } + }); + return acc; + }, + { pvcLabels: {}, k8sResourceLabel: {} } + ); + +export const ResourceLabelSelection: React.FC = ({ + k8sResourceLabelExpressions, + pvcLabelExpressions, + clusterName, + namespaces, + isValidationEnabled, + dispatch, +}) => { + const { t } = useCustomTranslation(); + + const searchQuery = React.useMemo( + () => queryK8sResourceFromCluster(clusterName, namespaces.map(getName)), + [clusterName, namespaces] + ); + + // ACM search proxy API call + const [searchResult, searchError, searchLoaded] = + useACMSafeFetch(searchQuery); + + const labelOptions = React.useMemo(() => { + if (searchLoaded && !searchError) { + return getLabelOptions(searchResult?.data.searchResult?.[0]?.items || []); + } + return { pvcLabels: {}, k8sResourceLabel: {} }; + }, [searchResult, searchLoaded, searchError]); + + const k8sResourceLabelValidated = getValidatedProp( + isValidationEnabled && !k8sResourceLabelExpressions + ); + + const pvcLabelValidated = getValidatedProp( + isValidationEnabled && !pvcLabelExpressions + ); + + const setK8sResourceLabelExpressions = (expressions: MatchExpression[]) => + dispatch({ + type: EnrollDiscoveredApplicationStateType.SET_K8S_RESOURCE_LABEL_EXPRESSIONS, + payload: expressions, + }); + + const setPVCLabelExpressions = (expressions: MatchExpression[]) => + dispatch({ + type: EnrollDiscoveredApplicationStateType.SET_PVC_LABEL_EXPRESSIONS, + payload: expressions, + }); + + return ( + <> + {searchLoaded && !searchError ? ( + <> + + + + + {k8sResourceLabelValidated === 'error' + ? t('Required') + : t( + 'Protect all Kubernetes resources that match the selected resource label selector' + )} + + + + + + + + + + {pvcLabelValidated === 'error' + ? t('Required') + : t( + 'Protect all PVCs that match the selected resource label selector' + )} + + + + + + + ) : ( + + )} + + ); +}; + +type LabelOptionsType = { + pvcLabels: { [key in string]: string[] }; + k8sResourceLabel: { [key in string]: string[] }; +}; + +type ResourceLabelSelectionProps = { + // Selected k8s resource label selector expressions + k8sResourceLabelExpressions: MatchExpression[]; + // Selected PVC label selector expressions + pvcLabelExpressions: MatchExpression[]; + // Selected discovered application deployment cluster + clusterName: string; + // Selected discovered application namespaces + namespaces: K8sResourceCommon[]; + // Form validation enabled/disabled + isValidationEnabled: boolean; + // Update state + dispatch: React.Dispatch; +}; diff --git a/packages/mco/components/modals/app-manage-policies/parsers/application-set-parser.spec.tsx b/packages/mco/components/modals/app-manage-policies/parsers/application-set-parser.spec.tsx index 70ddb4541..4fff2ddb6 100644 --- a/packages/mco/components/modals/app-manage-policies/parsers/application-set-parser.spec.tsx +++ b/packages/mco/components/modals/app-manage-policies/parsers/application-set-parser.spec.tsx @@ -345,7 +345,7 @@ jest.mock('@odf/mco/hooks/acm-safe-fetch', () => ({ }), })); -describe.skip('ApplicationSet manage data policy modal', () => { +describe('ApplicationSet manage data policy modal', () => { test('Empty list page test', async () => { testCase = 1; render( @@ -408,7 +408,7 @@ describe.skip('ApplicationSet manage data policy modal', () => { // Row actions fireEvent.click( screen.getByRole('button', { - name: 'Actions', + name: 'Kebab toggle', }) ); expect(screen.getByText('View configurations')).toBeInTheDocument(); @@ -461,7 +461,7 @@ describe.skip('ApplicationSet manage data policy modal', () => { // Row actions fireEvent.click( screen.getByRole('button', { - name: 'Actions', + name: 'Kebab toggle', }) ); // Policy config view diff --git a/packages/mco/utils/acm-search-quries.ts b/packages/mco/utils/acm-search-quries.ts index a48713198..9d633e50e 100644 --- a/packages/mco/utils/acm-search-quries.ts +++ b/packages/mco/utils/acm-search-quries.ts @@ -9,6 +9,9 @@ import { LABELS_SPLIT_CHAR, LABEL_SPLIT_CHAR } from '../constants'; export const searchFilterQuery = 'query searchResult($input: [SearchInput]) {\n searchResult: search(input: $input) {\n items\n }\n}'; +export const searchRelatedItemsFilterQuery = + 'query searchResultRelatedItems($input: [SearchInput]) {\n searchResult: search(input: $input) {\n items\n related {\n kind\n items\n __typename\n }\n __typename\n }\n}'; + export const queryAppWorkloadPVCs = ( workloadNamespace: string, clusterNames: string[] @@ -126,3 +129,29 @@ export const queryRecipesFromCluster = ( }, query: searchFilterQuery, }); + +// ACM seach query to fetch all releated resources of this namesapces from the managed cluster. +export const queryK8sResourceFromCluster = ( + clusterName: string, + namespaces: string[] +): SearchQuery => ({ + operationName: 'searchResultRelatedItems', + variables: { + input: [ + { + filters: [ + { + property: 'namespace', + values: namespaces, + }, + { + property: 'cluster', + values: clusterName, + }, + ], + limit: 2000, // search said not to use unlimited results + }, + ], + }, + query: searchRelatedItemsFilterQuery, +}); diff --git a/packages/mco/utils/disaster-recovery.tsx b/packages/mco/utils/disaster-recovery.tsx index d681235a5..977530435 100644 --- a/packages/mco/utils/disaster-recovery.tsx +++ b/packages/mco/utils/disaster-recovery.tsx @@ -32,6 +32,9 @@ import { DRPC_STATUS, THRESHOLD, DRActionType, + LABEL_SPLIT_CHAR, + LABELS_SPLIT_CHAR, + DR_BLOCK_LISTED_LABELS, } from '../constants'; import { DRPC_NAMESPACE_ANNOTATION, @@ -66,6 +69,7 @@ import { MirrorPeerKind, ArgoApplicationSetKind, ClusterClaim, + SearchResultItemType, } from '../types'; export type PlacementMap = { @@ -657,3 +661,17 @@ export const getValueFromClusterClaim = ( export const parseNamespaceName = (namespaceName: string) => namespaceName.split('/'); + +export const getLabelsFromSearchResult = ( + item: SearchResultItemType +): { [key in string]: string[] } => { + // example label foo1=bar1;foo2=bar2 + const labels: string[] = item?.label?.split(LABELS_SPLIT_CHAR) || []; + return labels?.reduce((acc, label) => { + const [key, value] = label.split(LABEL_SPLIT_CHAR); + if (!DR_BLOCK_LISTED_LABELS.includes(key)) { + acc[key] = [...(acc[key] || []), value]; + } + return acc; + }, {}); +}; diff --git a/packages/shared/src/dropdown/multiselectdropdown.tsx b/packages/shared/src/dropdown/multiselectdropdown.tsx index a8f8bf2e7..0e5e0075f 100644 --- a/packages/shared/src/dropdown/multiselectdropdown.tsx +++ b/packages/shared/src/dropdown/multiselectdropdown.tsx @@ -15,9 +15,13 @@ export const MultiSelectDropdown: React.FC = ({ selectOptions, selections = [], variant, + isCreatable, ...rest }) => { const [isOpen, setOpen] = React.useState(false); + const [newOptions, setNewOptions] = React.useState([]); + const allOptions = + newOptions.length > 0 ? selectOptions.concat(newOptions) : selectOptions; const onSelect = ( _event: React.MouseEvent | React.ChangeEvent, @@ -39,6 +43,12 @@ export const MultiSelectDropdown: React.FC = ({ )); + const onCreateOption = (newValue: string) => + setNewOptions([ + ...newOptions, + , + ]); + return ( ); }; @@ -66,4 +77,5 @@ export type MultiSelectDropdownProps = Omit< options?: string[]; selectOptions?: JSX.Element[]; onChange: (selected: string[], selection?: string) => void; + isCreatable?: boolean; }; diff --git a/packages/shared/src/dropdown/singleselectdropdown.tsx b/packages/shared/src/dropdown/singleselectdropdown.tsx index 52a2e8764..6921d661f 100644 --- a/packages/shared/src/dropdown/singleselectdropdown.tsx +++ b/packages/shared/src/dropdown/singleselectdropdown.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Select, + SelectOption, SelectProps, SelectVariant, } from '@patternfly/react-core/deprecated'; @@ -16,6 +17,10 @@ export const SingleSelectDropdown: React.FC = ({ const { t } = useCustomTranslation(); const [isOpen, setOpen] = React.useState(false); + const [newOptions, setNewOptions] = React.useState([]); + const allOptions = + newOptions.length > 0 ? selectOptions.concat(newOptions) : selectOptions; + const onSelect = React.useCallback( (event: React.MouseEvent | React.ChangeEvent, selection: string) => { /** @@ -34,9 +39,15 @@ export const SingleSelectDropdown: React.FC = ({ [valueLabelMap, onChange, setOpen] ); + const onCreateOption = (newValue: string) => + setNewOptions([ + ...newOptions, + , + ]); + return ( - // surround select with data-test-id to be able to find it in tests -
+ // surround select with data-test to be able to find it in tests +
); @@ -63,10 +75,11 @@ export type SingleSelectDropdownProps = { className?: string; selectOptions: JSX.Element[]; onChange: (selected: string) => void; - 'data-test-id'?: string; + 'data-test'?: string; onFilter?: SelectProps['onFilter']; hasInlineFilter?: SelectProps['hasInlineFilter']; isDisabled?: boolean; validated?: 'success' | 'warning' | 'error' | 'default'; required?: boolean; + isCreatable?: boolean; }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 57cd3d4a0..d684ce081 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -32,3 +32,4 @@ export * from './yaml-editor'; export * from './yup-validation-resolver'; export * from './file-handling'; export * from './text-inputs'; +export * from './label-expression-selector'; diff --git a/packages/shared/src/label-expression-selector/index.ts b/packages/shared/src/label-expression-selector/index.ts new file mode 100644 index 000000000..05c8e6670 --- /dev/null +++ b/packages/shared/src/label-expression-selector/index.ts @@ -0,0 +1 @@ +export * from './labelExpressionSelector'; diff --git a/packages/shared/src/label-expression-selector/labelExpressionSelector.spec.tsx b/packages/shared/src/label-expression-selector/labelExpressionSelector.spec.tsx new file mode 100644 index 000000000..edbe289af --- /dev/null +++ b/packages/shared/src/label-expression-selector/labelExpressionSelector.spec.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { MatchExpression } from '@openshift-console/dynamic-plugin-sdk'; +import { screen, render, fireEvent } from '@testing-library/react'; +import { LabelExpressionSelector } from './labelExpressionSelector'; + +const getLabels = () => ({ + 'option-1': ['value-1', 'value-2'], + 'option-2': ['value-3', 'value-4'], +}); + +describe('Label expression selector', () => { + test('Verify expression selection', async () => { + let selectedExpression: MatchExpression[] = []; + const onChange = jest.fn((expression: MatchExpression[]) => { + selectedExpression = expression; + }); + const component = () => ( + + ); + + const { rerender } = render(component()); + + // Verify add resource + fireEvent.click(screen.getByText('Add resource')); + + // rerender after argument change + rerender(component()); + + // Verify expand section before selection + expect(screen.getByText('Expand to enter expression')).toBeInTheDocument(); + + // Verify header text + expect(screen.getByText('Label')).toBeInTheDocument(); + expect(screen.getByText('Operator')).toBeInTheDocument(); + expect(screen.getByText('Values')).toBeInTheDocument(); + + // Verify label selection + fireEvent.click(screen.getByText('Select a label')); + expect(screen.getByText('option-1')).toBeInTheDocument(); + expect(screen.getByText('option-2')).toBeInTheDocument(); + fireEvent.click(screen.getByText('option-1')); + // rerender after argument change + rerender(component()); + expect(screen.getByText('option-1')).toBeInTheDocument(); + + // Verify operator selection + fireEvent.click(screen.getByText('In')); + expect(screen.getByText('NotIn')).toBeInTheDocument(); + expect(screen.getByText('Exists')).toBeInTheDocument(); + expect(screen.getByText('DoesNotExist')).toBeInTheDocument(); + fireEvent.click(screen.getByText('NotIn')); + // rerender after argument change + rerender(component()); + expect(screen.getByText('NotIn')).toBeInTheDocument(); + + // Verify values for option-1 + fireEvent.click(screen.getByText('Select the values')); + fireEvent.click(screen.getByText('value-1')); + // rerender after argument change + rerender(component()); + fireEvent.click(screen.getByText('value-2')); + // rerender after argument change + rerender(component()); + expect(screen.getByText('{{count}} selected')).toBeInTheDocument(); + + // Verify expand section after selection + expect( + screen.getByText('option-1 does not equal any of value-1, value-2') + ).toBeInTheDocument(); + }); + + test('Verify error validation', async () => { + const selectedExpression: MatchExpression[] = [ + { + key: '', + operator: 'In', + values: [], + }, + ]; + const onChange = jest.fn(); + render( + + ); + // Verify validation error message + const errors = screen.getAllByText('Required'); + expect(errors).toHaveLength(2); + expect( + screen.getByText('Expand to fix validation errors') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/label-expression-selector/labelExpressionSelector.tsx b/packages/shared/src/label-expression-selector/labelExpressionSelector.tsx new file mode 100644 index 000000000..a337cb7ff --- /dev/null +++ b/packages/shared/src/label-expression-selector/labelExpressionSelector.tsx @@ -0,0 +1,430 @@ +import * as React from 'react'; +import { RedExclamationCircleIcon } from '@odf/shared/status/icons'; +import { MatchExpression } from '@openshift-console/dynamic-plugin-sdk'; +import { SelectOption, SelectVariant } from '@patternfly/react-core/deprecated'; +import { SVGIconProps } from '@patternfly/react-icons/dist/esm/createIcon'; +import { TFunction } from 'i18next'; +import * as _ from 'lodash-es'; +import { + FormFieldGroupExpandable, + Button, + ButtonVariant, + Divider, + Form, + Grid, + FormGroup, + GridItem, + Split, + SplitItem, + FormFieldGroupHeader, + HelperText, + HelperTextItem, + FormHelperText, +} from '@patternfly/react-core'; +import { TrashIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import { SingleSelectDropdown, MultiSelectDropdown } from '../dropdown'; +import { useCustomTranslation } from '../useCustomTranslationHook'; +import { getValidatedProp } from '../utils'; +import { AsyncLoader } from '../utils/AsyncLoader'; + +/** + * Set up an AsyncComponent to wrap the label-expression-selector to allow on demand loading to reduce the + * vendor footprint size. + */ +export const LazyLabelExpressionSelector = ( + props: LabelExpressionSelectorProps +) => ( + + import('../label-expression-selector').then( + (c) => c.LabelExpressionSelector + ) + } + {...props} + /> +); + +export const isLabelOnlyOperator = (operator: string) => + [Operator.Exists, Operator.DoesNotExist].includes(operator as Operator); + +const matchExpressionSummaryError = ( + expandString: string, + t: TFunction +): React.ReactNode => ( + + + + + + +   {expandString || t('Expand to fix validation errors')} + + + +); + +export const matchExpressionSummary = ( + t: TFunction, + expression: MatchExpression, + expandString?: string, + isValidationEnabled?: boolean +) => { + const { key, operator, values } = expression; + + // Skipping values check for label only operator. + const hasError = + !key || (isLabelOnlyOperator(operator) ? false : !values.length); + + // Converting the selected label expression as text to summerize, + // Only for the display purpose + let operatorStr = t('unknown'); + switch (operator) { + case Operator.In: + if (!!values && values.length > 1) { + operatorStr = t('equals any of'); + } else { + operatorStr = t('equals'); + } + break; + case Operator.NotIn: + if (!!values && values.length > 1) { + operatorStr = t('does not equal any of'); + } else { + operatorStr = t('does not equal'); + } + break; + case Operator.Exists: + operatorStr = t('exists'); + break; + case Operator.DoesNotExist: + operatorStr = t('does not exist'); + break; + } + // For empty, default message will be displayed, + // For non-mepty, summerized expression text will be displayed + if (isValidationEnabled && hasError) { + return matchExpressionSummaryError(expandString, t); + } else { + return !key + ? expandString || t('Expand to enter expression') + : `${key} ${operatorStr} ${values.join(', ')}`; + } +}; + +const ExpressionElement: React.FC = ({ + index, + selectedExpression, + labels, + isValidationEnabled, + onSelect, +}) => { + const { key, operator, values } = selectedExpression; + + const { t } = useCustomTranslation(); + + // Display each label key as options + const keyOptions = React.useMemo( + () => + Object.keys(labels).map((labelKey) => ( + + )), + [labels] + ); + + // Default options of Operator enum. + const operatorOptions = Object.values(Operator).map((option) => ( + + )); + + // Display each values of the selected key as options + // Modify value options based on key selection. + const valueOptions = React.useMemo( + () => + (!!key ? labels[key] || [] : []).map((value) => ( + + )), + [labels, key] + ); + + // Reset values when label is selected. + const onKeyChange = React.useCallback( + (selectedKey: string) => { + onSelect(index, { + key: selectedKey, + operator, + values: key === selectedKey ? values : [], + }); + }, + [index, key, operator, values, onSelect] + ); + + // Reset values when label only operator is selected. + const onOperatorChange = React.useCallback( + (selectedOperator: Operator) => { + onSelect(index, { + ...selectedExpression, + operator: selectedOperator, + values: !isLabelOnlyOperator(selectedOperator) ? values : [], + }); + }, + [index, selectedExpression, values, onSelect] + ); + + // Call onSelect to update the state + const onValuesChange = React.useCallback( + (selectedValues: string[]) => { + onSelect(index, { + ...selectedExpression, + values: selectedValues, + }); + }, + [index, selectedExpression, onSelect] + ); + + const isKeyValid = getValidatedProp(isValidationEnabled && !key); + const isValuesValid = getValidatedProp(isValidationEnabled && !values.length); + + return ( + + + + + + + + {isKeyValid === 'error' && t('Required')} + + + + + + + + + + + {!isLabelOnlyOperator(operator) && ( + + + + + + + {isValuesValid === 'error' && t('Required')} + + + + + + )} + + ); +}; + +const ArrayInput: React.FC = ({ + index, + selectedExpression, + labels, + expandString, + isValidationEnabled, + DeleteIcon, + onSelect, +}) => { + const { t } = useCustomTranslation(); + + const expandSectionName = `expand-section-${index}`; + + return ( + <> + +
+ {/* Expand section */} + onSelect(index)} + > + + + } + /> + } + > + {/* Expand section body */} + + +
+ + + ); +}; + +export const LabelExpressionSelector: React.FC = + ({ + selectedExpressions, + labels, + expandString, + addExpressionString, + isValidationEnabled, + DeleteIcon = TrashIcon, + onChange, + }) => { + const { t } = useCustomTranslation(); + + const onSelect = React.useCallback( + (index: number, updatedExpression?: MatchExpression) => { + const newExpressions = _.cloneDeep(selectedExpressions); + if (!!updatedExpression) { + // Update expression + newExpressions[index] = updatedExpression; + } else { + // Delete expression + newExpressions.splice(index, 1); + } + // Callback to update the state + onChange(newExpressions); + }, + [selectedExpressions, onChange] + ); + + const addExpression = React.useCallback( + () => + onSelect(selectedExpressions.length, { + key: '', + operator: Operator.In, // In operator is a default selection + values: [], + }), + [selectedExpressions, onSelect] + ); + + return ( +
+ {selectedExpressions.map((expression, index) => ( + + ))} + +
+ ); + }; + +// Only selective operator are used as options. +enum Operator { + In = 'In', + NotIn = 'NotIn', + Exists = 'Exists', + DoesNotExist = 'DoesNotExist', +} + +type ExpressionElementProps = { + index: number; + selectedExpression: MatchExpression; + labels: { + [key: string]: string[]; + }; + isValidationEnabled?: boolean; + onSelect: (index: number, expression?: MatchExpression) => void; +}; + +type ArrayInputProps = ExpressionElementProps & { + expandString?: string; + DeleteIcon?: React.ComponentClass; +}; + +export type LabelExpressionSelectorProps = { + // Selected label selector expressions. + selectedExpressions: MatchExpression[]; + // Labels to form the label selector expression options + labels: { + [key: string]: string[]; + }; + // Dispay text for the expand section. + expandString?: string; + // Display text to add more expression selector. + addExpressionString?: string; + // Make it 'true' for the form validation. + isValidationEnabled?: boolean; + // Delete expression selector icon + DeleteIcon?: React.ComponentClass; + // Callback function to receive the updated expression list. + onChange: (onChange: MatchExpression[]) => void; +}; diff --git a/packages/shared/src/table/selectable-table.spec.tsx b/packages/shared/src/table/selectable-table.spec.tsx index 8de4c0c8d..c82f2ec4a 100644 --- a/packages/shared/src/table/selectable-table.spec.tsx +++ b/packages/shared/src/table/selectable-table.spec.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; -import { screen, render, fireEvent } from '@testing-library/react'; +import { screen, render, fireEvent, waitFor } from '@testing-library/react'; import { ActionsColumn, IAction, Td } from '@patternfly/react-table'; import { getName, getNamespace } from '../selectors'; -import { RowComponentType, SelectableTable } from './selectable-table'; +import { RowComponentType } from './composable-table'; +import { SelectableTable } from './selectable-table'; const getRow = (): K8sResourceCommon[] => [ { @@ -58,15 +59,13 @@ const MockRowComponent: React.FC> = ( {getNamespace(row)} {row?.kind} - + ); }; -// Todo(bipuladh): Enable tests -// eslint-disable-next line jest/no-test-prefixes -describe.skip('ApplicationSet manage data policy modal', () => { +describe('ApplicationSet manage data policy modal', () => { test('selectable table test', async () => { let selectedRows: K8sResourceCommon[] = []; const mockFuncton = jest.fn((selectedRowList: K8sResourceCommon[]) => { @@ -105,10 +104,12 @@ describe.skip('ApplicationSet manage data policy modal', () => { expect(screen.getByText('kind3')).toBeInTheDocument(); // Row action - fireEvent.click( - screen.getAllByRole('button', { - name: 'Actions', - })[0] + await waitFor(() => + fireEvent.click( + screen.getAllByRole('button', { + name: 'Kebab toggle', + })[0] + ) ); expect(screen.getByText('test action')).toBeInTheDocument(); });