-
Notifications
You must be signed in to change notification settings - Fork 167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Labels editing and validation #3426
base: main
Are you sure you want to change the base?
Changes from 3 commits
11aa082
62c8032
14ff57d
3dfe1d3
f6a8462
d10592a
be6cb6d
eed547a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,223 +1,170 @@ | ||
import * as React from 'react'; | ||
import { | ||
Button, | ||
Form, | ||
FormGroup, | ||
FormHelperText, | ||
HelperText, | ||
HelperTextItem, | ||
Label, | ||
LabelGroup, | ||
Modal, | ||
TextInput, | ||
} from '@patternfly/react-core'; | ||
import { ExclamationCircleIcon } from '@patternfly/react-icons'; | ||
import DashboardDescriptionListGroup, { | ||
DashboardDescriptionListGroupProps, | ||
} from './DashboardDescriptionListGroup'; | ||
import React, { useState } from 'react'; | ||
import { Label, LabelGroup, Alert, AlertVariant } from '@patternfly/react-core'; | ||
import DashboardDescriptionListGroup from './DashboardDescriptionListGroup'; | ||
|
||
type EditableTextDescriptionListGroupProps = Partial< | ||
Pick<DashboardDescriptionListGroupProps, 'title' | 'contentWhenEmpty'> | ||
> & { | ||
interface EditableLabelsProps { | ||
labels: string[]; | ||
saveEditedLabels: (labels: string[]) => Promise<unknown>; | ||
allExistingKeys?: string[]; | ||
onLabelsChange: (labels: string[]) => Promise<void>; | ||
isArchive?: boolean; | ||
}; | ||
allExistingKeys?: string[]; | ||
title?: string; | ||
contentWhenEmpty?: string; | ||
} | ||
|
||
const EditableLabelsDescriptionListGroup: React.FC<EditableTextDescriptionListGroupProps> = ({ | ||
export const EditableLabelsDescriptionListGroup: React.FC<EditableLabelsProps> = ({ | ||
title = 'Labels', | ||
contentWhenEmpty = 'No labels', | ||
labels, | ||
saveEditedLabels, | ||
onLabelsChange, | ||
isArchive, | ||
allExistingKeys = labels, | ||
}) => { | ||
const [isEditing, setIsEditing] = React.useState(false); | ||
const [unsavedLabels, setUnsavedLabels] = React.useState(labels); | ||
const [isSavingEdits, setIsSavingEdits] = React.useState(false); | ||
const [isEditing, setIsEditing] = useState(false); | ||
const [isSavingEdits, setIsSavingEdits] = useState(false); | ||
const [unsavedLabels, setUnsavedLabels] = useState(labels); | ||
const [labelErrors, setLabelErrors] = useState<{ [key: string]: string }>({}); | ||
|
||
const editUnsavedLabel = (newText: string, index: number) => { | ||
if (isSavingEdits) { | ||
return; | ||
const reservedKeys = [ | ||
...allExistingKeys.filter((key) => !labels.includes(key)), | ||
...unsavedLabels, | ||
]; | ||
|
||
const validateLabel = (text: string): string | null => { | ||
if (reservedKeys.includes(text)) { | ||
return `"${text}" already exists. Use a unique name that doesn't match any existing key or property`; | ||
} | ||
const copy = [...unsavedLabels]; | ||
copy[index] = newText; | ||
setUnsavedLabels(copy); | ||
if (text.length > 63) { | ||
return "Label text can't exceed 63 characters"; | ||
} | ||
return null; | ||
}; | ||
|
||
const removeUnsavedLabel = (text: string) => { | ||
if (isSavingEdits) { | ||
return; | ||
} | ||
setUnsavedLabels(unsavedLabels.filter((label) => label !== text)); | ||
}; | ||
const addUnsavedLabel = (text: string) => { | ||
if (isSavingEdits) { | ||
return; | ||
} | ||
setUnsavedLabels([...unsavedLabels, text]); | ||
}; | ||
|
||
// Don't allow a label that matches a non-label property key or another label (as they stand before saving) | ||
// Note that this means if you remove a label and add it back before saving, that is valid | ||
const reservedKeys = [ | ||
...allExistingKeys.filter((key) => !labels.includes(key)), | ||
...unsavedLabels, | ||
]; | ||
|
||
const [isAddLabelModalOpen, setIsAddLabelModalOpen] = React.useState(false); | ||
const [addLabelInputValue, setAddLabelInputValue] = React.useState(''); | ||
const addLabelInputRef = React.useRef<HTMLInputElement>(null); | ||
let addLabelValidationError: string | null = null; | ||
if (reservedKeys.includes(addLabelInputValue)) { | ||
addLabelValidationError = 'Label must not match an existing label or property key'; | ||
} else if (addLabelInputValue.length > 63) { | ||
addLabelValidationError = "Label text can't exceed 63 characters"; | ||
} | ||
|
||
const toggleAddLabelModal = () => { | ||
setAddLabelInputValue(''); | ||
setIsAddLabelModalOpen(!isAddLabelModalOpen); | ||
}; | ||
React.useEffect(() => { | ||
if (isAddLabelModalOpen && addLabelInputRef.current) { | ||
addLabelInputRef.current.focus(); | ||
} | ||
}, [isAddLabelModalOpen]); | ||
|
||
const addLabelModalSubmitDisabled = !addLabelInputValue || !!addLabelValidationError; | ||
const submitAddLabelModal = (event?: React.FormEvent) => { | ||
event?.preventDefault(); | ||
if (!addLabelModalSubmitDisabled) { | ||
addUnsavedLabel(addLabelInputValue); | ||
toggleAddLabelModal(); | ||
const handleEditComplete = (_event: MouseEvent | KeyboardEvent, newText: string) => { | ||
const error = validateLabel(newText); | ||
if (error) { | ||
setLabelErrors((prev) => ({ ...prev, [newText]: error })); | ||
} else { | ||
setUnsavedLabels((prev) => [...prev, newText]); | ||
setLabelErrors({}); | ||
} | ||
}; | ||
|
||
return ( | ||
<> | ||
<DashboardDescriptionListGroup | ||
title={title} | ||
isEmpty={labels.length === 0} | ||
contentWhenEmpty={contentWhenEmpty} | ||
isEditable={!isArchive} | ||
isEditing={isEditing} | ||
isSavingEdits={isSavingEdits} | ||
contentWhenEditing={ | ||
<DashboardDescriptionListGroup | ||
data-testid="editable-labels-group" | ||
title={title} | ||
isEmpty={labels.length === 0} | ||
contentWhenEmpty={contentWhenEmpty} | ||
isEditable={!isArchive} | ||
isEditing={isEditing} | ||
isSavingEdits={isSavingEdits} | ||
isSaveDisabled={Object.keys(labelErrors).length > 0} | ||
contentWhenEditing={ | ||
<> | ||
<LabelGroup | ||
data-testid="label-group" | ||
data-testid="editable-label-group" | ||
isEditable={!isSavingEdits} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't see this testid used anywhere. |
||
numLabels={unsavedLabels.length} | ||
numLabels={10} | ||
expandedText="Show Less" | ||
collapsedText="Show More" | ||
addLabelControl={ | ||
!isSavingEdits && ( | ||
<Label | ||
textMaxWidth="40ch" | ||
data-testid="add-label-button" | ||
color="blue" | ||
variant="outline" | ||
isOverflowLabel | ||
onClick={toggleAddLabelModal} | ||
isEditable | ||
editableProps={{ | ||
'aria-label': 'Add label', | ||
defaultValue: '', | ||
'data-testid': 'add-label-input', | ||
}} | ||
onEditComplete={(_event, newText) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The way I understand the mockup, I don't think we want the "Add label" button to be an editable label itself. It can just be a button like before, but instead of its There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 to @mturley. |
||
const error = validateLabel(newText); | ||
if (error) { | ||
setLabelErrors((prev) => ({ ...prev, [newText]: error })); | ||
} else { | ||
setUnsavedLabels((prev) => [...prev, newText]); | ||
const newErrors = { ...labelErrors }; | ||
delete newErrors[newText]; | ||
setLabelErrors(newErrors); | ||
} | ||
}} | ||
> | ||
Add label | ||
</Label> | ||
) | ||
} | ||
> | ||
{unsavedLabels.map((label, index) => ( | ||
{unsavedLabels.map((label) => ( | ||
<Label | ||
key={label} | ||
color="blue" | ||
data-testid="label" | ||
data-testid={`editable-label-${label}`} | ||
key={`${label}-${labelErrors[label] ? 'error' : 'normal'}`} | ||
color={labelErrors[label] ? 'red' : 'blue'} | ||
isEditable={!isSavingEdits} | ||
editableProps={{ 'aria-label': `Editable label with text ${label}` }} | ||
onClose={() => removeUnsavedLabel(label)} | ||
closeBtnProps={{ isDisabled: isSavingEdits }} | ||
onEditComplete={(_event, newText) => { | ||
if (!reservedKeys.includes(newText) && newText.length <= 63) { | ||
editUnsavedLabel(newText, index); | ||
} | ||
closeBtnProps={{ | ||
isDisabled: isSavingEdits, | ||
'data-testid': `remove-label-${label}`, | ||
}} | ||
onEditComplete={(_event, newText) => handleEditComplete(_event, newText)} | ||
editableProps={{ | ||
defaultValue: '', | ||
'aria-label': 'Edit label', | ||
'data-testid': `edit-label-input-${label}`, | ||
}} | ||
> | ||
{label} | ||
</Label> | ||
))} | ||
</LabelGroup> | ||
{Object.keys(labelErrors).length > 0 && ( | ||
<Alert | ||
data-testid="label-error-alert" | ||
variant={AlertVariant.danger} | ||
isInline | ||
title={Object.values(labelErrors)[0]} | ||
aria-live="polite" | ||
/> | ||
)} | ||
ppadti marked this conversation as resolved.
Show resolved
Hide resolved
|
||
</> | ||
} | ||
onEditClick={() => { | ||
setUnsavedLabels(labels); | ||
setLabelErrors({}); | ||
setIsEditing(true); | ||
}} | ||
onSaveEditsClick={async () => { | ||
if (Object.keys(labelErrors).length > 0) { | ||
return; | ||
} | ||
onEditClick={() => { | ||
setUnsavedLabels(labels); | ||
setIsEditing(true); | ||
}} | ||
onSaveEditsClick={async () => { | ||
setIsSavingEdits(true); | ||
try { | ||
await saveEditedLabels(unsavedLabels); | ||
} finally { | ||
setIsSavingEdits(false); | ||
} | ||
setIsSavingEdits(true); | ||
try { | ||
await onLabelsChange(unsavedLabels); | ||
} finally { | ||
setIsSavingEdits(false); | ||
setIsEditing(false); | ||
}} | ||
onDiscardEditsClick={() => { | ||
setUnsavedLabels(labels); | ||
setIsEditing(false); | ||
}} | ||
> | ||
<LabelGroup data-testid="label-group"> | ||
{labels.map((label) => ( | ||
<Label key={label} color="blue" data-testid="label"> | ||
{label} | ||
</Label> | ||
))} | ||
</LabelGroup> | ||
</DashboardDescriptionListGroup> | ||
{isAddLabelModalOpen ? ( | ||
<Modal | ||
variant="small" | ||
title="Add label" | ||
isOpen | ||
onClose={toggleAddLabelModal} | ||
actions={[ | ||
<Button | ||
key="save" | ||
variant="primary" | ||
form="add-label-form" | ||
onClick={submitAddLabelModal} | ||
isDisabled={addLabelModalSubmitDisabled} | ||
> | ||
Save | ||
</Button>, | ||
<Button key="cancel" variant="link" onClick={toggleAddLabelModal}> | ||
Cancel | ||
</Button>, | ||
]} | ||
> | ||
<Form id="add-label-form" onSubmit={submitAddLabelModal}> | ||
<FormGroup label="Label text" fieldId="add-label-form-label-text" isRequired> | ||
<TextInput | ||
type="text" | ||
id="add-label-form-label-text" | ||
name="add-label-form-label-text" | ||
value={addLabelInputValue} | ||
onChange={(_event: React.FormEvent<HTMLInputElement>, value: string) => | ||
setAddLabelInputValue(value) | ||
} | ||
ref={addLabelInputRef} | ||
isRequired | ||
validated={addLabelValidationError ? 'error' : 'default'} | ||
/> | ||
{addLabelValidationError && ( | ||
<FormHelperText> | ||
<HelperText> | ||
<HelperTextItem icon={<ExclamationCircleIcon />} variant="error"> | ||
{addLabelValidationError} | ||
</HelperTextItem> | ||
</HelperText> | ||
</FormHelperText> | ||
)} | ||
</FormGroup> | ||
</Form> | ||
</Modal> | ||
) : null} | ||
</> | ||
} | ||
}} | ||
onDiscardEditsClick={() => { | ||
setUnsavedLabels(labels); | ||
setIsEditing(false); | ||
}} | ||
> | ||
<LabelGroup data-testid="display-label-group"> | ||
{labels.map((label) => ( | ||
<Label key={label} color="blue" data-testid="label"> | ||
{label} | ||
</Label> | ||
))} | ||
</LabelGroup> | ||
</DashboardDescriptionListGroup> | ||
); | ||
}; | ||
|
||
export default EditableLabelsDescriptionListGroup; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this test-id necessary? I don't see this test-id used anywhere.