Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/packages/react-form-wizard/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
165 changes: 165 additions & 0 deletions frontend/packages/react-form-wizard/src/inputs/WizLabelSelect.tsx
Original file line number Diff line number Diff line change
@@ -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<string> & {
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<string[]>([])

// 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])

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const labelToValue = useMemo(() => {
const map = new Map<string, string>()
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])
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
[noResults, stringOptions, displayLabel]
)

if (hidden) return null

const toggle = (toggleRef: React.Ref<MenuToggleElement>) =>
value ? (
<MenuToggle
ref={toggleRef}
onClick={() => {
if (!open) setFilteredOptions(stringOptions)
setOpen(!open)
}}
isExpanded={open}
isDisabled={disabled || readonly}
status={validated === 'error' ? 'danger' : undefined}
>
<Label variant="outline">{displayLabel}</Label>
</MenuToggle>
) : (
<InputSelect
disabled={disabled || readonly}
validated={validated}
placeholder={placeholder}
required={required}
options={stringOptions}
setOptions={handleSetOptions}
isCreatable={isCreatable}
toggleRef={toggleRef}
value=""
onSelect={onSelect}
open={open}
setOpen={setOpen}
/>
)

return (
<div id={id}>
<WizFormGroup {...props} id={id}>
<PfSelect
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
setOpen(false)
}
}}
toggle={toggle}
selected={displayLabel}
onSelect={(_event, value) => {
const selected = value?.toString() ?? ''
if (selected !== noResults) {
onSelect(selected)
}
}}
isScrollable
>
<SelectListOptions
value={displayLabel}
allOptions={stringOptions}
filteredOptions={filteredOptions}
isCreatable={isCreatable}
footer={footer}
/>
</PfSelect>
</WizFormGroup>
</div>
)
}
24 changes: 23 additions & 1 deletion frontend/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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 <bold>Configure Discovery</bold> 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.",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1915,9 +1926,9 @@
"learn.submariner.additional": "<bold>Important: </bold>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",
Expand Down Expand Up @@ -2021,6 +2032,7 @@
"Managing clusters <bold>just got easier</bold>": "Managing clusters <bold>just got easier</bold>",
"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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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": "<bold>Technology Preview</bold>: For more information, <a>view documentation</a> on Cluster pools",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 <namespace>/<name> format.": "Value must be in <namespace>/<name> format.",
"Value must be in <user>@<domain> format.": "Value must be in <user>@<domain> format.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading