Skip to content

Commit

Permalink
Merge pull request #2760 from mturley/RHOAIENG-2235-model-details-sec…
Browse files Browse the repository at this point in the history
…tion

Model Registry: Initial content of model details tab with stub editable fields and tab navigation handling
  • Loading branch information
openshift-merge-bot[bot] authored May 1, 2024
2 parents 95fb721 + ea23ed0 commit 1b56d25
Show file tree
Hide file tree
Showing 13 changed files with 600 additions and 99 deletions.
4 changes: 4 additions & 0 deletions frontend/src/components/DashboardDescriptionListGroup.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.odh-custom-description-list-term-with-action > span {
/* Workaround for missing functionality in PF DescriptionList, see https://github.com/patternfly/patternfly/issues/6583 */
width: 100%;
}
108 changes: 108 additions & 0 deletions frontend/src/components/DashboardDescriptionListGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import * as React from 'react';
import {
ActionList,
ActionListItem,
Button,
DescriptionListDescription,
DescriptionListGroup,
DescriptionListTerm,
Split,
SplitItem,
} from '@patternfly/react-core';
import text from '@patternfly/react-styles/css/utilities/Text/text';
import { CheckIcon, PencilAltIcon, TimesIcon } from '@patternfly/react-icons';

import './DashboardDescriptionListGroup.scss';

type EditableProps = {
isEditing: boolean;
contentWhenEditing: React.ReactNode;
isSavingEdits?: boolean;
onEditClick: () => void;
onSaveEditsClick: () => void;
onDiscardEditsClick: () => void;
};

export type DashboardDescriptionListGroupProps = {
title: React.ReactNode;
action?: React.ReactNode;
isEmpty?: boolean;
contentWhenEmpty?: React.ReactNode;
children: React.ReactNode;
} & (({ isEditable: true } & EditableProps) | ({ isEditable?: false } & Partial<EditableProps>));

const DashboardDescriptionListGroup: React.FC<DashboardDescriptionListGroupProps> = (props) => {
const {
title,
action,
isEmpty,
contentWhenEmpty,
isEditable = false,
isEditing,
contentWhenEditing,
isSavingEdits = false,
onEditClick,
onSaveEditsClick,
onDiscardEditsClick,
children,
} = props;
return (
<DescriptionListGroup>
{action || isEditable ? (
<DescriptionListTerm className="odh-custom-description-list-term-with-action">
<Split>
<SplitItem isFilled>{title}</SplitItem>
<SplitItem>
{action ||
(isEditing ? (
<ActionList isIconList>
<ActionListItem>
<Button
data-testid={`save-edit-button-${title}`}
aria-label={`Save edits to ${title}`}
variant="link"
onClick={onSaveEditsClick}
isDisabled={isSavingEdits}
>
<CheckIcon />
</Button>
</ActionListItem>
<ActionListItem>
<Button
data-testid={`discard-edit-button-${title} `}
aria-label={`Discard edits to ${title} `}
variant="plain"
onClick={onDiscardEditsClick}
isDisabled={isSavingEdits}
>
<TimesIcon />
</Button>
</ActionListItem>
</ActionList>
) : (
<Button
data-testid={`edit-button-${title}`}
aria-label={`Edit ${title}`}
isInline
variant="link"
icon={<PencilAltIcon />}
iconPosition="end"
onClick={onEditClick}
>
Edit
</Button>
))}
</SplitItem>
</Split>
</DescriptionListTerm>
) : (
<DescriptionListTerm>{title}</DescriptionListTerm>
)}
<DescriptionListDescription className={isEmpty && !isEditing ? text.disabledColor_100 : ''}>
{isEditing ? contentWhenEditing : isEmpty ? contentWhenEmpty : children}
</DescriptionListDescription>
</DescriptionListGroup>
);
};

export default DashboardDescriptionListGroup;
193 changes: 193 additions & 0 deletions frontend/src/components/EditableLabelsDescriptionListGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
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';

type EditableTextDescriptionListGroupProps = Partial<
Pick<DashboardDescriptionListGroupProps, 'title' | 'contentWhenEmpty'>
> & {
labels: string[];
saveEditedLabels: (labels: string[]) => Promise<void>;
};

