Skip to content

Commit

Permalink
Add connections to notebook spawner page (#3311)
Browse files Browse the repository at this point in the history
* notebook connections

* Add cypress test

* small updates

* updates part 2
  • Loading branch information
emilys314 authored Oct 16, 2024
1 parent 8e85c4f commit cdc5b4c
Show file tree
Hide file tree
Showing 19 changed files with 934 additions and 79 deletions.
30 changes: 30 additions & 0 deletions frontend/src/__tests__/cypress/cypress/pages/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,22 @@ class NotebookRow extends TableRow {
}
}

class AttachConnectionModal extends Modal {
constructor() {
super('Attach existing connections');
}

selectConnectionOption(name: string) {
this.find().findByRole('button', { name: 'Connections' }).click();
this.find().findByRole('option', { name }).click();
this.find().findByRole('button', { name: 'Connections' }).click();
}

findAttachButton() {
return this.find().findByTestId('attach-button');
}
}

class CreateSpawnerPage {
k8sNameDescription = new K8sNameDescriptionField('workbench');

Expand Down Expand Up @@ -309,6 +325,19 @@ class CreateSpawnerPage {
findContainerSizeInput(name: string) {
return cy.findByTestId('container-size-group').contains(name);
}

findAttachConnectionButton() {
return cy.findByTestId('attach-existing-connection-button');
}

findConnectionsTable() {
return cy.findByTestId('connections-table');
}

findConnectionsTableRow(name: string, type: string) {
this.findConnectionsTable().find(`[data-label=Name]`).contains(name);
this.findConnectionsTable().find(`[data-label=Type]`).contains(type);
}
}

class EditSpawnerPage extends CreateSpawnerPage {
Expand Down Expand Up @@ -373,3 +402,4 @@ export const notebookConfirmModal = new NotebookConfirmModal();
export const editSpawnerPage = new EditSpawnerPage();
export const storageModal = new StorageModal();
export const notFoundSpawnerPage = new NotFoundSpawnerPage();
export const attachConnectionModal = new AttachConnectionModal();
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { mockImageStreamK8sResource } from '~/__mocks__/mockImageStreamK8sResour
import { mockPVCK8sResource } from '~/__mocks__/mockPVCK8sResource';
import { mockPodK8sResource } from '~/__mocks__/mockPodK8sResource';
import {
attachConnectionModal,
createSpawnerPage,
editSpawnerPage,
notFoundSpawnerPage,
Expand All @@ -37,6 +38,7 @@ import {
import { mock200Status } from '~/__mocks__/mockK8sStatus';
import type { NotebookSize } from '~/types';
import { mockAcceleratorProfile } from '~/__mocks__/mockAcceleratorProfile';
import { mockConnectionTypeConfigMap } from '~/__mocks__/mockConnectionType';

const configYamlPath = '../../__mocks__/mock-upload-configmap.yaml';

Expand Down Expand Up @@ -407,6 +409,74 @@ describe('Workbench page', () => {
verifyRelativeURL('/projects/test-project?section=workbenches');
});

it('Create workbench with connection', () => {
initIntercepts({ isEmpty: true });
cy.interceptOdh('GET /api/config', mockDashboardConfig({ disableConnectionTypes: false }));
cy.interceptOdh('GET /api/connection-types', [mockConnectionTypeConfigMap({})]);
cy.interceptK8sList(
{ model: SecretModel, ns: 'test-project' },
mockK8sResourceList([
mockSecretK8sResource({ name: 'test1', displayName: 'test1' }),
mockSecretK8sResource({ name: 'test2', displayName: 'test2' }),
]),
);

workbenchPage.visit('test-project');
workbenchPage.findCreateButton().click();
createSpawnerPage.findSubmitButton().should('be.disabled');
verifyRelativeURL('/projects/test-project/spawner');
createSpawnerPage.k8sNameDescription.findDisplayNameInput().fill('1234');
createSpawnerPage.findNotebookImage('test-9').click();

createSpawnerPage.findAttachConnectionButton().click();
attachConnectionModal.shouldBeOpen();
attachConnectionModal.findAttachButton().should('be.disabled');
attachConnectionModal.selectConnectionOption('test1');
attachConnectionModal.findAttachButton().should('be.enabled');
attachConnectionModal.selectConnectionOption('test2');
attachConnectionModal.findAttachButton().click();

createSpawnerPage.findConnectionsTableRow('test1', 's3');
createSpawnerPage.findConnectionsTableRow('test2', 's3');

createSpawnerPage.findSubmitButton().click();
cy.wait('@createWorkbench').then((interception) => {
expect(interception.request.body).to.containSubset({
metadata: {
annotations: {
'openshift.io/display-name': '1234',
},
name: 'wb-1234',
namespace: 'test-project',
},
spec: {
template: {
spec: {
affinity: {},
containers: [
{
envFrom: [
{
secretRef: {
name: 'test1',
},
},
{
secretRef: {
name: 'test2',
},
},
],
},
],
},
},
},
});
});
verifyRelativeURL('/projects/test-project?section=workbenches');
});

it('list workbench and table sorting', () => {
initIntercepts({
notebookSizes: [
Expand Down
24 changes: 15 additions & 9 deletions frontend/src/components/MultiSelection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SelectOptionProps, 'id'> & {
id: number | string;
name: string;
selected?: boolean;
Expand Down Expand Up @@ -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<MultiSelectionProps> = ({
value = [],
Expand All @@ -69,6 +73,7 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
isCreatable = false,
isCreateOptionOnTop = false,
createOptionMessage = defaultCreateOptionMessage,
filterFunction = defaultFilterFunction,
}) => {
const [isOpen, setIsOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState<string>('');
Expand All @@ -80,16 +85,14 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
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);
Expand All @@ -104,10 +107,11 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({

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(() => {
Expand Down Expand Up @@ -340,6 +344,7 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
value={option.id}
ref={null}
isSelected={option.selected}
description={option.description}
>
{option.name}
</SelectOption>
Expand All @@ -363,6 +368,7 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
value={option.id}
ref={null}
isSelected={option.selected}
description={option.description}
>
{option.name}
</SelectOption>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/table/TableRowTitleDescription.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +20,7 @@ type TableRowTitleDescriptionProps = {
const TableRowTitleDescription: React.FC<TableRowTitleDescriptionProps> = ({
title,
boldTitle = true,
titleIcon,
description,
resource,
subtitle,
Expand Down Expand Up @@ -56,6 +58,7 @@ const TableRowTitleDescription: React.FC<TableRowTitleDescriptionProps> = ({
) : (
title
)}
{titleIcon}
</div>
{subtitle}
{descriptionNode}
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/concepts/connectionTypes/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,17 @@ export const parseConnectionSecretValues = (

return response;
};

export const getConnectionTypeDisplayName = (
connection: Connection,
connectionTypes: ConnectionTypeConfigMapObj[],
): string => {
const matchingType = connectionTypes.find(
(type) =>
type.metadata.name === connection.metadata.annotations['opendatahub.io/connection-type'],
);
return (
matchingType?.metadata.annotations?.['openshift.io/display-name'] ||
connection.metadata.annotations['opendatahub.io/connection-type']
);
};
16 changes: 11 additions & 5 deletions frontend/src/pages/projects/notebook/useNotebooksStates.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,11 +8,17 @@ import { NotebookState } from './types';
export const useNotebooksStates = (
notebooks: NotebookKind[],
namespace: string,
checkStatus = true,
): FetchState<NotebookState[]> => {
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<NotebookState[]>(fetchNotebooksStatus, [], {
refreshRate: POLL_INTERVAL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,20 @@ const ConnectionsTable: React.FC<ConnectionsTableProps> = ({
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
Expand Down
Loading

0 comments on commit cdc5b4c

Please sign in to comment.