Skip to content
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

Merged
merged 15 commits into from
Nov 26, 2024
4 changes: 3 additions & 1 deletion frontend/src/components/DashboardDescriptionListGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type DashboardDescriptionListGroupProps = {
contentWhenEmpty?: React.ReactNode;
children: React.ReactNode;
groupTestId?: string;
isSaveDisabled?: boolean;
} & (({ isEditable: true } & EditableProps) | ({ isEditable?: false } & Partial<EditableProps>));

const DashboardDescriptionListGroup: React.FC<DashboardDescriptionListGroupProps> = (props) => {
Expand All @@ -57,6 +58,7 @@ const DashboardDescriptionListGroup: React.FC<DashboardDescriptionListGroupProps
editButtonTestId,
saveButtonTestId,
cancelButtonTestId,
isSaveDisabled,
} = props;
return (
<DescriptionListGroup data-testid={groupTestId}>
Expand All @@ -74,7 +76,7 @@ const DashboardDescriptionListGroup: React.FC<DashboardDescriptionListGroupProps
aria-label={`Save edits to ${title}`}
variant="link"
onClick={onSaveEditsClick}
isDisabled={isSavingEdits}
isDisabled={isSavingEdits || isSaveDisabled}
>
<CheckIcon />
</Button>
Expand Down
293 changes: 120 additions & 173 deletions frontend/src/components/EditableLabelsDescriptionListGroup.tsx
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}
Copy link
Contributor

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.

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}
Copy link
Contributor

Choose a reason for hiding this comment

The 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={
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooooone last thing - we should set numLabels back to unsavedLabels.length and remove the expandedText and collapsedText props. When editing labels they should always be expanded, the show more/less behavior is only when they are not being edited which is handled by the other LabelGroup rendered in non-edit mode. Otherwise, if they are collapsed and you click "Add label" the new label doesn't appear until you expand.

!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) => {
Copy link
Contributor

@mturley mturley Nov 4, 2024

Choose a reason for hiding this comment

The 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 onClick opening a modal, it should add a new label called "New label" via setUnsavedLabels(). Then the user can click that label to edit it.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to @mturley.
@YuliaKrimerman here's a PF demo of how should the Add label work: https://www.patternfly.org/components/label/#editable-label-group-with-add-button Hope it helps.

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;
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { ModelVersion } from '~/concepts/modelRegistry/types';
import DashboardDescriptionListGroup from '~/components/DashboardDescriptionListGroup';
import EditableTextDescriptionListGroup from '~/components/EditableTextDescriptionListGroup';
import EditableLabelsDescriptionListGroup from '~/components/EditableLabelsDescriptionListGroup';
import {EditableLabelsDescriptionListGroup} from '~/components/EditableLabelsDescriptionListGroup';

Check failure on line 6 in frontend/src/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsView.tsx

View workflow job for this annotation

GitHub Actions / Tests (18.x)

Replace `EditableLabelsDescriptionListGroup` with `·EditableLabelsDescriptionListGroup·`
import ModelPropertiesDescriptionListGroup from '~/pages/modelRegistry/screens/ModelPropertiesDescriptionListGroup';
import { getLabels, mergeUpdatedLabels } from '~/pages/modelRegistry/screens/utils';
import useModelArtifactsByVersionId from '~/concepts/modelRegistry/apiHooks/useModelArtifactsByVersionId';
Expand Down Expand Up @@ -61,7 +61,7 @@
labels={getLabels(mv.customProperties)}
isArchive={isArchiveVersion}
allExistingKeys={Object.keys(mv.customProperties)}
saveEditedLabels={(editedLabels) =>
onLabelsChange={(editedLabels) =>
apiState.api
.patchModelVersion(
{},
Expand Down
Loading
Loading