const EditableLabelsDescriptionListGroup: React.FC<EditableTextDescriptionListGroupProps> = ({
title = 'Labels',
contentWhenEmpty = 'No labels',
labels,
saveEditedLabels,
}) => {
const [isEditing, setIsEditing] = React.useState(false);
const [unsavedLabels, setUnsavedLabels] = React.useState(labels);
const [isSavingEdits, setIsSavingEdits] = React.useState(false);

const editUnsavedLabel = (newText: string, index: number) => {
if (isSavingEdits) {
return;
}
const copy = [...unsavedLabels];
copy[index] = newText;
setUnsavedLabels(copy);
};
const removeUnsavedLabel = (text: string) => {
if (isSavingEdits) {
return;
}
setUnsavedLabels(unsavedLabels.filter((label) => label !== text));
};
const addUnsavedLabel = (text: string) => {
if (isSavingEdits) {
return;
}
setUnsavedLabels([...unsavedLabels, text]);
};

const [isAddLabelModalOpen, setIsAddLabelModalOpen] = React.useState(false);
const [addLabelInputValue, setAddLabelInputValue] = React.useState('');
const addLabelInputRef = React.useRef<HTMLInputElement>(null);
const addLabelInputTooLong = addLabelInputValue.length > 63;

const toggleAddLabelModal = () => {
setAddLabelInputValue('');
setIsAddLabelModalOpen(!isAddLabelModalOpen);
};
React.useEffect(() => {
if (isAddLabelModalOpen && addLabelInputRef.current) {
addLabelInputRef.current.focus();
}
}, [isAddLabelModalOpen]);
const addLabelModalSubmitDisabled = !addLabelInputValue || addLabelInputTooLong;
const submitAddLabelModal = (event?: React.FormEvent) => {
event?.preventDefault();
if (!addLabelModalSubmitDisabled) {
addUnsavedLabel(addLabelInputValue);
toggleAddLabelModal();
}
};

return (
<>
<DashboardDescriptionListGroup
title={title}
isEmpty={labels.length === 0}
contentWhenEmpty={contentWhenEmpty}
isEditable
isEditing={isEditing}
isSavingEdits={isSavingEdits}
contentWhenEditing={
<LabelGroup
isEditable={!isSavingEdits}
numLabels={unsavedLabels.length}
addLabelControl={
!isSavingEdits && (
<Label color="blue" variant="outline" isOverflowLabel onClick={toggleAddLabelModal}>
Add label
</Label>
)
}
>
{unsavedLabels.map((label, index) => (
<Label
key={label}
color="blue"
data-testid="label"
isEditable={!isSavingEdits}
editableProps={{ 'aria-label': `Editable label with text ${label}` }}
onClose={() => removeUnsavedLabel(label)}
closeBtnProps={{ isDisabled: isSavingEdits }}
onEditComplete={(_event, newText) => editUnsavedLabel(newText, index)}
>
{label}
</Label>
))}
</LabelGroup>
}
onEditClick={() => {
setUnsavedLabels(labels);
setIsEditing(true);
}}
onSaveEditsClick={async () => {
setIsSavingEdits(true);
try {
await saveEditedLabels(unsavedLabels);
} finally {
setIsSavingEdits(false);
}
setIsEditing(false);
}}
onDiscardEditsClick={() => {
setUnsavedLabels(labels);
setIsEditing(false);
}}
>
<LabelGroup>
{labels.map((label) => (
<Label key={label} color="blue" data-testid="label">
{label}
</Label>
))}
</LabelGroup>
</DashboardDescriptionListGroup>
<Modal
variant="small"
title="Add label"
isOpen={isAddLabelModalOpen}
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={addLabelInputTooLong ? 'error' : 'default'}
/>
{addLabelInputTooLong && (
<FormHelperText>
<HelperText>
<HelperTextItem icon={<ExclamationCircleIcon />} variant="error">
Label text can&apos;t exceed 63 characters
</HelperTextItem>
</HelperText>
</FormHelperText>
)}
</FormGroup>
</Form>
</Modal>
</>
);
};

export default EditableLabelsDescriptionListGroup;
73 changes: 73 additions & 0 deletions frontend/src/components/EditableTextDescriptionListGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as React from 'react';
import { ExpandableSection, TextArea } from '@patternfly/react-core';
import DashboardDescriptionListGroup, {
DashboardDescriptionListGroupProps,
} from './DashboardDescriptionListGroup';

type EditableTextDescriptionListGroupProps = Pick<
DashboardDescriptionListGroupProps,
'title' | 'contentWhenEmpty'
> & {
value: string;
saveEditedValue: (value: string) => Promise<void>;
};

const EditableTextDescriptionListGroup: React.FC<EditableTextDescriptionListGroupProps> = ({
title,
contentWhenEmpty,
value,
saveEditedValue,
}) => {
const [isEditing, setIsEditing] = React.useState(false);
const [unsavedValue, setUnsavedValue] = React.useState(value);
const [isSavingEdits, setIsSavingEdits] = React.useState(false);
const [isTextExpanded, setIsTextExpanded] = React.useState(false);
return (
<DashboardDescriptionListGroup
title={title}
isEmpty={!value}
contentWhenEmpty={contentWhenEmpty}
isEditable
isEditing={isEditing}
isSavingEdits={isSavingEdits}
contentWhenEditing={
<TextArea
data-testid={`edit-text-area-${title}`}
aria-label={`Text box for editing ${title}`}
value={unsavedValue}
onChange={(_event, v) => setUnsavedValue(v)}
isDisabled={isSavingEdits}
/>
}
onEditClick={() => {
setUnsavedValue(value);
setIsEditing(true);
}}
onSaveEditsClick={async () => {
setIsSavingEdits(true);
try {
await saveEditedValue(unsavedValue);
} finally {
setIsSavingEdits(false);
}
setIsEditing(false);
}}
onDiscardEditsClick={() => {
setUnsavedValue(value);
setIsEditing(false);
}}
>
<ExpandableSection
variant="truncate"
truncateMaxLines={12}
toggleText={isTextExpanded ? 'Show less' : 'Show more'}
onToggle={(_event, isExpanded) => setIsTextExpanded(isExpanded)}
isExpanded={isTextExpanded}
>
{value}
</ExpandableSection>
</DashboardDescriptionListGroup>
);
};

export default EditableTextDescriptionListGroup;
Loading

0 comments on commit 1b56d25

Please sign in to comment.