diff --git a/frontend/packages/react-form-wizard/src/index.ts b/frontend/packages/react-form-wizard/src/index.ts index e5bd440b83a..8c07a4533af 100644 --- a/frontend/packages/react-form-wizard/src/index.ts +++ b/frontend/packages/react-form-wizard/src/index.ts @@ -17,6 +17,7 @@ export * from './inputs/WizHidden' export * from './inputs/WizItemSelector' export * from './inputs/WizItemText' export * from './inputs/WizKeyValue' +export * from './inputs/WizLabelSelect' export * from './inputs/WizMultiSelect' export * from './inputs/WizNumberInput' export * from './inputs/WizRadio' diff --git a/frontend/packages/react-form-wizard/src/inputs/WizLabelSelect.tsx b/frontend/packages/react-form-wizard/src/inputs/WizLabelSelect.tsx new file mode 100644 index 00000000000..3eb21086e2f --- /dev/null +++ b/frontend/packages/react-form-wizard/src/inputs/WizLabelSelect.tsx @@ -0,0 +1,165 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { Label, MenuToggle, MenuToggleElement, Select as PfSelect } from '@patternfly/react-core' +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { useStringContext } from '../contexts/StringContext' +import { getSelectPlaceholder, InputCommonProps, useInput } from './Input' +import { InputSelect, SelectListOptions } from './InputSelect' +import { WizFormGroup } from './WizFormGroup' + +import './Select.css' + +export interface WizLabelSelectOption { + label: string + value: string +} + +export type WizLabelSelectProps = InputCommonProps & { + label: string + placeholder?: string + isCreatable?: boolean + footer?: ReactNode + options: (string | WizLabelSelectOption)[] +} + +/** + * A single-select dropdown that displays the selected value as a PatternFly Label pill, + * similar to how WizMultiSelect displays its selected values. + * When no value is selected, shows a typeahead input with placeholder. + * When a value is selected, shows a simple toggle with the Label pill. + */ +export function WizLabelSelect(props: WizLabelSelectProps) { + const { value, setValue, validated, hidden, id, disabled, required } = useInput(props) + const { noResults } = useStringContext() + const { readonly, isCreatable, footer } = props + const placeholder = getSelectPlaceholder(props) + const [open, setOpen] = useState(false) + const [filteredOptions, setFilteredOptions] = useState([]) + + // Normalize options to { label, value } pairs + const normalizedOptions = useMemo( + () => props.options.map((opt) => (typeof opt === 'string' ? { label: opt, value: opt } : opt)), + [props.options] + ) + + // String options for InputSelect (uses labels for display) + const stringOptions = useMemo(() => normalizedOptions.map((opt) => opt.label), [normalizedOptions]) + + const labelToValue = useMemo(() => { + const map = new Map() + for (const opt of normalizedOptions) { + if (!map.has(opt.label)) { + map.set(opt.label, opt.value) + } + } + return map + }, [normalizedOptions]) + + useEffect(() => { + setFilteredOptions(stringOptions) + }, [stringOptions]) + + // Find display label for the current value + const displayLabel = useMemo(() => { + const match = normalizedOptions.find((opt) => opt.value === value) + return match?.label ?? value ?? '' + }, [normalizedOptions, value]) + + const onSelect = useCallback( + (selectedLabel: string | undefined) => { + if (!selectedLabel) { + setValue(undefined) + setOpen(false) + return + } + const selectedValue = labelToValue.get(selectedLabel) ?? selectedLabel + + if (selectedValue === value) { + setValue(undefined) + } else { + setValue(selectedValue) + } + setOpen(false) + }, + [setValue, value, labelToValue] + ) + + const handleSetOptions = useCallback( + (o: string[]) => { + if (o.length > 0) { + const filtered = stringOptions.filter((option) => o.includes(option)) + if (displayLabel && !stringOptions.includes(displayLabel)) { + filtered.unshift(displayLabel) + } + setFilteredOptions([...new Set([...filtered, ...o])]) + } else { + setFilteredOptions([noResults]) + } + }, + [noResults, stringOptions, displayLabel] + ) + + if (hidden) return null + + const toggle = (toggleRef: React.Ref) => + value ? ( + { + if (!open) setFilteredOptions(stringOptions) + setOpen(!open) + }} + isExpanded={open} + isDisabled={disabled || readonly} + status={validated === 'error' ? 'danger' : undefined} + > + + + ) : ( + + ) + + return ( +
+ + { + if (!isOpen) { + setOpen(false) + } + }} + toggle={toggle} + selected={displayLabel} + onSelect={(_event, value) => { + const selected = value?.toString() ?? '' + if (selected !== noResults) { + onSelect(selected) + } + }} + isScrollable + > + + + +
+ ) +} diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 5ae84dc02b5..cbaf6a54e2d 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -416,6 +416,7 @@ "Add rule": "Add rule", "Add storage class mapping": "Add storage class mapping", "Add to policy set": "Add to policy set", + "Add toleration": "Add toleration", "Add user": "Add user", "Add value": "Add value", "Add variable": "Add variable", @@ -459,6 +460,7 @@ "Allow privilege escalation": "Allow privilege escalation", "Allow privileged container": "Allow privileged container", "Allowed Cluster Service Versions": "Allowed Cluster Service Versions", + "Allows your application to be placed on clusters with specific taints. Example: To deploy on GPU clusters, add a toleration with key=gpu, operator=Equal, value=nvidia, effect=NoSelect": "Allows your application to be placed on clusters with specific taints. Example: To deploy on GPU clusters, add a toleration with key=gpu, operator=Equal, value=nvidia, effect=NoSelect", "Already enforcing": "Already enforcing", "Already informing": "Already informing", "Amazon Web Services": "Amazon Web Services", @@ -1161,6 +1163,7 @@ "decoupled control plane and workers": "decoupled control plane and workers", "dedicated control plane nodes": "dedicated control plane nodes", "Define a business application using open standards and deploy the applications using placement policies that are integrated into existing CI/CD pipelines and governance controls.": "Define a business application using open standards and deploy the applications using placement policies that are integrated into existing CI/CD pipelines and governance controls.", + "Define a new label expression": "Define a new label expression", "Define cluster granularity": "Define cluster granularity", "Define cluster set granularity": "Define cluster set granularity", "Define the level of access for the selected cluster set.": "Define the level of access for the selected cluster set.", @@ -1357,6 +1360,7 @@ "editor.bar.undo": "Undo", "editor.show.secrets": "Show secrets", "editor.toolbar": "Editor toolbar", + "Effect": "Effect", "emptystate.addCredential": "Add credential", "emptystate.credentials.msg": "No discovered clusters found. You have {{discoveryConfigTotal}} credentials. Click Configure Discovery to set up a filter for discovered clusters from your connections.", "emptystate.discoveryEnabled.msg": "Cluster discovery was configured. Return to this page later, configure Discovery again, or view documentation.", @@ -1439,6 +1443,7 @@ "Enter the Google Cloud Platform credentials": "Enter the Google Cloud Platform credentials", "Enter the HTTP proxy URL": "Enter the HTTP proxy URL", "Enter the HTTPS proxy URL": "Enter the HTTPS proxy URL", + "Enter the key": "Enter the key", "Enter the Microsoft Azure credentials": "Enter the Microsoft Azure credentials", "Enter the name": "Enter the name", "Enter the name for the credential": "Enter the name for the credential", @@ -1458,8 +1463,10 @@ "Enter the startingCSV of a Submariner subscription": "Enter the startingCSV of a Submariner subscription", "Enter the token key": "Enter the token key", "Enter the token secret name": "Enter the token secret name", + "Enter the value": "Enter the value", "Enter the version or versions": "Enter the version or versions", "Enter the VMware credentials": "Enter the VMware credentials", + "Enter toleration seconds": "Enter toleration seconds", "Enter valid search criteria to save a search.": "Enter valid search criteria to save a search.", "Enter value": "Enter value", "Enter your additional trust bundle": "Enter your additional trust bundle", @@ -1483,6 +1490,8 @@ "Enter your vSphere datacenter": "Enter your vSphere datacenter", "enter.add.label": "Enter key=value", "enter.duplicate.key": "Duplicate key: {{0}}", + "equal": "equal", + "Equal": "Equal", "equals": "equals", "equals any of": "equals any of", "error": "Error", @@ -1522,6 +1531,7 @@ "existing hosts": "existing hosts", "Existing placement": "Existing placement", "exists": "exists", + "Exists": "Exists", "Expand": "Expand", "Expand all": "Expand all", "Expand the table rows to view detailed error messages.": "Expand the table rows to view detailed error messages.", @@ -1871,6 +1881,7 @@ "Job template": "Job template", "Joined": "Joined", "Jump to the bottom": "Jump to the bottom", + "Key": "Key", "Key-value parameters to pass to the plugin": "Key-value parameters to pass to the plugin", "Kind": "Kind", "Kubeconfig": "Kubeconfig", @@ -1915,9 +1926,9 @@ "learn.submariner.additional": "Important: To get started with Submariner, your clusters must meet prerequisite criteria and configurations before installing the Submariner add-on. Read the documentation for more information on how to use Submariner.", "learn.terminology": "Learn more about the terminology", "Leave": "Leave", + "Leave empty for all effects": "Leave empty for all effects", "Leave form?": "Leave form?", "Let’s get started.": "Let’s get started.", - "Limit the number of clusters selected": "Limit the number of clusters selected", "Limits": "Limits", "List elements": "List elements", "Loading": "Loading", @@ -2021,6 +2032,7 @@ "Managing clusters just got easier": "Managing clusters just got easier", "Manual run: Set this automation to run once. After the automation runs, it is set to disabled.": "Manual run: Set this automation to run once. After the automation runs, it is set to disabled.", "Map infrastructure volume snapshot classes to guest cluster volume snapshot classes. These mappings cannot be changed after cluster creation.": "Map infrastructure volume snapshot classes to guest cluster volume snapshot classes. These mappings cannot be changed after cluster creation.", + "Match clusters using label selectors. Multiple expressions are combined using AND logic (all inputs must be true).": "Match clusters using label selectors. Multiple expressions are combined using AND logic (all inputs must be true).", "Match labels": "Match labels", "Matched Clusters": "Matched Clusters", "Matched on": "Matched on", @@ -2115,6 +2127,7 @@ "No changes": "No changes", "No changes have been made. Please modify or cancel to exit.": "No changes have been made. Please modify or cancel to exit.", "No cluster results": "No cluster results", + "No cluster sets are bound to the {{namespace}} namespace. To use this namespace for your placement, go to the Cluster sets page to configure a binding.": "No cluster sets are bound to the {{namespace}} namespace. To use this namespace for your placement, go to the Cluster sets page to configure a binding.", "No clusters": "No clusters", "No clusters are reporting status for this policy.": "No clusters are reporting status for this policy.", "No clusters available": "No clusters available", @@ -2207,6 +2220,8 @@ "None of the selected clusters has a template.": "None of the selected clusters has a template.", "None selected": "None selected", "noProxyTip": "Add a comma-separated list of sites that bypass the proxy. The hub API server address must be included in this list. By default, all cluster egress traffic is sent through the proxy, including calls to hosting and cloud provider APIs.", + "NoSelect": "NoSelect", + "NoSelectIfNew": "NoSelectIfNew", "Not available": "Not available", "Not Created": "Not Created", "Not defined": "Not defined", @@ -2221,6 +2236,7 @@ "Not set": "Not set", "Note: Resources that you do not have permission to view display a status of \"Not deployed\".": "Note: Resources that you do not have permission to view display a status of \"Not deployed\".", "Number input": "Number input", + "Number of clusters": "Number of clusters", "Number of clusters where the grouped Argo applications' resources are deployed.": "Number of clusters where the grouped Argo applications' resources are deployed.", "Number of control plane nodes": "Number of control plane nodes", "Number of nodes": "Number of nodes", @@ -2450,6 +2466,7 @@ "Pre-authorized group created": "Pre-authorized group created", "Pre-authorized user added": "Pre-authorized user added", "Pre-authorized user created": "Pre-authorized user created", + "PreferNoSelect": "PreferNoSelect", "Prerequisites and Configuration": "Prerequisites and Configuration", "Press space or enter to begin dragging, and use the arrow keys to navigate up or down. Press enter to confirm the drag, or any other key to cancel the drag operation.": "Press space or enter to begin dragging, and use the arrow keys to navigate up or down. Press enter to confirm the drag, or any other key to cancel the drag operation.", "preview.clusterPools": "Technology Preview: For more information, view documentation on Cluster pools", @@ -2710,10 +2727,12 @@ "Select the Ansible credential": "Select the Ansible credential", "Select the Argo server": "Select the Argo server", "Select the cluster sets": "Select the cluster sets", + "Select the effect": "Select the effect", "Select the existing placement": "Select the existing placement", "Select the label": "Select the label", "Select the namespace": "Select the namespace", "Select the new versions for the cluster and node pools that you want to update. This action is irreversible.": "Select the new versions for the cluster and node pools that you want to update. This action is irreversible.", + "Select the operator": "Select the operator", "Select the placement": "Select the placement", "Select the values": "Select the values", "Select timezone": "Select timezone", @@ -3324,9 +3343,11 @@ "Toggle details": "Toggle details", "Token key": "Token key", "Token secret name": "Token secret name", + "Toleration seconds": "Toleration seconds", "Tolerations": "Tolerations", "tolerations.count": "{{count}} toleration", "tolerations.count_plural": "{{count}} tolerations", + "TolerationSeconds represents the period of time the toleration tolerates the taint.": "TolerationSeconds represents the period of time the toleration tolerates the taint.", "Too many requests": "Too many requests", "tooltip.architecture": "Determines the CPU instruction set architecture of the machines in the pool. Only set this value when the architecture of the managed cluster is different from the architecture of the hub.", "tooltip.channel.type.helmrepo": "Use a Helm repository", @@ -3575,6 +3596,7 @@ "validation.missing.resource": "Missing: {{0}}", "validation.missing.value": "{{0}} is required", "Value": "Value", + "Value is required when operator is Equal": "Value is required when operator is Equal", "Value must be a valid IPv4 CIDR.": "Value must be a valid IPv4 CIDR.", "Value must be in / format.": "Value must be in / format.", "Value must be in @ format.": "Value must be in @ format.", diff --git a/frontend/src/routes/Governance/policies/CreatePolicy.test.tsx b/frontend/src/routes/Governance/policies/CreatePolicy.test.tsx index df9473fdef3..23050d0c49b 100755 --- a/frontend/src/routes/Governance/policies/CreatePolicy.test.tsx +++ b/frontend/src/routes/Governance/policies/CreatePolicy.test.tsx @@ -92,7 +92,7 @@ describe('Create Policy Page', () => { // new placement screen.getByRole('button', { name: 'New placement' }).click() - screen.getByRole('button', { name: /action/i }).click() + screen.getAllByRole('button', { name: /action/i })[0].click() screen.getByPlaceholderText(/select the label/i).click() screen.getByRole('option', { name: /cloud/i }).click() screen.getByPlaceholderText(/select the values/i).click() diff --git a/frontend/src/routes/Governance/policy-sets/CreatePolicySet.test.tsx b/frontend/src/routes/Governance/policy-sets/CreatePolicySet.test.tsx index 8f8279b3a84..55f367ee8b4 100644 --- a/frontend/src/routes/Governance/policy-sets/CreatePolicySet.test.tsx +++ b/frontend/src/routes/Governance/policy-sets/CreatePolicySet.test.tsx @@ -54,7 +54,7 @@ describe('Create Policy Page', () => { await waitForText('How do you want to select clusters?') screen.getByRole('button', { name: 'New placement' }).click() - screen.getByRole('button', { name: /action/i }).click() + screen.getAllByRole('button', { name: /action/i })[0].click() screen.getByPlaceholderText(/select the label/i).click() screen.getByRole('option', { name: /cloud/i }).click() screen.getByPlaceholderText(/select the values/i).click() diff --git a/frontend/src/wizards/Argo/ArgoWizard.test.tsx b/frontend/src/wizards/Argo/ArgoWizard.test.tsx index 617cd8a6961..e175d4db818 100644 --- a/frontend/src/wizards/Argo/ArgoWizard.test.tsx +++ b/frontend/src/wizards/Argo/ArgoWizard.test.tsx @@ -12,20 +12,14 @@ import { nockIgnoreApiPaths, nockIgnoreOperatorCheck, } from '../../lib/nock-util' -import { - createClusterVersionMock, - clickByRole, - clickByText, - typeByRole, - waitForNocks, - waitForText, -} from '../../lib/test-util' +import { createClusterVersionMock } from '../../lib/test-util' const mockUseClusterVersion = createClusterVersionMock() jest.mock('../../hooks/use-cluster-version', () => ({ useClusterVersion: () => mockUseClusterVersion(), })) +import { clickByRole, clickByText, typeByRole, waitForNocks, waitForText } from '../../lib/test-util' import { NavigationPath } from '../../NavigationPath' import { GitOpsClusterApiVersion, @@ -224,13 +218,11 @@ describe('ArgoWizard tests', () => { // placement page //===================================================================== await clickByText('New placement') - await clickByRole('button', { name: 'Action' }) + await clickByRole('button', { name: 'Action' }, 0) await clickByRole('combobox', { name: 'Select the label' }) await clickByRole('option', { name: /cloud/i }) - await clickByRole('combobox', { - name: /select the operator/i, - }) + await clickByText('equals any of') await clickByRole('option', { name: /does not equal any of/i }) await clickByRole('combobox', { @@ -337,13 +329,11 @@ describe('ArgoWizard tests', () => { // placement page //===================================================================== await clickByText('New placement') - await clickByRole('button', { name: 'Action' }) + await clickByRole('button', { name: 'Action' }, 0) await clickByRole('combobox', { name: 'Select the label' }) await clickByRole('option', { name: /cloud/i }) - await clickByRole('combobox', { - name: /select the operator/i, - }) + await clickByText('equals any of') await clickByRole('option', { name: /does not equal any of/i }) await clickByRole('combobox', { @@ -823,6 +813,17 @@ const submittedGit = [ namespace: 'http://argoserver.com', }, spec: { + tolerations: [ + { + key: 'cluster.open-cluster-management.io/unreachable', + operator: 'Exists', + }, + { + key: 'cluster.open-cluster-management.io/unavailable', + operator: 'Exists', + }, + ], + numberOfClusters: 1, predicates: [ { requiredClusterSelector: { diff --git a/frontend/src/wizards/Argo/ArgoWizard.tsx b/frontend/src/wizards/Argo/ArgoWizard.tsx index 17e2dca3be1..615a4c44920 100644 --- a/frontend/src/wizards/Argo/ArgoWizard.tsx +++ b/frontend/src/wizards/Argo/ArgoWizard.tsx @@ -913,6 +913,17 @@ function ArgoWizardPlacementSection(props: { kind: PlacementKind, metadata: { name: '', namespace: '' }, spec: { + tolerations: [ + { + key: 'cluster.open-cluster-management.io/unreachable', + operator: 'Exists', + }, + { + key: 'cluster.open-cluster-management.io/unavailable', + operator: 'Exists', + }, + ], + numberOfClusters: 1, predicates: [ { // ArgoCD pull model doesn't support the hub cluster @@ -935,7 +946,19 @@ function ArgoWizardPlacementSection(props: { apiVersion: PlacementApiVersion, kind: PlacementKind, metadata: { name: '', namespace: '' }, - spec: {}, + spec: { + tolerations: [ + { + key: 'cluster.open-cluster-management.io/unreachable', + operator: 'Exists', + }, + { + key: 'cluster.open-cluster-management.io/unavailable', + operator: 'Exists', + }, + ], + numberOfClusters: 1, + }, } as IResource) ) update(newResources) diff --git a/frontend/src/wizards/Governance/Policy/policyWizard.test.tsx b/frontend/src/wizards/Governance/Policy/policyWizard.test.tsx index 7a91f44f7c5..9c8e710bd52 100644 --- a/frontend/src/wizards/Governance/Policy/policyWizard.test.tsx +++ b/frontend/src/wizards/Governance/Policy/policyWizard.test.tsx @@ -10,6 +10,7 @@ import { } from '../../../routes/Governance/governance.sharedMocks' import { IResource } from '@patternfly-labs/react-form-wizard' +import { ReactNode } from 'react' import { BrowserRouter as Router } from 'react-router-dom-v5-compat' import { waitForText } from '../../../lib/test-util' import { Policy } from '../../../resources' @@ -28,7 +29,7 @@ describe('ExistingTemplateName', () => { }) }) -function TestPolicyWizard() { +function TestPolicyWizard(props?: { yamlEditor?: () => ReactNode }) { return ( new Promise(() => {})} onCancel={() => {}} + yamlEditor={props?.yamlEditor} /> ) @@ -285,4 +287,72 @@ describe('Policy wizard', () => { expect(input).toHaveTextContent('subscription: namespace: my-namespace') expect(input).not.toHaveTextContent('operatorGroup: targetNamespaces: - my-namespace') }) + + test('default tolerations are set when creating new placement', async () => { + render( } />) + + const nameTextbox = screen.getByRole('textbox', { name: /name/i }) + userEvent.type(nameTextbox, 'test-policy') + screen.getByPlaceholderText(/select namespace/i).click() + screen.getByRole('option', { name: /argo-server-1/i }).click() + + screen.getByRole('button', { name: /placement/i }).click() + screen.getByRole('button', { name: /new placement/i }).click() + await waitFor(() => screen.getByPlaceholderText(/select the cluster sets/i)) + + const yamlCheckBox = screen.getByRole('switch', { name: /yaml/i }) as HTMLInputElement + if (!yamlCheckBox.checked) { + userEvent.click(yamlCheckBox) + } + + await waitFor(() => { + const input = screen.getByRole('textbox', { name: /monaco/i }) as HTMLTextAreaElement + expect(input).not.toHaveValue('') + }) + + const input = screen.getByRole('textbox', { name: /monaco/i }) as HTMLTextAreaElement + const yamlContent = input.textContent ?? '' + expect(yamlContent).toContain('key: cluster.open-cluster-management.io/unreachable') + expect(yamlContent).toContain('key: cluster.open-cluster-management.io/unavailable') + expect(yamlContent).toContain('operator: Exists') + const tolerationMatches = yamlContent.match(/- key: cluster\.open-cluster-management\.io\//g) + expect(tolerationMatches).toHaveLength(2) + }) + + test('default tolerations persist after switching to existing and back to new placement', async () => { + render( } />) + + const nameTextbox = screen.getByRole('textbox', { name: /name/i }) + userEvent.type(nameTextbox, 'test-policy') + screen.getByPlaceholderText(/select namespace/i).click() + screen.getByRole('option', { name: /argo-server-1/i }).click() + + screen.getByRole('button', { name: /placement/i }).click() + screen.getByRole('button', { name: /new placement/i }).click() + await waitFor(() => screen.getByPlaceholderText(/select the cluster sets/i)) + + screen.getByRole('button', { name: /existing placement/i }).click() + await waitFor(() => screen.getByPlaceholderText(/select the placement/i)) + + screen.getByRole('button', { name: /new placement/i }).click() + await waitFor(() => screen.getByPlaceholderText(/select the cluster sets/i)) + + const yamlCheckBox = screen.getByRole('switch', { name: /yaml/i }) as HTMLInputElement + if (!yamlCheckBox.checked) { + userEvent.click(yamlCheckBox) + } + + await waitFor(() => { + const input = screen.getByRole('textbox', { name: /monaco/i }) as HTMLTextAreaElement + expect(input).not.toHaveValue('') + }) + + const input = screen.getByRole('textbox', { name: /monaco/i }) as HTMLTextAreaElement + const yamlContent = input.textContent ?? '' + expect(yamlContent).toContain('key: cluster.open-cluster-management.io/unreachable') + expect(yamlContent).toContain('key: cluster.open-cluster-management.io/unavailable') + expect(yamlContent).toContain('operator: Exists') + const tolerationMatches = yamlContent.match(/- key: cluster\.open-cluster-management\.io\//g) + expect(tolerationMatches).toHaveLength(2) + }) }) diff --git a/frontend/src/wizards/Placement/MatchExpression.css b/frontend/src/wizards/Placement/MatchExpression.css new file mode 100644 index 00000000000..a291f63c0a8 --- /dev/null +++ b/frontend/src/wizards/Placement/MatchExpression.css @@ -0,0 +1,9 @@ +.match-expression-field .pf-v6-c-menu-toggle:not(.pf-m-typeahead) { + height: calc( + var(--pf-t--global--font--size--body--default) * var(--pf-t--global--font--line-height--body) + 2 * + var(--pf-t--global--spacer--control--vertical--default) + ); + padding-block: 0; + display: flex; + align-items: center; +} diff --git a/frontend/src/wizards/Placement/MatchExpression.tsx b/frontend/src/wizards/Placement/MatchExpression.tsx index 78d4b134d97..99f7b3811dd 100644 --- a/frontend/src/wizards/Placement/MatchExpression.tsx +++ b/frontend/src/wizards/Placement/MatchExpression.tsx @@ -1,60 +1,64 @@ /* Copyright Contributors to the Open Cluster Management project */ -import { Flex } from '@patternfly/react-core' +import { Flex, Label } from '@patternfly/react-core' import set from 'set-value' import { ItemContext, useItem, - WizSelect, + WizLabelSelect, WizMultiSelect, - WizSingleSelect, WizStringsInput, WizTextInput, } from '@patternfly-labs/react-form-wizard' import { IExpression } from '../common/resources/IMatchExpression' import { useTranslation } from '../../lib/acm-i18next' +import './MatchExpression.css' export function MatchExpression(props: { labelValuesMap?: Record }) { const labelValuesMap = props.labelValuesMap const { t } = useTranslation() return ( - {labelValuesMap ? ( - set(item as object, 'values', [])} - /> - ) : ( - + {labelValuesMap ? ( + set(item as object, 'values', [])} + /> + ) : ( + set(item as object, 'values', [])} + /> + )} + +
+ set(item as object, 'values', [])} + onValueChange={(value, item) => { + switch (value) { + case 'Exists': + case 'DoesNotExist': + set(item as object, 'values', undefined) + break + } + }} /> - )} - { - switch (value) { - case 'Exists': - case 'DoesNotExist': - set(item, 'values', undefined) - break - } - }} - /> +
{labelValuesMap ? ( {(item: IExpression) => { @@ -122,8 +126,8 @@ export function MatchExpressionSummary(props: { expression: IExpression }) { } return ( -
- {expression?.key} {operator} {expression?.values?.map((value) => value).join(', ')} -
+ ) } diff --git a/frontend/src/wizards/Placement/Placement.tsx b/frontend/src/wizards/Placement/Placement.tsx index 64dabdbcfc9..eaf6398243e 100644 --- a/frontend/src/wizards/Placement/Placement.tsx +++ b/frontend/src/wizards/Placement/Placement.tsx @@ -10,20 +10,29 @@ import { WizMultiSelect, WizNumberInput, WizTextInput, + WizLabelSelect, } from '@patternfly-labs/react-form-wizard' -import { Alert, Button } from '@patternfly/react-core' +import { Button, Divider, ExpandableSection, Label } from '@patternfly/react-core' import { ExternalLinkAltIcon } from '@patternfly/react-icons' import get from 'get-value' -import { Fragment, ReactNode, useMemo } from 'react' +import { Fragment, ReactNode, useMemo, useState } from 'react' import set from 'set-value' import { useTranslation } from '../../lib/acm-i18next' import { useValidation } from '../../hooks/useValidation' import { IClusterSetBinding } from '../common/resources/IClusterSetBinding' -import { IPlacement, PlacementKind, PlacementType, Predicate } from '../common/resources/IPlacement' +import { IPlacement, PlacementKind, PlacementType, Predicate, Toleration } from '../common/resources/IPlacement' import { IResource } from '../common/resources/IResource' import { useLabelValuesMap } from '../common/useLabelValuesMap' import { MatchExpression, MatchExpressionCollapsed, MatchExpressionSummary } from './MatchExpression' +function TolerationCollapsed() { + const toleration = useItem() as Toleration + const { t } = useTranslation() + const key = toleration?.key ?? '' + const operator = toleration?.operator === 'Equal' ? t('equal') : t('exists') + return +} + export function Placements(props: { clusterSets: IResource[] clusterSetBindings: IClusterSetBinding[] @@ -87,8 +96,9 @@ export function Placement(props: { alertContent?: ReactNode }) { const placement = useItem() as IPlacement - const isClusterSet = placement.spec?.clusterSets?.length + const editMode = useEditMode() const { update } = useData() + const [isTolerationsExpanded, setIsTolerationsExpanded] = useState(true) const { t } = useTranslation() const { validateKubernetesResourceName } = useValidation() @@ -110,13 +120,6 @@ export function Placement(props: { /> )} - {!isClusterSet && !props.namespaceClusterSetNames.length && props.alertTitle ? ( - - {props.alertContent} - - ) : null} - - {/* */} } isInline variant="link" onClick={props.createClusterSetCallback}> @@ -139,6 +149,74 @@ export function Placement(props: { /> + + {t('Tolerations')}} + isExpanded={isTolerationsExpanded} + onToggle={(_event, expanded) => setIsTolerationsExpanded(expanded)} + isIndented + > +

+ {t( + 'Allows your application to be placed on clusters with specific taints. Example: To deploy on GPU clusters, add a toleration with key=gpu, operator=Equal, value=nvidia, effect=NoSelect' + )} +

+ {(placement.spec?.tolerations?.length ?? 0) > 0 && } + } + newValue={{ key: '', operator: 'Exists' }} + defaultCollapsed={editMode !== EditMode.Create} + collapsedPlaceholder={t('Expand to edit')} + > + + + +
+ !!value || value === 0} onValueChange={(value) => { if (value) { - // Set default value to 1 when checkbox is enabled set(placement, 'spec.numberOfClusters', 1, { preservePaths: false }) } else { - // Set to undefined when checkbox is disabled set(placement, 'spec.numberOfClusters', undefined, { preservePaths: false }) } update() @@ -157,7 +233,7 @@ export function Placement(props: { />