diff --git a/frontend/src/components/MultiSelection.tsx b/frontend/src/components/MultiSelection.tsx index 1ab72eeadb..160d6c8159 100644 --- a/frontend/src/components/MultiSelection.tsx +++ b/frontend/src/components/MultiSelection.tsx @@ -15,10 +15,11 @@ import { HelperTextItem, SelectGroup, Divider, + SelectOptionProps, } from '@patternfly/react-core'; import { TimesIcon } from '@patternfly/react-icons/dist/esm/icons/times-icon'; -export type SelectionOptions = { +export type SelectionOptions = Omit & { id: number | string; name: string; selected?: boolean; @@ -49,9 +50,12 @@ type MultiSelectionProps = { isCreateOptionOnTop?: boolean; /** Message to display to create a new option */ createOptionMessage?: string | ((newValue: string) => string); + filterFunction?: (filterText: string, options: SelectionOptions[]) => SelectionOptions[]; }; const defaultCreateOptionMessage = (newValue: string) => `Create "${newValue}"`; +const defaultFilterFunction = (filterText: string, options: SelectionOptions[]) => + options.filter((o) => !filterText || o.name.toLowerCase().includes(filterText.toLowerCase())); export const MultiSelection: React.FC = ({ value = [], @@ -69,6 +73,7 @@ export const MultiSelection: React.FC = ({ isCreatable = false, isCreateOptionOnTop = false, createOptionMessage = defaultCreateOptionMessage, + filterFunction = defaultFilterFunction, }) => { const [isOpen, setIsOpen] = React.useState(false); const [inputValue, setInputValue] = React.useState(''); @@ -80,16 +85,14 @@ export const MultiSelection: React.FC = ({ let counter = 0; return groupedValues .map((g) => { - const values = g.values.filter( - (v) => !inputValue || v.name.toLowerCase().includes(inputValue.toLowerCase()), - ); + const values = filterFunction(inputValue, g.values); return { ...g, values: values.map((v) => ({ ...v, index: counter++ })), }; }) .filter((g) => g.values.length); - }, [inputValue, groupedValues]); + }, [filterFunction, groupedValues, inputValue]); const setOpen = (open: boolean) => { setIsOpen(open); @@ -104,10 +107,11 @@ export const MultiSelection: React.FC = ({ const selectOptions = React.useMemo( () => - value - .filter((v) => !inputValue || v.name.toLowerCase().includes(inputValue.toLowerCase())) - .map((v, index) => ({ ...v, index: groupOptions.length + index })), - [groupOptions, inputValue, value], + filterFunction(inputValue, value).map((v, index) => ({ + ...v, + index: groupOptions.length + index, + })), + [filterFunction, groupOptions, inputValue, value], ); const allValues = React.useMemo(() => { @@ -340,6 +344,7 @@ export const MultiSelection: React.FC = ({ value={option.id} ref={null} isSelected={option.selected} + description={option.description} > {option.name} @@ -363,6 +368,7 @@ export const MultiSelection: React.FC = ({ value={option.id} ref={null} isSelected={option.selected} + description={option.description} > {option.name} diff --git a/frontend/src/components/table/TableRowTitleDescription.tsx b/frontend/src/components/table/TableRowTitleDescription.tsx index a80076bef1..75fdf11ba3 100644 --- a/frontend/src/components/table/TableRowTitleDescription.tsx +++ b/frontend/src/components/table/TableRowTitleDescription.tsx @@ -7,6 +7,7 @@ import TruncatedText from '~/components/TruncatedText'; type TableRowTitleDescriptionProps = { title: React.ReactNode; boldTitle?: boolean; + titleIcon?: React.ReactNode; resource?: K8sResourceCommon; subtitle?: React.ReactNode; description?: React.ReactNode; @@ -19,6 +20,7 @@ type TableRowTitleDescriptionProps = { const TableRowTitleDescription: React.FC = ({ title, boldTitle = true, + titleIcon, description, resource, subtitle, @@ -56,6 +58,7 @@ const TableRowTitleDescription: React.FC = ({ ) : ( title )} + {titleIcon} {subtitle} {descriptionNode} diff --git a/frontend/src/pages/projects/notebook/useNotebooksStates.ts b/frontend/src/pages/projects/notebook/useNotebooksStates.ts index 00ab80044c..d8d5ad998a 100644 --- a/frontend/src/pages/projects/notebook/useNotebooksStates.ts +++ b/frontend/src/pages/projects/notebook/useNotebooksStates.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import useFetchState, { FetchState } from '~/utilities/useFetchState'; +import useFetchState, { FetchState, NotReadyError } from '~/utilities/useFetchState'; import { NotebookKind } from '~/k8sTypes'; import { POLL_INTERVAL } from '~/utilities/const'; import { getNotebooksStates } from '~/pages/projects/notebook/useProjectNotebookStates'; @@ -8,11 +8,17 @@ import { NotebookState } from './types'; export const useNotebooksStates = ( notebooks: NotebookKind[], namespace: string, + checkStatus = true, ): FetchState => { - const fetchNotebooksStatus = React.useCallback( - () => getNotebooksStates(notebooks, namespace), - [namespace, notebooks], - ); + const fetchNotebooksStatus = React.useCallback(() => { + if (!namespace) { + return Promise.reject(new NotReadyError('No namespace')); + } + if (!checkStatus) { + return Promise.reject(new NotReadyError('Not running')); + } + return getNotebooksStates(notebooks, namespace); + }, [namespace, notebooks, checkStatus]); return useFetchState(fetchNotebooksStatus, [], { refreshRate: POLL_INTERVAL, diff --git a/frontend/src/pages/projects/screens/detail/connections/ConnectionsTable.tsx b/frontend/src/pages/projects/screens/detail/connections/ConnectionsTable.tsx index c669b591b2..87516c77a3 100644 --- a/frontend/src/pages/projects/screens/detail/connections/ConnectionsTable.tsx +++ b/frontend/src/pages/projects/screens/detail/connections/ConnectionsTable.tsx @@ -34,8 +34,20 @@ const ConnectionsTable: React.FC = ({ key={connection.metadata.name} obj={connection} connectionTypes={connectionTypes} - onEditConnection={() => setManageConnectionModal(connection)} - onDeleteConnection={() => setDeleteConnection(connection)} + kebabActions={[ + { + title: 'Edit', + onClick: () => { + setManageConnectionModal(connection); + }, + }, + { + title: 'Delete', + onClick: () => { + setDeleteConnection(connection); + }, + }, + ]} /> )} isStriped diff --git a/frontend/src/pages/projects/screens/detail/connections/ConnectionsTableRow.tsx b/frontend/src/pages/projects/screens/detail/connections/ConnectionsTableRow.tsx index 95d5d9749d..477e8b12d3 100644 --- a/frontend/src/pages/projects/screens/detail/connections/ConnectionsTableRow.tsx +++ b/frontend/src/pages/projects/screens/detail/connections/ConnectionsTableRow.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; -import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; -import { LabelGroup, Truncate } from '@patternfly/react-core'; +import { ActionsColumn, IAction, Td, Tr } from '@patternfly/react-table'; +import { Icon, LabelGroup, Truncate } from '@patternfly/react-core'; +import { ExclamationTriangleIcon } from '@patternfly/react-icons'; import { Connection, ConnectionTypeConfigMapObj } from '~/concepts/connectionTypes/types'; import { TableRowTitleDescription } from '~/components/table'; import { getDescriptionFromK8sResource, getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; @@ -11,15 +12,19 @@ import ConnectedResources from '~/pages/projects/screens/detail/connections/Conn type ConnectionsTableRowProps = { obj: Connection; connectionTypes?: ConnectionTypeConfigMapObj[]; - onEditConnection: (pvc: Connection) => void; - onDeleteConnection: (dataConnection: Connection) => void; + kebabActions: IAction[]; + showCompatibilityCell?: boolean; + showConnectedResourcesCell?: boolean; + showWarningIcon?: boolean; }; const ConnectionsTableRow: React.FC = ({ obj, connectionTypes, - onEditConnection, - onDeleteConnection, + kebabActions, + showCompatibilityCell = true, + showConnectedResourcesCell = true, + showWarningIcon = false, }) => { const connectionTypeDisplayName = React.useMemo(() => { const matchingType = connectionTypes?.find( @@ -45,6 +50,13 @@ const ConnectionsTableRow: React.FC = ({ } boldTitle={false} + titleIcon={ + showWarningIcon ? ( + + + + ) : undefined + } resource={obj} description={getDescriptionFromK8sResource(obj)} truncateDescriptionLines={2} @@ -52,37 +64,26 @@ const ConnectionsTableRow: React.FC = ({ /> {connectionTypeDisplayName} - - {compatibleTypes.length ? ( - - {compatibleTypes.map((compatibleType) => ( - - ))} - - ) : ( - '-' - )} - - - - + {showCompatibilityCell && ( + + {compatibleTypes.length ? ( + + {compatibleTypes.map((compatibleType) => ( + + ))} + + ) : ( + '-' + )} + + )} + {showConnectedResourcesCell && ( + + + + )} - { - onEditConnection(obj); - }, - }, - { - title: 'Delete', - onClick: () => { - onDeleteConnection(obj); - }, - }, - ]} - /> + ); diff --git a/frontend/src/pages/projects/screens/spawner/SpawnerFooter.tsx b/frontend/src/pages/projects/screens/spawner/SpawnerFooter.tsx index 5748bd473b..34c89757f5 100644 --- a/frontend/src/pages/projects/screens/spawner/SpawnerFooter.tsx +++ b/frontend/src/pages/projects/screens/spawner/SpawnerFooter.tsx @@ -27,6 +27,7 @@ import { useUser } from '~/redux/selectors'; import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; import { AppContext } from '~/app/AppContext'; import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; +import { Connection } from '~/concepts/connectionTypes/types'; import { fireFormTrackingEvent } from '~/concepts/analyticsTracking/segmentIOUtils'; import { FormTrackingEventProperties, @@ -40,12 +41,15 @@ import { } from './service'; import { checkRequiredFieldsForNotebookStart } from './spawnerUtils'; import { getNotebookDataConnection } from './dataConnection/useNotebookDataConnection'; +import { setConnectionsOnEnvFrom } from './connections/utils'; type SpawnerFooterProps = { startNotebookData: StartNotebookData; storageData: StorageData; envVariables: EnvVariable[]; dataConnection: DataConnectionData; + isConnectionTypesEnabled: boolean; + connections: Connection[]; canEnablePipelines: boolean; }; @@ -54,6 +58,8 @@ const SpawnerFooter: React.FC = ({ storageData, envVariables, dataConnection, + isConnectionTypesEnabled, + connections, canEnablePipelines, }) => { const [error, setError] = React.useState(); @@ -67,6 +73,7 @@ const SpawnerFooter: React.FC = ({ const { notebooks: { data }, dataConnections: { data: existingDataConnections }, + connections: { data: projectConnections }, refreshAllProjectData, } = React.useContext(ProjectDetailsContext); const { notebookName } = useParams(); @@ -148,7 +155,7 @@ const SpawnerFooter: React.FC = ({ dryRun, ).catch(handleError); - const envFrom = await updateConfigMapsAndSecretsForNotebook( + let envFrom = await updateConfigMapsAndSecretsForNotebook( projectName, editNotebook, envVariables, @@ -157,6 +164,10 @@ const SpawnerFooter: React.FC = ({ dryRun, ).catch(handleError); + if (isConnectionTypesEnabled && envFrom) { + envFrom = setConnectionsOnEnvFrom(connections, envFrom, projectConnections); + } + if (!pvcDetails || !envFrom) { return; } @@ -248,7 +259,7 @@ const SpawnerFooter: React.FC = ({ : []; const pvcDetails = await createPvcDataForNotebook(projectName, storageData).catch(handleError); - const envFrom = await createConfigMapsAndSecretsForNotebook(projectName, [ + let envFrom = await createConfigMapsAndSecretsForNotebook(projectName, [ ...envVariables, ...newDataConnection, ]).catch(handleError); @@ -259,6 +270,9 @@ const SpawnerFooter: React.FC = ({ } const { volumes, volumeMounts } = pvcDetails; + if (isConnectionTypesEnabled) { + envFrom = setConnectionsOnEnvFrom(connections, envFrom, projectConnections); + } const newStartData: StartNotebookData = { ...startNotebookData, volumes, diff --git a/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx b/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx index ec74a5b62f..77288b5c04 100644 --- a/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx +++ b/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx @@ -31,6 +31,8 @@ import K8sNameDescriptionField, { useK8sNameDescriptionFieldData, } from '~/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField'; import { LimitNameResourceType } from '~/concepts/k8s/K8sNameDescriptionField/utils'; +import useConnectionTypesEnabled from '~/concepts/connectionTypes/useConnectionTypesEnabled'; +import { Connection } from '~/concepts/connectionTypes/types'; import { SpawnerPageSectionID } from './types'; import { ScrollableSelectorID, SpawnerPageSectionTitles } from './const'; import SpawnerFooter from './SpawnerFooter'; @@ -46,13 +48,19 @@ import { useNotebookDataConnection } from './dataConnection/useNotebookDataConne import { useNotebookSizeState } from './useNotebookSizeState'; import useDefaultStorageClass from './storage/useDefaultStorageClass'; import usePreferredStorageClass from './storage/usePreferredStorageClass'; +import { ConnectionsFormSection } from './connections/ConnectionsFormSection'; +import { getConnectionsFromNotebook } from './connections/utils'; type SpawnerPageProps = { existingNotebook?: NotebookKind; }; const SpawnerPage: React.FC = ({ existingNotebook }) => { - const { currentProject, dataConnections } = React.useContext(ProjectDetailsContext); + const { + currentProject, + dataConnections, + connections: { data: projectConnections, refresh: refreshProjectConnections }, + } = React.useContext(ProjectDetailsContext); const displayName = getDisplayNameFromK8sResource(currentProject); const k8sNameDescriptionData = useK8sNameDescriptionFieldData({ @@ -88,6 +96,13 @@ const SpawnerPage: React.FC = ({ existingNotebook }) => { existingNotebook, ); + const isConnectionTypesEnabled = useConnectionTypesEnabled(); + const [notebookConnections, setNotebookConnections] = React.useState( + isConnectionTypesEnabled && existingNotebook + ? getConnectionsFromNotebook(existingNotebook, projectConnections) + : [], + ); + const [selectedAcceleratorProfile, setSelectedAcceleratorProfile] = useGenericObjectState({ profile: undefined, @@ -218,16 +233,28 @@ const SpawnerPage: React.FC = ({ existingNotebook }) => { /> - - - + ) : ( + + + + )} @@ -257,6 +284,8 @@ const SpawnerPage: React.FC = ({ existingNotebook }) => { storageData={storageData} envVariables={envVariables} dataConnection={dataConnectionData} + isConnectionTypesEnabled={isConnectionTypesEnabled} + connections={notebookConnections} canEnablePipelines={canEnablePipelines} /> )} diff --git a/frontend/src/pages/projects/screens/spawner/connections/ConnectionsFormSection.tsx b/frontend/src/pages/projects/screens/spawner/connections/ConnectionsFormSection.tsx new file mode 100644 index 0000000000..53f6aa501e --- /dev/null +++ b/frontend/src/pages/projects/screens/spawner/connections/ConnectionsFormSection.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { + Bullseye, + Button, + EmptyState, + EmptyStateBody, + EmptyStateHeader, + EmptyStateIcon, + FormSection, +} from '@patternfly/react-core'; +import { PlusCircleIcon } from '@patternfly/react-icons'; +import { SortableData, Table } from '~/components/table'; +import { createSecret, replaceSecret } from '~/api'; +import { NotebookKind, ProjectKind } from '~/k8sTypes'; +import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import { Connection } from '~/concepts/connectionTypes/types'; +import { useWatchConnectionTypes } from '~/utilities/useWatchConnectionTypes'; +import { useNotebooksStates } from '~/pages/projects/notebook/useNotebooksStates'; +import { SpawnerPageSectionTitles } from '~/pages/projects/screens/spawner/const'; +import { SpawnerPageSectionID } from '~/pages/projects/screens/spawner/types'; +import { ManageConnectionModal } from '~/pages/projects/screens/detail/connections/ManageConnectionsModal'; +import ConnectionsTableRow from '~/pages/projects/screens/detail/connections/ConnectionsTableRow'; +import { SelectConnectionsModal } from './SelectConnectionsModal'; +import { connectionEnvVarConflicts, DuplicateEnvVarWarning } from './DuplicateEnvVarsWarning'; +import { DetachConnectionModal } from './DetachConnectionModal'; + +const columns: SortableData[] = [ + { + field: 'name', + label: 'Name', + sortable: (a, b) => + getDisplayNameFromK8sResource(a).localeCompare(getDisplayNameFromK8sResource(b)), + }, + { + field: 'type', + label: 'Type', + sortable: (a, b) => + a.metadata.annotations['opendatahub.io/connection-type'].localeCompare( + b.metadata.annotations['opendatahub.io/connection-type'], + ), + }, + { + field: 'kebab', + label: '', + sortable: false, + }, +]; + +type Props = { + project: ProjectKind; + projectConnections: Connection[]; + refreshProjectConnections: () => void; + notebook?: NotebookKind; + notebookDisplayName: string; + selectedConnections: Connection[]; + setSelectedConnections: (connections: Connection[]) => void; +}; + +export const ConnectionsFormSection: React.FC = ({ + project, + projectConnections, + refreshProjectConnections, + notebook, + notebookDisplayName, + selectedConnections, + setSelectedConnections, +}) => { + const [connectionTypes] = useWatchConnectionTypes(); + + const [initialNumberConnections] = React.useState(selectedConnections.length); + const notebookArray = React.useMemo(() => (notebook ? [notebook] : []), [notebook]); + const [notebookStates] = useNotebooksStates( + notebookArray, + notebook?.metadata.namespace || '', + initialNumberConnections > 0, + ); + const isRunning = React.useMemo( + () => + !!notebookStates.find((n) => n.notebook.metadata.name === notebook?.metadata.name)?.isRunning, + [notebookStates, notebook], + ); + + const [showAttachConnectionsModal, setShowAttachConnectionsModal] = React.useState(false); + const [detachConnectionModal, setDetachConnectionModal] = React.useState(); + const [manageConnectionModal, setManageConnectionModal] = React.useState<{ + connection?: Connection; + isEdit?: boolean; + }>(); + + const envVarConflicts = React.useMemo( + () => connectionEnvVarConflicts(selectedConnections), + [selectedConnections], + ); + + return ( + + {SpawnerPageSectionTitles[SpawnerPageSectionID.CONNECTIONS]}{' '} + {' '} + + + } + id={SpawnerPageSectionID.CONNECTIONS} + aria-label={SpawnerPageSectionTitles[SpawnerPageSectionID.CONNECTIONS]} + > + {envVarConflicts.length > 0 && } + {selectedConnections.length > 0 ? ( + ( + { + setManageConnectionModal({ connection, isEdit: true }); + }, + }, + { + title: 'Detach', + onClick: () => { + setDetachConnectionModal(connection); + }, + }, + ]} + showCompatibilityCell={false} + showConnectedResourcesCell={false} + showWarningIcon={ + !!envVarConflicts.find( + (conflict) => + conflict.firstConnection === getDisplayNameFromK8sResource(connection) || + conflict.secondConnection === getDisplayNameFromK8sResource(connection), + ) + } + /> + )} + isStriped + /> + ) : ( + + + } + titleText="No connections" + headingLevel="h2" + /> + + 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. + + + + )} + {showAttachConnectionsModal && ( + !selectedConnections.find((sc) => pc.metadata.name === sc.metadata.name), + )} + onSave={(connections) => { + setSelectedConnections([...selectedConnections, ...connections]); + setShowAttachConnectionsModal(false); + }} + onClose={() => setShowAttachConnectionsModal(false)} + /> + )} + {detachConnectionModal && ( + { + setSelectedConnections( + selectedConnections.filter( + (c) => c.metadata.name !== detachConnectionModal.metadata.name, + ), + ); + setDetachConnectionModal(undefined); + }} + onClose={() => setDetachConnectionModal(undefined)} + /> + )} + {manageConnectionModal && ( + { + setManageConnectionModal(undefined); + if (refresh) { + refreshProjectConnections(); + } + }} + onSubmit={(connection: Connection) => { + if (manageConnectionModal.isEdit) { + return replaceSecret(connection); + } + setSelectedConnections([...selectedConnections, connection]); + return createSecret(connection); + }} + isEdit={manageConnectionModal.isEdit} + /> + )} + + ); +}; diff --git a/frontend/src/pages/projects/screens/spawner/connections/DetachConnectionModal.tsx b/frontend/src/pages/projects/screens/spawner/connections/DetachConnectionModal.tsx new file mode 100644 index 0000000000..36270d015f --- /dev/null +++ b/frontend/src/pages/projects/screens/spawner/connections/DetachConnectionModal.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Button, Modal } from '@patternfly/react-core'; +import { Connection } from '~/concepts/connectionTypes/types'; +import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; + +type Props = { + connection: Connection; + isRunning?: boolean; + notebookDisplayName: string; + onDetach: () => void; + onClose: () => void; +}; + +export const DetachConnectionModal: React.FC = ({ + connection, + isRunning, + notebookDisplayName, + onDetach, + onClose, +}) => ( + { + onDetach(); + }} + > + Detach + , + , + ]} + > + {isRunning ? ( + <> + The {getDisplayNameFromK8sResource(connection)} connection will be detached from the + workbench. To avoid losing your work, save any recent data in the current workbench,{' '} + {notebookDisplayName}. + + ) : ( + <> + The {getDisplayNameFromK8sResource(connection)} connection will be detached from the{' '} + {notebookDisplayName} workbench. + + )} + +); diff --git a/frontend/src/pages/projects/screens/spawner/connections/DuplicateEnvVarsWarning.tsx b/frontend/src/pages/projects/screens/spawner/connections/DuplicateEnvVarsWarning.tsx new file mode 100644 index 0000000000..c6fd78776b --- /dev/null +++ b/frontend/src/pages/projects/screens/spawner/connections/DuplicateEnvVarsWarning.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Alert, ExpandableSection, List, ListItem } from '@patternfly/react-core'; +import { Connection } from '~/concepts/connectionTypes/types'; +import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; + +type Conflict = { + firstConnection: string; + secondConnection: string; + vars: string[]; +}; + +export const connectionEnvVarConflicts = (connections: Connection[]): Conflict[] => { + const conflicts: Conflict[] = []; + for (const first of connections) { + for (const second of connections) { + if (first.metadata.name === second.metadata.name) { + break; + } + if (!first.data || !second.data) { + continue; + } + const envVars = [...Object.keys(first.data), ...Object.keys(second.data)]; + const duplicates = envVars.filter((e, i) => envVars.indexOf(e) !== i); + if (duplicates.length > 0) { + conflicts.push({ + firstConnection: getDisplayNameFromK8sResource(first), + secondConnection: getDisplayNameFromK8sResource(second), + vars: duplicates, + }); + } + } + } + return conflicts; +}; + +type Props = { + envVarConflicts: Conflict[]; +}; + +export const DuplicateEnvVarWarning: React.FC = ({ envVarConflicts }) => { + const [showConflicts, setShowConflicts] = React.useState(false); + + return ( + + Two or more connections contain conflicting environment variables. When environment variables + conflict, only one of the values is used in the workbench. + setShowConflicts((prev) => !prev)} + isExpanded={showConflicts} + isIndented + > + {envVarConflicts.map((conflict, conflictIndex) => ( +
+ {conflict.firstConnection} and {conflict.secondConnection} contain the + following conflicting variables: + + {conflict.vars.map((value, valueIndex) => ( + + {value} + + ))} + +
+
+ ))} +
+
+ ); +}; diff --git a/frontend/src/pages/projects/screens/spawner/connections/SelectConnectionsModal.tsx b/frontend/src/pages/projects/screens/spawner/connections/SelectConnectionsModal.tsx new file mode 100644 index 0000000000..f50fcdc5c8 --- /dev/null +++ b/frontend/src/pages/projects/screens/spawner/connections/SelectConnectionsModal.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Button, Flex, FlexItem, Form, FormGroup, Modal, Truncate } from '@patternfly/react-core'; +import { MultiSelection, SelectionOptions } from '~/components/MultiSelection'; +import { Connection, ConnectionTypeConfigMapObj } from '~/concepts/connectionTypes/types'; +import { getDescriptionFromK8sResource, getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import { connectionEnvVarConflicts, DuplicateEnvVarWarning } from './DuplicateEnvVarsWarning'; + +type Props = { + connectionTypes: ConnectionTypeConfigMapObj[]; + connectionsToList: Connection[]; + onSave: (connections: Connection[]) => void; + onClose: () => void; +}; + +export const SelectConnectionsModal: React.FC = ({ + connectionTypes, + connectionsToList, + onSave, + onClose, +}) => { + const [selectionOptions, setSelectionOptions] = React.useState(() => + connectionsToList.map((c) => { + const category = connectionTypes + .find( + (type) => c.metadata.annotations['opendatahub.io/connection-type'] === type.metadata.name, + ) + ?.data?.category?.join(' '); + + return { + id: c.metadata.name, + name: getDisplayNameFromK8sResource(c), + selected: false, + description: ( + + {getDescriptionFromK8sResource(c) && ( + + + + )} + {category && ( + + + + )} + + ), + data: `${getDescriptionFromK8sResource(c)}`, + }; + }), + ); + + const selectedConnections = React.useMemo(() => { + const connectionsFromSelections: Connection[] = []; + for (const s of selectionOptions) { + const selected = connectionsToList.find( + (c) => c.metadata.name === s.id && s.selected === true, + ); + if (selected) { + connectionsFromSelections.push(selected); + } + } + return connectionsFromSelections; + }, [connectionsToList, selectionOptions]); + + const envVarConflicts = React.useMemo( + () => connectionEnvVarConflicts(selectedConnections), + [selectedConnections], + ); + + return ( + selection.selected === false)} + onClick={() => { + onSave(selectedConnections); + }} + > + Attach + , + , + ]} + > +
+ {envVarConflicts.length > 0 && } + + + options.filter( + (o) => + !filterText || + o.name.toLowerCase().includes(filterText.toLowerCase()) || + o.data?.toLowerCase().includes(filterText.toLowerCase()), + ) + } + /> + + +
+ ); +}; diff --git a/frontend/src/pages/projects/screens/spawner/connections/__tests__/ConnectionsFormSection.spec.tsx b/frontend/src/pages/projects/screens/spawner/connections/__tests__/ConnectionsFormSection.spec.tsx new file mode 100644 index 0000000000..3ecb026759 --- /dev/null +++ b/frontend/src/pages/projects/screens/spawner/connections/__tests__/ConnectionsFormSection.spec.tsx @@ -0,0 +1,135 @@ +import React, { act } from 'react'; +import '@testing-library/jest-dom'; +import { render, within } from '@testing-library/react'; +import { mockProjectK8sResource } from '~/__mocks__'; +import { ConnectionsFormSection } from '~/pages/projects/screens/spawner/connections/ConnectionsFormSection'; +import { mockConnection } from '~/__mocks__/mockConnection'; + +jest.mock('~/pages/projects/notebook/useNotebooksStates', () => ({ + useNotebooksStates: jest.fn().mockReturnValue([[]]), +})); +jest.mock('~/utilities/useWatchConnectionTypes', () => ({ + useWatchConnectionTypes: jest.fn().mockReturnValue([[]]), +})); + +describe('ConnectionsFormSection', () => { + it('should render empty section', () => { + const result = render( + undefined} + notebookDisplayName="" + selectedConnections={[]} + setSelectedConnections={() => undefined} + />, + ); + + expect(result.getByRole('button', { name: 'Attach existing connections' })).toBeTruthy(); + expect(result.getByRole('button', { name: 'Create connection' })).toBeTruthy(); + expect(result.getByRole('heading', { name: 'No connections' })).toBeTruthy(); + }); + + it('should list existing connections', () => { + const result = render( + undefined} + notebookDisplayName="" + selectedConnections={[ + mockConnection({ name: 's3-connection-1', displayName: 's3 connection 1' }), + mockConnection({ name: 's3-connection-2', displayName: 's3 connection 2' }), + ]} + setSelectedConnections={() => undefined} + />, + ); + + expect(result.getByRole('columnheader', { name: 'Name' })).toBeTruthy(); + expect(result.getByRole('columnheader', { name: 'Type' })).toBeTruthy(); + expect(result.getByRole('cell', { name: 's3 connection 1' })).toBeTruthy(); + expect(result.getByRole('cell', { name: 's3 connection 2' })).toBeTruthy(); + expect(result.getAllByRole('cell', { name: 's3' }).length).toEqual(2); + }); + + it('should show env conflicts', async () => { + const result = render( + undefined} + notebookDisplayName="" + selectedConnections={[ + mockConnection({ + name: 's3-connection-1', + displayName: 's3 connection 1', + data: { ENV1: '' }, + }), + mockConnection({ + name: 's3-connection-2', + displayName: 's3 connection 2', + data: { ENV1: '' }, + }), + ]} + setSelectedConnections={() => undefined} + />, + ); + + expect( + result.getByRole('heading', { name: 'Warning alert: Connections conflict' }), + ).toBeTruthy(); + + await act(async () => result.getByRole('button', { name: 'Show conflicts' }).click()); + expect(result.getByTestId('envvar-conflict-0')).toHaveTextContent( + 's3 connection 2 and s3 connection 1 contain the following conflicting variables:ENV1', + ); + }); + + it('should attach existing connection', async () => { + const setSelectedConnectionsMock = jest.fn(); + const result = render( + undefined} + notebookDisplayName="" + selectedConnections={[ + mockConnection({ name: 's3-connection-1', displayName: 's3 connection 1' }), + ]} + setSelectedConnections={setSelectedConnectionsMock} + />, + ); + + act(() => result.getByRole('button', { name: 'Attach existing connections' }).click()); + const attachModal = result.getByRole('dialog', { name: 'Attach existing connections' }); + expect(attachModal).toBeTruthy(); + expect(within(attachModal).getByRole('button', { name: 'Attach' })).toBeDisabled(); + expect(within(attachModal).getByRole('button', { name: 'Cancel' })).toBeEnabled(); + expect(within(attachModal).getByRole('combobox', { name: 'Type to filter' })).toHaveValue(''); + + await act(async () => result.getByRole('button', { name: 'Connections' }).click()); + expect(within(attachModal).queryByRole('option', { name: 's3 connection 1' })).toBeFalsy(); // don't show attached connections + expect(within(attachModal).getByRole('option', { name: 's3 connection 2' })).toBeTruthy(); + expect(within(attachModal).getByRole('option', { name: 's3 connection 3' })).toBeTruthy(); + + await act(async () => result.getByRole('option', { name: 's3 connection 3' }).click()); + expect( + within(attachModal).getByRole('group', { name: 'Current selections' }), + ).toHaveTextContent('s3 connection 3'); + + await act(async () => result.getByRole('button', { name: 'Attach' }).click()); + expect(result.queryByRole('dialog', { name: 'Attach existing connections' })).toBeFalsy(); + expect(setSelectedConnectionsMock).toBeCalledWith([ + mockConnection({ name: 's3-connection-1', displayName: 's3 connection 1' }), + mockConnection({ name: 's3-connection-3', displayName: 's3 connection 3' }), + ]); + }); +}); diff --git a/frontend/src/pages/projects/screens/spawner/connections/utils.ts b/frontend/src/pages/projects/screens/spawner/connections/utils.ts new file mode 100644 index 0000000000..b81f21a39b --- /dev/null +++ b/frontend/src/pages/projects/screens/spawner/connections/utils.ts @@ -0,0 +1,43 @@ +import { Connection } from '~/concepts/connectionTypes/types'; +import { NotebookKind } from '~/k8sTypes'; +import { EnvironmentFromVariable } from '~/pages/projects/types'; + +export const getConnectionsFromNotebook = ( + notebook: NotebookKind, + projectConnections: Connection[], +): Connection[] => { + const connectionNames = []; + for (const env of notebook.spec.template.spec.containers[0].envFrom ?? []) { + if (env.secretRef?.name) { + connectionNames.push(env.secretRef.name); + } + } + + const attachedConnections: Connection[] = []; + for (const name of connectionNames) { + const found = projectConnections.find((c) => c.metadata.name === name); + if (found) { + attachedConnections.push(found); + } + } + return attachedConnections; +}; + +const isNameInConnections = (name: string | undefined, connections: Connection[]): boolean => + !!name && !!connections.find((c) => name === c.metadata.name); + +export const setConnectionsOnEnvFrom = ( + notebookConnections: Connection[], + envFrom: EnvironmentFromVariable[], + projectConnections: Connection[], +): EnvironmentFromVariable[] => { + const newEnvFrom = envFrom.filter( + (env) => !isNameInConnections(env.secretRef?.name, projectConnections), + ); + newEnvFrom.push( + ...notebookConnections.map((c) => ({ + secretRef: { name: c.metadata.name }, + })), + ); + return newEnvFrom; +}; diff --git a/frontend/src/pages/projects/screens/spawner/const.ts b/frontend/src/pages/projects/screens/spawner/const.ts index 9b2b1754c1..4407ba4934 100644 --- a/frontend/src/pages/projects/screens/spawner/const.ts +++ b/frontend/src/pages/projects/screens/spawner/const.ts @@ -8,6 +8,7 @@ export const SpawnerPageSectionTitles: SpawnerPageSectionTitlesType = { [SpawnerPageSectionID.ENVIRONMENT_VARIABLES]: 'Environment variables', [SpawnerPageSectionID.CLUSTER_STORAGE]: 'Cluster storage', [SpawnerPageSectionID.DATA_CONNECTIONS]: 'Data connections', + [SpawnerPageSectionID.CONNECTIONS]: 'Connections', }; export const ScrollableSelectorID = 'workbench-spawner-page'; diff --git a/frontend/src/pages/projects/screens/spawner/environmentVariables/useNotebookEnvVariables.tsx b/frontend/src/pages/projects/screens/spawner/environmentVariables/useNotebookEnvVariables.tsx index 73a3cf7fca..25ef5c66d6 100644 --- a/frontend/src/pages/projects/screens/spawner/environmentVariables/useNotebookEnvVariables.tsx +++ b/frontend/src/pages/projects/screens/spawner/environmentVariables/useNotebookEnvVariables.tsx @@ -1,13 +1,15 @@ import * as React from 'react'; -import { DATA_CONNECTION_PREFIX, getConfigMap, getSecret } from '~/api'; +import { getConfigMap, getSecret } from '~/api'; import { ConfigMapKind, NotebookKind, SecretKind } from '~/k8sTypes'; import { EnvVarResourceType } from '~/types'; +import { isConnection } from '~/concepts/connectionTypes/types'; import { ConfigMapCategory, EnvironmentVariableType, EnvVariable, SecretCategory, } from '~/pages/projects/types'; +import { isSecretKind } from './utils'; export const fetchNotebookEnvVariables = (notebook: NotebookKind): Promise => { const envFromList = notebook.spec.template.spec.containers[0].envFrom || []; @@ -40,10 +42,7 @@ export const fetchNotebookEnvVariables = (notebook: NotebookKind): Promise ({ key, value: data[key] })) : [], }, }; - } else if ( - resource.kind === EnvVarResourceType.Secret && - !resource.metadata.name.startsWith(DATA_CONNECTION_PREFIX) - ) { + } else if (isSecretKind(resource) && !isConnection(resource)) { envVar = { type: EnvironmentVariableType.SECRET, existingName: resource.metadata.name, diff --git a/frontend/src/pages/projects/screens/spawner/types.ts b/frontend/src/pages/projects/screens/spawner/types.ts index 1fc4d61f7d..b781b39c23 100644 --- a/frontend/src/pages/projects/screens/spawner/types.ts +++ b/frontend/src/pages/projects/screens/spawner/types.ts @@ -7,6 +7,7 @@ export enum SpawnerPageSectionID { ENVIRONMENT_VARIABLES = 'environment-variables', CLUSTER_STORAGE = 'cluster-storage', DATA_CONNECTIONS = 'data-connections', + CONNECTIONS = 'connections', } export type SpawnerPageSectionTitlesType = {