diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index f70d81389b..1aaddf729e 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -608,6 +608,7 @@ "Base domain resource group name": "Base domain resource group name", "Basic information": "Basic information", "Binding name": "Binding name", + "Bold": "Bold", "Branch": "Branch", "Bucket": "Bucket", "Bucket name": "Bucket name", @@ -1373,6 +1374,7 @@ "Edit application set - Pull model": "Edit application set - Pull model", "Edit application set - push model": "Edit application set - push model", "Edit channel": "Edit channel", + "Edit cluster description": "Edit cluster description", "Edit credential": "Edit credential", "Edit MultiClusterHub": "Edit MultiClusterHub", "Edit placement": "Edit placement", @@ -1427,6 +1429,7 @@ "Enter a name for this search query": "Enter a name for this search query", "Enter a value": "Enter a value", "Enter bucket information": "Enter bucket information", + "Enter cluster description": "Enter cluster description", "Enter CPU architecture": "Enter CPU architecture", "Enter description (optional)": "Enter description (optional)", "Enter display name (optional)": "Enter display name (optional)", @@ -1927,6 +1930,7 @@ "IP address": "IP address", "It will not be destroyed when you perform this action._one": "It will not be destroyed when you perform this action.", "It will not be destroyed when you perform this action._other": "They will not be destroyed when you perform this action.", + "Italic": "Italic", "items": "items", "items per page": "items per page", "Job": "Job", @@ -1990,6 +1994,8 @@ "Let’s get started.": "Let’s get started.", "Limit the number of clusters selected": "Limit the number of clusters selected", "Limits": "Limits", + "Link": "Link", + "List": "List", "List elements": "List elements", "Loading": "Loading", "Loading diff": "Loading diff", diff --git a/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/ClusterDetails/ClusterOverview/ClusterOverview.tsx b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/ClusterDetails/ClusterOverview/ClusterOverview.tsx index f1466fa450..3dc13143f3 100644 --- a/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/ClusterDetails/ClusterOverview/ClusterOverview.tsx +++ b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/ClusterDetails/ClusterOverview/ClusterOverview.tsx @@ -5,7 +5,7 @@ import { ClusterDeploymentK8sResource, getClusterProperties, } from '@openshift-assisted/ui-lib/cim' -import { AlertVariant, ButtonVariant, Content, PageSection, Popover } from '@patternfly/react-core' +import { AlertVariant, ButtonVariant, PageSection, Popover } from '@patternfly/react-core' import { Modal, ModalVariant } from '@patternfly/react-core/deprecated' import { ExternalLinkAltIcon, OutlinedQuestionCircleIcon, PencilAltIcon } from '@patternfly/react-icons' import { Markdown } from '@redhat-cloud-services/rule-components/Markdown' @@ -47,6 +47,7 @@ import AIClusterDetails from '../../components/cim/AIClusterDetails' import AIHypershiftClusterDetails from '../../components/cim/AIHypershiftClusterDetails' import { ClusterStatusMessageAlert } from '../../components/ClusterStatusMessageAlert' import { DistributionField } from '../../components/DistributionField' +import { EditDescription } from '../../components/EditDescription' import { EditLabels } from '../../components/EditLabels' import { HiveNotification } from '../../components/HiveNotification' import HypershiftClusterDetails from '../../components/HypershiftClusterDetails' @@ -85,6 +86,7 @@ export function ClusterOverviewPageContent() { const localHubName = useLocalHubName() const clusterDescriptionAnnotation = 'console.open-cluster-management.io/description' const [showEditLabels, setShowEditLabels] = useState(false) + const [showEditDescription, setShowEditDescription] = useState(false) const [showChannelSelectModal, setShowChannelSelectModal] = useState(false) const [curatorSummaryModalIsOpen, setCuratorSummaryModalIsOpen] = useState(false) const { projects } = useProjects() @@ -342,12 +344,20 @@ export function ClusterOverviewPageContent() { description: { key: t('Description'), value: cluster?.annotations?.[clusterDescriptionAnnotation] ? ( - - - + ) : ( '-' ), + keyAction: cluster?.isManaged && ( + setShowEditDescription(true)} + variant={ButtonVariant.plain} + aria-label={t('Edit cluster description')} + rbac={[rbacPatch(ManagedClusterDefinition, undefined, cluster?.name)]} + > + + + ), }, } @@ -506,6 +516,18 @@ export function ClusterOverviewPageContent() { displayName={cluster.displayName} close={() => setShowEditLabels(false)} /> + setShowEditDescription(false)} + /> {details} { + beforeEach(() => nockIgnoreApiPaths()) + + test('renders with existing description', () => { + const { getByDisplayValue } = render( {}} />) + expect(getByDisplayValue('Initial description')).toBeInTheDocument() + }) + + test('has zero accessibility defects', async () => { + const { container } = render( {}} />) + expect(await axe(container)).toHaveNoViolations() + }) + + test('can update description', async () => { + const { getByLabelText, getByRole } = render( {}} />) + const textarea = getByLabelText('Description') + + userEvent.clear(textarea) + userEvent.type(textarea, 'Updated description text') + + const nockScope = nockPatch( + { apiVersion: resource.apiVersion, kind: resource.kind, metadata: { name: resource.metadata!.name } }, + { + metadata: { + annotations: { + [CLUSTER_DESCRIPTION_ANNOTATION]: 'Updated description text', + }, + }, + } + ) + + getByRole('button', { name: /save/i }).click() + await waitFor(() => expect(nockScope.isDone()).toBeTruthy()) + }) + + test('can clear description', async () => { + const { getByLabelText, getByRole } = render( {}} />) + const textarea = getByLabelText('Description') + + userEvent.clear(textarea) + + const nockScope = nockPatch( + { apiVersion: resource.apiVersion, kind: resource.kind, metadata: { name: resource.metadata!.name } }, + { + metadata: { + annotations: { + [CLUSTER_DESCRIPTION_ANNOTATION]: null, + }, + }, + } + ) + + getByRole('button', { name: /save/i }).click() + await waitFor(() => expect(nockScope.isDone()).toBeTruthy()) + }) + + test('formatting buttons insert markdown syntax', async () => { + const { getByLabelText, getByLabelText: getByAriaLabel } = render( + {}} /> + ) + const textarea = getByLabelText('Description') as HTMLTextAreaElement + + userEvent.clear(textarea) + userEvent.type(textarea, 'test') + + textarea.setSelectionRange(0, 4) + + const boldButton = getByAriaLabel('Bold') + userEvent.click(boldButton) + + await waitFor(() => expect(textarea.value).toBe('**test**')) + }) + + test('shows errors on save failure', async () => { + const { getByLabelText, getByRole } = render( {}} />) + const textarea = getByLabelText('Description') + + userEvent.clear(textarea) + userEvent.type(textarea, 'New description') + + const nockScope = nockPatch( + { apiVersion: resource.apiVersion, kind: resource.kind, metadata: { name: resource.metadata!.name } }, + { + metadata: { + annotations: { + [CLUSTER_DESCRIPTION_ANNOTATION]: 'New description', + }, + }, + }, + mockBadRequestStatus + ) + + getByRole('button', { name: /save/i }).click() + await waitFor(() => expect(nockScope.isDone()).toBeTruthy()) + }) + + test('works without existing annotations', () => { + const resourceWithoutAnnotations: IResource = { + apiVersion: ManagedClusterApiVersion, + kind: ManagedClusterKind, + metadata: { + name: 'test-cluster', + }, + } + const { getByLabelText } = render( {}} />) + const textarea = getByLabelText('Description') as HTMLTextAreaElement + expect(textarea.value).toBe('') + }) +}) diff --git a/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/EditDescription.tsx b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/EditDescription.tsx new file mode 100644 index 0000000000..d4cadcfbc8 --- /dev/null +++ b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/EditDescription.tsx @@ -0,0 +1,162 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { IResource } from '../../../../../resources' +import { patchResource } from '../../../../../resources/utils' +import { + AcmAlertContext, + AcmAlertGroup, + AcmForm, + AcmModal, + AcmSubmit, + IAlertContext, +} from '../../../../../ui-components' +import { + ActionGroup, + Button, + FormGroup, + TextArea, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core' +import { ModalVariant } from '@patternfly/react-core/deprecated' +import { BoldIcon, ItalicIcon, LinkIcon, ListIcon } from '@patternfly/react-icons' +import { useLayoutEffect, useRef, useState } from 'react' +import { useTranslation } from '../../../../../lib/acm-i18next' +import { getErrorInfo } from '../../../../../components/ErrorPage' + +const CLUSTER_DESCRIPTION_ANNOTATION = 'console.open-cluster-management.io/description' + +export function EditDescription(props: { resource?: IResource; displayName?: string; close: () => void }) { + const { t } = useTranslation() + const [description, setDescription] = useState('') + const textAreaRef = useRef(null) + const isOpen = props.resource !== undefined + + useLayoutEffect(() => { + if (isOpen) { + const desc = props.resource?.metadata?.annotations?.[CLUSTER_DESCRIPTION_ANNOTATION] ?? '' + setDescription(desc) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]) + + const handleSave = (alertContext: IAlertContext) => { + alertContext.clearAlerts() + const resourceToPatch: IResource = { + apiVersion: props.resource!.apiVersion, + kind: props.resource!.kind, + metadata: { + name: props.resource!.metadata!.name, + namespace: props.resource!.metadata?.namespace, + }, + } + + const patch = { + metadata: { + annotations: { + [CLUSTER_DESCRIPTION_ANNOTATION]: description.trim() || null, + }, + }, + } + + return patchResource(resourceToPatch, patch) + .promise.then(() => { + props.close() + }) + .catch((err) => { + alertContext.addAlert(getErrorInfo(err, t)) + }) + } + + const insertMarkdown = (prefix: string, suffix: string = prefix) => { + const textarea = textAreaRef.current + if (!textarea) return + + const start = textarea.selectionStart + const end = textarea.selectionEnd + const selectedText = description.substring(start, end) + const beforeText = description.substring(0, start) + const afterText = description.substring(end) + + const newText = beforeText + prefix + selectedText + suffix + afterText + setDescription(newText) + + // Place cursor between opening and closing markdown markers (e.g., **|** for typing) + setTimeout(() => { + const scrollPosition = textarea.scrollTop + textarea.focus() + const newPosition = start + prefix.length + selectedText.length + textarea.setSelectionRange(newPosition, newPosition) + textarea.scrollTop = scrollPosition + }, 0) + } + + return ( + + + {(alertContext) => ( + + + + + + + + + + + + + + + + + + + + + +