From 71d923a764994cbec82cf8c5dd487105df58b92a Mon Sep 17 00:00:00 2001 From: emilys314 Date: Wed, 18 Sep 2024 11:35:41 -0400 Subject: [PATCH] Add connection modal --- .../ConnectionTypeDetailsHelperText.tsx | 72 ++++++ .../connectionTypes/ConnectionTypePreview.tsx | 205 ++++++++++-------- .../ConnectionTypePreviewDrawer.tsx | 2 +- .../fields/ConnectionTypeFormFields.tsx | 19 +- .../src/concepts/connectionTypes/types.ts | 4 +- .../detail/connections/ConnectionsList.tsx | 25 +++ .../connections/ManageConnectionsModal.tsx | 171 +++++++++++++++ 7 files changed, 399 insertions(+), 99 deletions(-) create mode 100644 frontend/src/concepts/connectionTypes/ConnectionTypeDetailsHelperText.tsx create mode 100644 frontend/src/pages/projects/screens/detail/connections/ManageConnectionsModal.tsx diff --git a/frontend/src/concepts/connectionTypes/ConnectionTypeDetailsHelperText.tsx b/frontend/src/concepts/connectionTypes/ConnectionTypeDetailsHelperText.tsx new file mode 100644 index 0000000000..3afca817fc --- /dev/null +++ b/frontend/src/concepts/connectionTypes/ConnectionTypeDetailsHelperText.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { + Button, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + HelperText, + HelperTextItem, + LabelGroup, + Popover, +} from '@patternfly/react-core'; +import { getDescriptionFromK8sResource } from '~/concepts/k8s/utils'; +import { ConnectionTypeConfigMapObj } from './types'; +import UnspecifiedValue from './fields/UnspecifiedValue'; +import CategoryLabel from './CategoryLabel'; + +type Props = { + connectionType?: ConnectionTypeConfigMapObj; +}; + +export const ConnectionTypeDetailsHelperText: React.FC = ({ connectionType }) => { + const displayName = + connectionType && connectionType.metadata.annotations?.['openshift.io/display-name']; + const description = connectionType && getDescriptionFromK8sResource(connectionType); + + return ( + + + + + Connection type name + + {displayName || } + + + {description ? ( + + Connection type description + + {description || } + + + ) : undefined} + + Category + + {connectionType?.data?.category?.length ? ( + + {connectionType.data.category.map((category) => ( + + ))} + + ) : ( + + )} + + + + } + > + + + + + ); +}; diff --git a/frontend/src/concepts/connectionTypes/ConnectionTypePreview.tsx b/frontend/src/concepts/connectionTypes/ConnectionTypePreview.tsx index dd65e03a20..9e2c6b45bd 100644 --- a/frontend/src/concepts/connectionTypes/ConnectionTypePreview.tsx +++ b/frontend/src/concepts/connectionTypes/ConnectionTypePreview.tsx @@ -1,111 +1,128 @@ import * as React from 'react'; -import { - Button, - DescriptionList, - DescriptionListDescription, - DescriptionListGroup, - DescriptionListTerm, - Form, - FormGroup, - FormSection, - HelperText, - HelperTextItem, - LabelGroup, - MenuToggleStatus, - Popover, - Title, -} from '@patternfly/react-core'; +import { Form, FormGroup, FormSection, MenuToggleStatus, Title } from '@patternfly/react-core'; import ConnectionTypeFormFields from '~/concepts/connectionTypes/fields/ConnectionTypeFormFields'; -import { ConnectionTypeConfigMapObj } from '~/concepts/connectionTypes/types'; -import NameDescriptionField from '~/concepts/k8s/NameDescriptionField'; -import { getDescriptionFromK8sResource } from '~/concepts/k8s/utils'; -import UnspecifiedValue from '~/concepts/connectionTypes/fields/UnspecifiedValue'; -import SimpleSelect from '~/components/SimpleSelect'; -import CategoryLabel from '~/concepts/connectionTypes/CategoryLabel'; -import TruncatedText from '~/components/TruncatedText'; +import { + ConnectionTypeConfigMapObj, + ConnectionTypeValueType, +} from '~/concepts/connectionTypes/types'; +import { + getDisplayNameFromK8sResource, + getResourceNameFromK8sResource, +} from '~/concepts/k8s/utils'; +import TypeaheadSelect, { TypeaheadSelectOption } from '~/components/TypeaheadSelect'; +import K8sNameDescriptionField from '~/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField'; +import { + K8sNameDescriptionFieldData, + K8sNameDescriptionFieldUpdateFunction, +} from '~/concepts/k8s/K8sNameDescriptionField/types'; +import { ConnectionTypeDetailsHelperText } from './ConnectionTypeDetailsHelperText'; type Props = { obj?: ConnectionTypeConfigMapObj; + setObj?: (obj?: ConnectionTypeConfigMapObj) => void; + connectionTypes?: ConnectionTypeConfigMapObj[]; + isPreview?: boolean; + connectionNameDesc?: K8sNameDescriptionFieldData; + setConnectionNameDesc?: K8sNameDescriptionFieldUpdateFunction; + connectionValues?: { + [key: string]: ConnectionTypeValueType; + }; + setConnectionValues?: (values: { [key: string]: ConnectionTypeValueType }) => void; }; -// TODO consider refactoring this form for reuse when creating connection type instances -const ConnectionTypePreview: React.FC = ({ obj }) => { +const ConnectionTypePreview: React.FC = ({ + obj, + setObj, + connectionTypes, + isPreview, + connectionNameDesc, + setConnectionNameDesc, + connectionValues, + setConnectionValues, +}) => { const connectionTypeName = obj && obj.metadata.annotations?.['openshift.io/display-name']; - const connectionTypeDescription = (obj && getDescriptionFromK8sResource(obj)) ?? undefined; + + const options: TypeaheadSelectOption[] = React.useMemo(() => { + if (isPreview && connectionTypeName) { + return [ + { + value: connectionTypeName, + content: connectionTypeName, + isSelected: true, + }, + ]; + } + if (!isPreview && connectionTypes) { + return connectionTypes.map((t) => ({ + value: getResourceNameFromK8sResource(t), + content: getDisplayNameFromK8sResource(t), + isSelected: t.metadata.name === obj?.metadata.name, + })); + } + return []; + }, [isPreview, obj?.metadata.name, connectionTypes, connectionTypeName]); + return (
- Add connection + {isPreview && Add connection} - undefined} + selectOptions={options} + onSelect={(_, selection) => + setObj?.(connectionTypes?.find((c) => c.metadata.name === selection)) + } + isDisabled={isPreview} + placeholder={ + isPreview && !connectionTypeName + ? 'Unspecified' + : 'Select a type, or search by keyword or category' + } + toggleProps={ + isPreview && !connectionTypeName ? { status: MenuToggleStatus.danger } : undefined + } /> - - {connectionTypeDescription ? ( - - - - ) : undefined} - - - - Connection type name - - {connectionTypeName || } - - - {connectionTypeDescription ? ( - - Connection type description - - {connectionTypeDescription || } - - - ) : undefined} - - Category - - {obj?.data?.category?.length ? ( - - {obj.data.category.map((category) => ( - - ))} - - ) : ( - - )} - - - - } - > - - - - + {(isPreview || obj?.metadata.name) && ( + + )} - - - - + {(isPreview || obj?.metadata.name) && ( + + + { + setConnectionValues?.({ + ...connectionValues, + [field.envVar]: value, + }); + }} + /> + + )}
); }; diff --git a/frontend/src/concepts/connectionTypes/ConnectionTypePreviewDrawer.tsx b/frontend/src/concepts/connectionTypes/ConnectionTypePreviewDrawer.tsx index 5897a537b8..919dfce069 100644 --- a/frontend/src/concepts/connectionTypes/ConnectionTypePreviewDrawer.tsx +++ b/frontend/src/concepts/connectionTypes/ConnectionTypePreviewDrawer.tsx @@ -53,7 +53,7 @@ const ConnectionTypePreviewDrawer: React.FC = ({ children, isExpanded, on > - + diff --git a/frontend/src/concepts/connectionTypes/fields/ConnectionTypeFormFields.tsx b/frontend/src/concepts/connectionTypes/fields/ConnectionTypeFormFields.tsx index d285a18977..6b49f98c25 100644 --- a/frontend/src/concepts/connectionTypes/fields/ConnectionTypeFormFields.tsx +++ b/frontend/src/concepts/connectionTypes/fields/ConnectionTypeFormFields.tsx @@ -6,18 +6,30 @@ import { ConnectionTypeDataField, ConnectionTypeField, ConnectionTypeFieldType, + ConnectionTypeValueType, SectionField, } from '~/concepts/connectionTypes/types'; type Props = { fields?: ConnectionTypeField[]; isPreview?: boolean; - onChange?: (field: ConnectionTypeDataField, value: unknown) => void; + onChange?: (field: ConnectionTypeDataField, value: ConnectionTypeValueType) => void; + connectionValues?: { + [key: string]: ConnectionTypeValueType; + }; }; -type FieldGroup = { section: SectionField | undefined; fields: ConnectionTypeDataField[] }; +type FieldGroup = { + section: SectionField | undefined; + fields: ConnectionTypeDataField[]; +}; -const ConnectionTypeFormFields: React.FC = ({ fields, isPreview, onChange }) => { +const ConnectionTypeFormFields: React.FC = ({ + fields, + isPreview, + onChange, + connectionValues, +}) => { const fieldGroups = React.useMemo( () => fields?.reduce((acc, field) => { @@ -42,6 +54,7 @@ const ConnectionTypeFormFields: React.FC = ({ fields, isPreview, onChange field={field} mode={isPreview ? 'preview' : 'instance'} onChange={onChange ? (v) => onChange(field, v) : undefined} + value={connectionValues?.[field.envVar]} /> )} diff --git a/frontend/src/concepts/connectionTypes/types.ts b/frontend/src/concepts/connectionTypes/types.ts index 19524fb31d..78ed9d67e9 100644 --- a/frontend/src/concepts/connectionTypes/types.ts +++ b/frontend/src/concepts/connectionTypes/types.ts @@ -136,6 +136,8 @@ export type ConnectionTypeConfigMapObj = Omit & }; }; +export type ConnectionTypeValueType = ConnectionTypeDataField['properties']['defaultValue']; + export type Connection = SecretKind & { metadata: { labels: DashboardLabels & { @@ -145,7 +147,7 @@ export type Connection = SecretKind & { 'opendatahub.io/connection-type': string; }; }; - data: { + data?: { [key: string]: string; }; }; diff --git a/frontend/src/pages/projects/screens/detail/connections/ConnectionsList.tsx b/frontend/src/pages/projects/screens/detail/connections/ConnectionsList.tsx index 204c038b3e..c8319f8528 100644 --- a/frontend/src/pages/projects/screens/detail/connections/ConnectionsList.tsx +++ b/frontend/src/pages/projects/screens/detail/connections/ConnectionsList.tsx @@ -8,8 +8,11 @@ import DetailsSection from '~/pages/projects/screens/detail/DetailsSection'; import EmptyDetailsView from '~/components/EmptyDetailsView'; import DashboardPopupIconButton from '~/concepts/dashboard/DashboardPopupIconButton'; import { ProjectObjectType, typedEmptyImage } from '~/concepts/design/utils'; +import { Connection } from '~/concepts/connectionTypes/types'; import { useWatchConnectionTypes } from '~/utilities/useWatchConnectionTypes'; +import { createSecret } from '~/api'; import ConnectionsTable from './ConnectionsTable'; +import { ManageConnectionModal } from './ManageConnectionsModal'; const ConnectionsDescription = 'Connections enable you to store and retrieve information that typically should not be stored in code. For example, you can store details (including credentials) for object storage, databases, and more. You can then attach the connections to artifacts in your project, such as workbenches and model servers.'; @@ -17,9 +20,14 @@ const ConnectionsDescription = const ConnectionsList: React.FC = () => { const { connections: { data: connections, loaded, error, refresh: refreshConnections }, + currentProject, } = React.useContext(ProjectDetailsContext); const [connectionTypes, connectionTypesLoaded, connectionTypesError] = useWatchConnectionTypes(); + const [manageConnectionModal, setManageConnectionModal] = React.useState<{ + connection?: Connection; + }>(); + return ( { key={`action-${ProjectSectionID.CONNECTIONS}`} data-testid="add-connection-button" variant="primary" + onClick={() => { + setManageConnectionModal({}); + }} > Add connection , @@ -65,6 +76,20 @@ const ConnectionsList: React.FC = () => { connectionTypes={connectionTypes} refreshConnections={refreshConnections} /> + {manageConnectionModal && ( + { + setManageConnectionModal(undefined); + if (refresh) { + refreshConnections(); + } + }} + onSubmit={(connection: Connection) => createSecret(connection)} + /> + )} ); }; diff --git a/frontend/src/pages/projects/screens/detail/connections/ManageConnectionsModal.tsx b/frontend/src/pages/projects/screens/detail/connections/ManageConnectionsModal.tsx new file mode 100644 index 0000000000..db55f77941 --- /dev/null +++ b/frontend/src/pages/projects/screens/detail/connections/ManageConnectionsModal.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { Modal } from '@patternfly/react-core'; +import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; +import ConnectionTypePreview from '~/concepts/connectionTypes/ConnectionTypePreview'; +import { + Connection, + ConnectionTypeConfigMapObj, + ConnectionTypeValueType, + isConnectionTypeDataField, +} from '~/concepts/connectionTypes/types'; +import { translateDisplayNameForK8s } from '~/concepts/k8s/utils'; +import { ProjectKind, SecretKind } from '~/k8sTypes'; +import { useK8sNameDescriptionFieldData } from '~/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField'; +import { K8sNameDescriptionFieldData } from '~/concepts/k8s/K8sNameDescriptionField/types'; + +type Props = { + connection?: Connection; + connectionTypes?: ConnectionTypeConfigMapObj[]; + project?: ProjectKind; + onClose: (refresh?: boolean) => void; + onSubmit: (connection: Connection) => Promise; +}; + +export const ManageConnectionModal: React.FC = ({ + connection, + connectionTypes, + project, + onClose, + onSubmit, +}) => { + const [error, setError] = React.useState(); + const [isSaving, setIsSaving] = React.useState(false); + + const [selectedConnectionType, setSelectedConnectionType] = + React.useState(); + const { data: nameDescData, onDataChange: setNameDescData } = useK8sNameDescriptionFieldData(); + const [connectionValues, setConnectionValues] = React.useState<{ + [key: string]: ConnectionTypeValueType; + }>(connection?.data ?? {}); + + // if user changes connection types, don't discard previous entries in case of accident + const [previousEntries, setPreviousEntries] = React.useState<{ + [connectionTypeName: string]: { + nameDesc: K8sNameDescriptionFieldData; + values: { + [key: string]: ConnectionTypeValueType; + }; + }; + }>({}); + const changeSelectionType = React.useCallback( + (type?: ConnectionTypeConfigMapObj) => { + // save previous connection values + if (selectedConnectionType) { + setPreviousEntries({ + ...previousEntries, + [selectedConnectionType.metadata.name]: { + nameDesc: nameDescData, + values: connectionValues, + }, + }); + // clear previous values + setNameDescData('name', ''); + setNameDescData('description', ''); + setConnectionValues({}); + } + // load saved values? + if (type?.metadata.name && type.metadata.name in previousEntries) { + setNameDescData('name', previousEntries[type.metadata.name].nameDesc.name); + setNameDescData('description', previousEntries[type.metadata.name].nameDesc.description); + setConnectionValues(previousEntries[type.metadata.name].values); + } else { + // first time load, so add default values + const defaults: { + [key: string]: ConnectionTypeValueType; + } = {}; + for (const field of type?.data?.fields ?? []) { + if (isConnectionTypeDataField(field) && field.properties.defaultValue) { + defaults[field.envVar] = field.properties.defaultValue; + } + } + setConnectionValues(defaults); + } + + setSelectedConnectionType(type); + }, + [selectedConnectionType, previousEntries, nameDescData, connectionValues, setNameDescData], + ); + + const isValid = React.useMemo( + () => + !!selectedConnectionType && + !!nameDescData.name && + !selectedConnectionType.data?.fields?.find( + (field) => + isConnectionTypeDataField(field) && field.required && !connectionValues[field.envVar], + ), + [selectedConnectionType, nameDescData, connectionValues], + ); + + const enabledConnectionTypes = React.useMemo( + () => + connectionTypes?.filter((t) => t.metadata.annotations?.['opendatahub.io/enabled'] === 'true'), + [connectionTypes], + ); + + return ( + { + onClose(); + }} + variant="medium" + footer={ + { + const connectionValuesAsStrings = Object.fromEntries( + Object.entries(connectionValues).map(([key, value]) => [key, String(value)]), + ); + const update: Connection = { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: nameDescData.k8sName.value || translateDisplayNameForK8s(nameDescData.name), + namespace: project?.metadata.name ?? '', + labels: { + 'opendatahub.io/dashboard': 'true', + 'opendatahub.io/managed': 'true', + }, + annotations: { + 'opendatahub.io/connection-type': selectedConnectionType?.metadata.name ?? '', + 'openshift.io/display-name': nameDescData.name, + 'openshift.io/description': nameDescData.description, + }, + }, + stringData: connectionValuesAsStrings, + }; + + setIsSaving(true); + setError(undefined); + + onSubmit(update) + .then(() => { + onClose(true); + }) + .catch((e) => { + setError(e); + setIsSaving(false); + }); + }} + error={error} + isSubmitDisabled={!isValid} + isSubmitLoading={isSaving} + alertTitle="" + /> + } + > + + + ); +};