From 6364a1b39ee62600887c86c92335936dce443989 Mon Sep 17 00:00:00 2001 From: Andrew Ballantyne Date: Fri, 6 Sep 2024 11:02:52 -0400 Subject: [PATCH] Support better K8s Resource Name editing --- .../mockK8sNameDescriptionFieldData.ts | 26 ++ .../src/__mocks__/mockStartNotebookData.ts | 4 +- frontend/src/api/k8s/notebooks.ts | 12 +- .../HelperTextItemVariants.tsx | 42 +++ .../K8sNameDescriptionField.tsx | 128 +++++++++ .../ResourceNameField.tsx | 58 ++++ .../__tests__/utils.spec.ts | 265 ++++++++++++++++++ .../k8s/K8sNameDescriptionField/types.ts | 55 ++++ .../k8s/K8sNameDescriptionField/utils.ts | 123 ++++++++ .../src/concepts/k8s/NameDescriptionField.tsx | 32 +-- .../k8s/ResourceNameDefinitionTooltip.tsx | 19 ++ .../src/concepts/k8s/__tests__/utils.spec.ts | 26 +- frontend/src/concepts/k8s/utils.ts | 60 +++- .../pages/home/projects/ProjectsSection.tsx | 5 +- .../screens/projects/ManageProjectModal.tsx | 65 ++--- .../screens/projects/NewProjectButton.tsx | 27 +- .../screens/projects/ProjectListView.tsx | 33 +-- .../screens/spawner/SpawnerFooter.tsx | 4 +- .../projects/screens/spawner/SpawnerPage.tsx | 42 ++- .../projects/screens/spawner/spawnerUtils.ts | 5 +- frontend/src/pages/projects/types.ts | 6 +- frontend/src/utilities/useAutoFocusRef.ts | 21 ++ 22 files changed, 904 insertions(+), 154 deletions(-) create mode 100644 frontend/src/__mocks__/mockK8sNameDescriptionFieldData.ts create mode 100644 frontend/src/concepts/k8s/K8sNameDescriptionField/HelperTextItemVariants.tsx create mode 100644 frontend/src/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField.tsx create mode 100644 frontend/src/concepts/k8s/K8sNameDescriptionField/ResourceNameField.tsx create mode 100644 frontend/src/concepts/k8s/K8sNameDescriptionField/__tests__/utils.spec.ts create mode 100644 frontend/src/concepts/k8s/K8sNameDescriptionField/types.ts create mode 100644 frontend/src/concepts/k8s/K8sNameDescriptionField/utils.ts create mode 100644 frontend/src/concepts/k8s/ResourceNameDefinitionTooltip.tsx create mode 100644 frontend/src/utilities/useAutoFocusRef.ts diff --git a/frontend/src/__mocks__/mockK8sNameDescriptionFieldData.ts b/frontend/src/__mocks__/mockK8sNameDescriptionFieldData.ts new file mode 100644 index 0000000000..b02fc1b82c --- /dev/null +++ b/frontend/src/__mocks__/mockK8sNameDescriptionFieldData.ts @@ -0,0 +1,26 @@ +import * as _ from 'lodash-es'; +import { RecursivePartial } from '~/typeHelpers'; +import { K8sNameDescriptionFieldData } from '~/concepts/k8s/K8sNameDescriptionField/types'; + +export const mockK8sNameDescriptionFieldData = ( + overrides: RecursivePartial = {}, +): K8sNameDescriptionFieldData => + _.merge( + {}, + { + name: '', + description: '', + k8sName: { + value: '', + state: { + immutable: false, + invalidLength: false, + invalidCharacters: false, + autoTrimmed: false, + maxLength: 253, + touched: false, + }, + }, + }, + overrides, + ); diff --git a/frontend/src/__mocks__/mockStartNotebookData.ts b/frontend/src/__mocks__/mockStartNotebookData.ts index c13a867eba..94379dff22 100644 --- a/frontend/src/__mocks__/mockStartNotebookData.ts +++ b/frontend/src/__mocks__/mockStartNotebookData.ts @@ -1,5 +1,6 @@ import { ImageStreamKind } from '~/k8sTypes'; import { StartNotebookData } from '~/pages/projects/types'; +import { mockK8sNameDescriptionFieldData } from '~/__mocks__/mockK8sNameDescriptionFieldData'; type MockResourceConfigType = { volumeName?: string; @@ -8,8 +9,7 @@ export const mockStartNotebookData = ({ volumeName = 'test-volume', }: MockResourceConfigType): StartNotebookData => ({ projectName: 'test-project', - notebookName: 'test-notebook', - description: '', + notebookData: mockK8sNameDescriptionFieldData({ name: 'test-notebook', description: '' }), image: { imageStream: { metadata: { diff --git a/frontend/src/api/k8s/notebooks.ts b/frontend/src/api/k8s/notebooks.ts index f2b897538f..c5e1e48bf7 100644 --- a/frontend/src/api/k8s/notebooks.ts +++ b/frontend/src/api/k8s/notebooks.ts @@ -14,7 +14,6 @@ import { K8sAPIOptions, KnownLabels, NotebookKind } from '~/k8sTypes'; import { usernameTranslate } from '~/utilities/notebookControllerUtils'; import { EnvironmentFromVariable, StartNotebookData } from '~/pages/projects/types'; import { ROOT_MOUNT_PATH } from '~/pages/projects/pvc/const'; -import { translateDisplayNameForK8s } from '~/concepts/k8s/utils'; import { getTolerationPatch, TolerationChanges } from '~/utilities/tolerations'; import { applyK8sAPIOptions } from '~/api/apiMergeUtils'; import { @@ -36,9 +35,7 @@ export const assembleNotebook = ( ): NotebookKind => { const { projectName, - notebookName, - notebookId: overrideNotebookId, - description, + notebookData, notebookSize, envFrom, initialAcceleratorProfile, @@ -50,7 +47,11 @@ export const assembleNotebook = ( existingTolerations, existingResources, } = data; - const notebookId = overrideNotebookId || translateDisplayNameForK8s(notebookName, 'wb-'); + const { + name: notebookName, + description, + k8sName: { value: notebookId }, + } = notebookData; const imageUrl = `${image.imageStream?.status?.dockerImageRepository}:${image.imageVersion?.name}`; const imageSelection = `${image.imageStream?.metadata.name}:${image.imageVersion?.name}`; @@ -277,7 +278,6 @@ export const updateNotebook = ( username: string, opts?: K8sAPIOptions, ): Promise => { - assignableData.notebookId = existingNotebook.metadata.name; const notebook = assembleNotebook(assignableData, username); const oldNotebook = structuredClone(existingNotebook); diff --git a/frontend/src/concepts/k8s/K8sNameDescriptionField/HelperTextItemVariants.tsx b/frontend/src/concepts/k8s/K8sNameDescriptionField/HelperTextItemVariants.tsx new file mode 100644 index 0000000000..42c875c48b --- /dev/null +++ b/frontend/src/concepts/k8s/K8sNameDescriptionField/HelperTextItemVariants.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { HelperTextItem } from '@patternfly/react-core'; +import { K8sNameDescriptionFieldData } from '~/concepts/k8s/K8sNameDescriptionField/types'; + +type Variants = React.ComponentProps['variant']; + +type HelperTextItemType = React.FC<{ + k8sName: K8sNameDescriptionFieldData['k8sName']; +}>; + +export const HelperTextItemMaxLength: HelperTextItemType = ({ k8sName }) => { + let variant: Variants = 'indeterminate'; + if (k8sName.state.invalidLength) { + variant = 'error'; + } else if (k8sName.state.autoTrimmed) { + variant = 'warning'; + } else if (k8sName.value.trim().length > 0) { + variant = 'success'; + } + + return ( + + Cannot exceed {k8sName.state.maxLength} characters + + ); +}; + +export const HelperTextItemValidCharacters: HelperTextItemType = ({ k8sName }) => { + let variant: Variants = 'indeterminate'; + if (k8sName.state.invalidCharacters) { + variant = 'error'; + } else if (k8sName.value.trim().length > 0) { + variant = 'success'; + } + + return ( + + Must start and end with a letter or number. Valid characters include lowercase letters, + numbers, and hyphens (-). + + ); +}; diff --git a/frontend/src/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField.tsx b/frontend/src/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField.tsx new file mode 100644 index 0000000000..0114d51cff --- /dev/null +++ b/frontend/src/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import { + Button, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + Stack, + StackItem, + TextArea, + TextInput, +} from '@patternfly/react-core'; +import { + K8sNameDescriptionFieldData, + K8sNameDescriptionFieldUpdateFunction, + UseK8sNameDescriptionDataConfiguration, + UseK8sNameDescriptionFieldData, +} from '~/concepts/k8s/K8sNameDescriptionField/types'; +import ResourceNameDefinitionTooltip from '~/concepts/k8s/ResourceNameDefinitionTooltip'; +import useAutoFocusRef from '~/utilities/useAutoFocusRef'; +import { handleUpdateLogic, setupDefaults } from '~/concepts/k8s/K8sNameDescriptionField/utils'; +import ResourceNameField from '~/concepts/k8s/K8sNameDescriptionField/ResourceNameField'; + +/** Companion data hook */ +export const useK8sNameDescriptionFieldData = ( + configuration: UseK8sNameDescriptionDataConfiguration = {}, +): UseK8sNameDescriptionFieldData => { + const [data, setData] = React.useState(() => + setupDefaults(configuration), + ); + + // Hold the data in a ref to avoid churn in the update method + const dataRef = React.useRef(data); + dataRef.current = data; + const onDataChange = React.useCallback( + (key, value) => { + setData(handleUpdateLogic(dataRef.current)(key, value)); + }, + [setData], + ); + + return { data, onDataChange }; +}; + +type K8sNameDescriptionFieldProps = { + autoFocusName?: boolean; + dataTestId: string; + descriptionLabel?: string; + nameLabel?: string; +} & UseK8sNameDescriptionFieldData; + +/** + * Use in place of any K8s Resource creation / edit. + * @see useK8sNameDescriptionFieldData + */ +const K8sNameDescriptionField: React.FC = ({ + autoFocusName, + data, + dataTestId, + descriptionLabel = 'Description', + onDataChange, + nameLabel = 'Name', +}) => { + const autoFocusNameRef = useAutoFocusRef(autoFocusName); + const [showK8sField, setShowK8sField] = React.useState(false); + + const { name, description, k8sName } = data; + + return ( + + + + onDataChange('name', value)} + /> + + {!showK8sField && !k8sName.state.immutable && ( + + {k8sName.value && ( + + + The resource name will be {k8sName.value}. + + + )} + + + {' '} + + + + + )} + + + + +