diff --git a/ui/apps/platform/src/Components/TypeaheadSelect/TypeaheadSelect.tsx b/ui/apps/platform/src/Components/TypeaheadSelect/TypeaheadSelect.tsx index 315167dc88cf8..8580dea689eaa 100644 --- a/ui/apps/platform/src/Components/TypeaheadSelect/TypeaheadSelect.tsx +++ b/ui/apps/platform/src/Components/TypeaheadSelect/TypeaheadSelect.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import type { FocusEventHandler, FormEvent, @@ -9,6 +9,7 @@ import type { Ref, } from 'react'; import { + Button, MenuFooter, MenuToggle, Select, @@ -16,8 +17,10 @@ import { SelectOption, TextInputGroup, TextInputGroupMain, + TextInputGroupUtilities, } from '@patternfly/react-core'; import type { MenuToggleElement } from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons'; export type TypeaheadSelectOption = { value: string; @@ -32,6 +35,7 @@ export type TypeaheadSelectProps = { onChange: (value: string) => void; options: TypeaheadSelectOption[]; allowCreate?: boolean; + isClearable?: boolean; placeholder?: string; isDisabled?: boolean; toggleAriaLabel?: string; @@ -50,6 +54,7 @@ function TypeaheadSelect({ onChange, options, allowCreate = false, + isClearable = false, placeholder = 'Type to search...', isDisabled = false, toggleAriaLabel, @@ -63,6 +68,7 @@ function TypeaheadSelect({ const [isOpen, setIsOpen] = useState(false); const [inputValue, setInputValue] = useState(''); const [focusedItemIndex, setFocusedItemIndex] = useState(-1); + const inputRef = useRef(null); function onSelect( _event: ReactMouseEvent | undefined, @@ -142,6 +148,17 @@ function TypeaheadSelect({ return selectedOption?.label || selectedOption?.value || value; }; + const hasClearButton = isClearable && !isDisabled && Boolean(value || inputValue); + + function onClear(event: ReactMouseEvent) { + event.stopPropagation(); + onChange(''); + setInputValue(''); + setFocusedItemIndex(-1); + setIsOpen(false); + inputRef.current?.focus(); + } + const toggle = (toggleRef: Ref) => ( + + + ); diff --git a/ui/apps/platform/src/Containers/Policies/Wizard/Step4/InclusionScopeCard.tsx b/ui/apps/platform/src/Containers/Policies/Wizard/Step4/InclusionScopeCard.tsx index 3f98338147a7c..1b7c292216b5c 100644 --- a/ui/apps/platform/src/Containers/Policies/Wizard/Step4/InclusionScopeCard.tsx +++ b/ui/apps/platform/src/Containers/Policies/Wizard/Step4/InclusionScopeCard.tsx @@ -2,15 +2,19 @@ import { useState } from 'react'; import type { FormEvent, ReactElement } from 'react'; import { Flex, Form, FormGroup, Radio, TextInput } from '@patternfly/react-core'; +import TypeaheadSelect from 'Components/TypeaheadSelect/TypeaheadSelect'; +import type { TypeaheadSelectOption } from 'Components/TypeaheadSelect/TypeaheadSelect'; +import type { ClusterScopeObject } from 'services/RolesService'; import type { PolicyScope } from 'types/policy.proto'; import PolicyScopeCardBase from './PolicyScopeCardBase'; -type NamespaceMode = 'name' | 'label'; +type Mode = 'name' | 'label'; type InclusionScopeCardProps = { scope: PolicyScope; index: number; + clusters: ClusterScopeObject[]; handleChange: (event: FormEvent, value: string) => void; setFieldValue: (field: string, value: unknown, shouldValidate?: boolean) => void; onDelete: () => void; @@ -19,27 +23,102 @@ type InclusionScopeCardProps = { function InclusionScopeCard({ scope, index, + clusters, handleChange, setFieldValue, onDelete, }: InclusionScopeCardProps): ReactElement { - const [namespaceMode, setNamespaceMode] = useState( + const scopePath = `scope[${index}]`; + const [clusterMode, setClusterMode] = useState(scope.clusterLabel ? 'label' : 'name'); + const [namespaceMode, setNamespaceMode] = useState( scope.namespaceLabel ? 'label' : 'name' ); - function handleChangeNamespaceMode(mode: NamespaceMode) { + const clusterName = clusters.find((cluster) => cluster.id === scope.cluster)?.name; + + const clusterOptions: TypeaheadSelectOption[] = clusters.map((cluster) => ({ + value: cluster.id, + label: cluster.name, + })); + + function handleChangeNamespaceMode(mode: Mode) { setNamespaceMode(mode); if (mode === 'name') { - setFieldValue(`scope[${index}].namespaceLabel`, null); + setFieldValue(`${scopePath}.namespaceLabel`, null); + } else { + setFieldValue(`${scopePath}.namespace`, ''); + } + } + + function handleChangeClusterMode(mode: Mode) { + setClusterMode(mode); + + if (mode === 'name') { + setFieldValue(`${scopePath}.clusterLabel`, null); } else { - setFieldValue(`scope[${index}].namespace`, ''); + setFieldValue(`${scopePath}.cluster`, ''); } } return ( - +
+ + + handleChangeClusterMode('name')} + /> + handleChangeClusterMode('label')} + /> + + {clusterMode === 'name' ? ( + + setFieldValue(`${scopePath}.cluster`, clusterId) + } + options={clusterOptions} + placeholder="Select a cluster" + className="pf-v5-u-w-100" + isClearable + /> + ) : ( + + + + + )} + void; children: ReactNode; + scope: PolicyScope | undefined; + clusterName?: string; }; function PolicyScopeCardBase({ title, onDelete, + scope, + clusterName, children, }: PolicyScopeCardBaseProps): ReactElement { + let clusterDisplay = 'Cluster: all'; + if (scope?.clusterLabel?.key) { + const { key, value } = scope.clusterLabel; + clusterDisplay = `Cluster label: ${key}${value ? `=${value}` : ''}`; + } else if (scope?.cluster) { + clusterDisplay = `Cluster: ${clusterName ?? scope.cluster}`; + } + + let namespaceDisplay = 'Namespace: all'; + if (scope?.namespaceLabel?.key) { + const { key, value } = scope.namespaceLabel; + namespaceDisplay = `Namespace label: ${key}${value ? `=${value}` : ''}`; + } else if (scope?.namespace) { + namespaceDisplay = `Namespace: ${scope.namespace}`; + } + + let deploymentDisplay = 'Deployment: all'; + if (scope?.label) { + const { label } = scope; + deploymentDisplay = `Deployment label: ${label.key}${label.value ? `=${label.value}` : ''}`; + } + return ( {title} {children} + + Applies to: + + + {[ + `(${clusterDisplay})`, + `AND (${namespaceDisplay})`, + `AND (${deploymentDisplay})`, + ].join('\n')} + + + ); } diff --git a/ui/apps/platform/src/Containers/Policies/Wizard/Step4/PolicyScopeForm.tsx b/ui/apps/platform/src/Containers/Policies/Wizard/Step4/PolicyScopeForm.tsx index d2ed0b54ea5e9..485f6059bfb4e 100644 --- a/ui/apps/platform/src/Containers/Policies/Wizard/Step4/PolicyScopeForm.tsx +++ b/ui/apps/platform/src/Containers/Policies/Wizard/Step4/PolicyScopeForm.tsx @@ -162,6 +162,7 @@ function PolicyScopeForm(): ReactElement { deleteInclusionScope(index)} diff --git a/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.test.ts b/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.test.ts index ce0998ec3e814..fa54a5a66f82d 100644 --- a/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.test.ts +++ b/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.test.ts @@ -178,6 +178,10 @@ describe('Step 4', () => { scope: [ { cluster: 'non-empty', + clusterLabel: { + key: 'non-empty', + value: 'non-empty', + }, namespace: 'non-empty', namespaceLabel: { key: 'non-empty', @@ -200,6 +204,10 @@ describe('Step 4', () => { scope: [ { cluster: 'non-empty', + clusterLabel: { + key: 'non-empty', + value: 'non-empty', + }, namespace: 'non-empty', namespaceLabel: { key: 'non-empty', diff --git a/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.ts b/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.ts index adee9eacf9cf9..5b5f391f78888 100644 --- a/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.ts +++ b/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.ts @@ -162,6 +162,10 @@ const validationSchemaStep3: yup.ObjectSchema = yup.object().shape( [['value', 'arrayValue']] ); +// Formik normalizes values for Yup validation by converting '' to undefined (see `prepareDataForValidation`). +// Even though `.defined()` seems more correct per Yup docs for “present but possibly empty”, Formik’s +// normalization makes it behave unexpectedly for optional text inputs. We use `.ensure()` to cast +// undefined/null back to '' and keep our custom `.test()` logic working with strings. const labelSchema = yup .object({ key: yup.string().ensure(), @@ -186,6 +190,7 @@ export const validationSchemaStep4: yup.ObjectSchema = yup.ob yup .object({ cluster: yup.string().ensure(), + clusterLabel: labelSchema, namespace: yup.string().ensure(), namespaceLabel: labelSchema, label: labelSchema, @@ -196,6 +201,8 @@ export const validationSchemaStep4: yup.ObjectSchema = yup.ob (scope) => Boolean( scope?.cluster.trim() || + scope?.clusterLabel?.key.trim() || + scope?.clusterLabel?.value.trim() || scope?.namespace.trim() || scope?.label?.key.trim() || scope?.label?.value.trim() || diff --git a/ui/apps/platform/src/Containers/Policies/policies.utils.test.ts b/ui/apps/platform/src/Containers/Policies/policies.utils.test.ts index 33a8b65f4245c..06e2396804526 100644 --- a/ui/apps/platform/src/Containers/Policies/policies.utils.test.ts +++ b/ui/apps/platform/src/Containers/Policies/policies.utils.test.ts @@ -29,6 +29,7 @@ describe('policies.utils', () => { namespace: 'kube-*', label: { key: 'app', value: 'archlinux' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value' }, }, }, image: null, @@ -53,12 +54,14 @@ describe('policies.utils', () => { namespace: 'ui-testing', label: { key: 'app', value: 'include1' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value1' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value1' }, }, { cluster: '5c5c9aae-9c92-4648-88a2-9e593c225fa1', namespace: 'ui-testing2', label: { key: 'app', value: 'include2' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value2' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value2' }, }, ], severity: 'LOW_SEVERITY', @@ -152,6 +155,7 @@ describe('policies.utils', () => { namespace: 'kube-*', label: { key: 'app', value: 'archlinux' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value' }, }, }, image: null, @@ -176,12 +180,14 @@ describe('policies.utils', () => { namespace: 'ui-testing', label: { key: 'app', value: 'include1' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value1' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value1' }, }, { cluster: '5c5c9aae-9c92-4648-88a2-9e593c225fa1', namespace: 'ui-testing2', label: { key: 'app', value: 'include2' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value2' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value2' }, }, ], severity: 'LOW_SEVERITY', @@ -273,6 +279,7 @@ describe('policies.utils', () => { namespace: 'kube-*', label: { key: 'app', value: 'archlinux' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value' }, }, }, ], @@ -357,6 +364,7 @@ describe('policies.utils', () => { namespace: 'kube-*', label: { key: 'app', value: 'archlinux' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value' }, }, }, image: null, @@ -378,12 +386,14 @@ describe('policies.utils', () => { namespace: 'ui-testing', label: { key: 'app', value: 'include1' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value1' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value1' }, }, { cluster: '5c5c9aae-9c92-4648-88a2-9e593c225fa1', namespace: 'ui-testing2', label: { key: 'app', value: 'include2' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value2' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value2' }, }, ], severity: 'LOW_SEVERITY', @@ -477,6 +487,7 @@ describe('policies.utils', () => { namespace: 'kube-*', label: { key: 'app', value: 'archlinux' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value' }, }, }, image: null, @@ -501,12 +512,14 @@ describe('policies.utils', () => { namespace: 'ui-testing', label: { key: 'app', value: 'include1' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value1' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value1' }, }, { cluster: '5c5c9aae-9c92-4648-88a2-9e593c225fa1', namespace: 'ui-testing2', label: { key: 'app', value: 'include2' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value2' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value2' }, }, ], severity: 'LOW_SEVERITY', @@ -598,6 +611,7 @@ describe('policies.utils', () => { namespace: 'kube-*', label: { key: 'app', value: 'archlinux' }, namespaceLabel: { key: 'namespace-key', value: 'namespace-value' }, + clusterLabel: { key: 'cluster-key', value: 'cluster-value' }, }, }, ], diff --git a/ui/apps/platform/src/Containers/Policies/policies.utils.ts b/ui/apps/platform/src/Containers/Policies/policies.utils.ts index 795cea2b4aa05..1d011dae38b17 100644 --- a/ui/apps/platform/src/Containers/Policies/policies.utils.ts +++ b/ui/apps/platform/src/Containers/Policies/policies.utils.ts @@ -289,6 +289,7 @@ export type WizardExcludedScope = { */ export type WizardScope = WizardExcludedScope & { + clusterLabel: WizardScopeLabel | null; namespaceLabel: WizardScopeLabel | null; }; @@ -299,6 +300,7 @@ export type WizardScopeLabel = { export const initialScope: WizardScope = { cluster: '', + clusterLabel: null, namespace: '', namespaceLabel: null, label: null, diff --git a/ui/apps/platform/src/types/policy.proto.ts b/ui/apps/platform/src/types/policy.proto.ts index 77d554fd7adf0..8a781b72208ab 100644 --- a/ui/apps/platform/src/types/policy.proto.ts +++ b/ui/apps/platform/src/types/policy.proto.ts @@ -88,6 +88,7 @@ export type PolicyBaseExclusion = { // TODO prefer initial values instead of optional properties while adding a new policy? export type PolicyScope = { cluster: string; + clusterLabel: PolicyScopeLabel | null; namespace: string; namespaceLabel: PolicyScopeLabel | null; label: PolicyScopeLabel | null;