Skip to content

Commit

Permalink
feat(ws): add namespace dropdown to UI (#154)
Browse files Browse the repository at this point in the history
* feat(ws): Notebooks 2.0 // Frontend // Namespace selector

Signed-off-by: yelias <[email protected]>

* add MenuSearch

Signed-off-by: yelias <[email protected]>

* feat(ws): Notebooks 2.0 // Frontend // Namespace selector

Signed-off-by: yelias <[email protected]>

---------

Signed-off-by: yelias <[email protected]>
Co-authored-by: yelias <[email protected]>
  • Loading branch information
YosiElias and yelias authored Jan 9, 2025
1 parent 0f363c0 commit 57fc8b1
Show file tree
Hide file tree
Showing 12 changed files with 295 additions and 21 deletions.
8 changes: 5 additions & 3 deletions workspaces/frontend/src/__mocks__/mockNamespaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { NamespacesList } from '~/app/types';

export const mockNamespaces = (): NamespacesList => ({
data: [{ name: 'default' }, { name: 'kubeflow' }, { name: 'custom-namespace' }],
});
export const mockNamespaces: NamespacesList = [
{ name: 'default' },
{ name: 'kubeflow' },
{ name: 'custom-namespace' },
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { mockNamespaces } from '~/__mocks__/mockNamespaces';
import { mockBFFResponse } from '~/__mocks__/utils';

const namespaces = ['default', 'kubeflow', 'custom-namespace'];

describe('Namespace Selector Dropdown', () => {
beforeEach(() => {
// Mock the namespaces API response
cy.intercept('GET', '/api/v1/namespaces', {
body: mockBFFResponse(mockNamespaces),
}).as('getNamespaces');
cy.visit('/');
cy.wait('@getNamespaces');
});

it('should open the namespace dropdown and select a namespace', () => {
cy.findByTestId('namespace-toggle').click();
cy.findByTestId('namespace-dropdown').should('be.visible');
namespaces.forEach((ns) => {
cy.findByTestId(`dropdown-item-${ns}`).should('exist').and('contain', ns);
});

cy.findByTestId('dropdown-item-kubeflow').click();

// Assert the selected namespace is updated
cy.findByTestId('namespace-toggle').should('contain', 'kubeflow');
});

it('should display the default namespace initially', () => {
cy.findByTestId('namespace-toggle').should('contain', 'default');
});

it('should navigate to notebook settings and retain the namespace', () => {
cy.findByTestId('namespace-toggle').click();
cy.findByTestId('dropdown-item-custom-namespace').click();
cy.findByTestId('namespace-toggle').should('contain', 'custom-namespace');
// Click on navigation button
cy.get('#Settings').click();
cy.findByTestId('nav-link-/notebookSettings').click();
cy.findByTestId('namespace-toggle').should('contain', 'custom-namespace');
});

it('should filter namespaces based on search input', () => {
cy.findByTestId('namespace-toggle').click();
cy.findByTestId('namespace-search-input').type('custom');
cy.findByTestId('namespace-search-input').find('input').should('have.value', 'custom');
cy.findByTestId('namespace-search-button').click();
// Verify that only the matching namespace is displayed
namespaces.forEach((ns) => {
if (ns === 'custom-namespace') {
cy.findByTestId(`dropdown-item-${ns}`).should('exist').and('contain', ns);
} else {
cy.findByTestId(`dropdown-item-${ns}`).should('not.exist');
}
});
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { pageNotfound } from '~/__tests__/cypress/cypress/pages/pageNotFound';
import { home } from '~/__tests__/cypress/cypress/pages/home';
import { mockNamespaces } from '~/__mocks__/mockNamespaces';
import { mockBFFResponse } from '~/__mocks__/utils';

describe('Application', () => {
beforeEach(() => {
// Mock the namespaces API response
cy.intercept('GET', '/api/v1/namespaces', {
body: mockBFFResponse(mockNamespaces),
}).as('getNamespaces');
cy.visit('/');
cy.wait('@getNamespaces');
});

it('Page not found should render', () => {
pageNotfound.visit();
});
Expand Down
21 changes: 13 additions & 8 deletions workspaces/frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
Title,
} from '@patternfly/react-core';
import { BarsIcon } from '@patternfly/react-icons';
import NamespaceSelector from '~/shared/components/NamespaceSelector';
import { NamespaceContextProvider } from './context/NamespaceContextProvider';
import AppRoutes from './AppRoutes';
import NavSidebar from './NavSidebar';
import { NotebookContextProvider } from './context/NotebookContext';
Expand All @@ -29,21 +31,24 @@ const App: React.FC = () => {
<Title headingLevel="h2" size="3xl">
Kubeflow Notebooks 2.0
</Title>
<NamespaceSelector />
</Flex>
</MastheadContent>
</Masthead>
);

return (
<NotebookContextProvider>
<Page
mainContainerId="primary-app-container"
masthead={masthead}
isManagedSidebar
sidebar={<NavSidebar />}
>
<AppRoutes />
</Page>
<NamespaceContextProvider>
<Page
mainContainerId="primary-app-container"
masthead={masthead}
isManagedSidebar
sidebar={<NavSidebar />}
>
<AppRoutes />
</Page>
</NamespaceContextProvider>
</NotebookContextProvider>
);
};
Expand Down
5 changes: 3 additions & 2 deletions workspaces/frontend/src/app/NavSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { useNavData, isNavDataGroup, NavDataHref, NavDataGroup } from './AppRout

const NavHref: React.FC<{ item: NavDataHref }> = ({ item }) => (
<NavItem key={item.label} data-id={item.label} itemId={item.label}>
<NavLink to={item.path}>{item.label}</NavLink>
<NavLink to={item.path} data-testid={`nav-link-${item.path}`}>
{item.label}
</NavLink>
</NavItem>
);

Expand Down Expand Up @@ -40,7 +42,6 @@ const NavGroup: React.FC<{ item: NavDataGroup }> = ({ item }) => {

const NavSidebar: React.FC = () => {
const navData = useNavData();

return (
<PageSidebar>
<PageSidebarBody>
Expand Down
56 changes: 56 additions & 0 deletions workspaces/frontend/src/app/context/NamespaceContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React, { useState, useContext, ReactNode, useMemo, useCallback } from 'react';
import useMount from '~/app/hooks/useMount';
import useNamespaces from '~/app/hooks/useNamespaces';

interface NamespaceContextType {
namespaces: string[];
selectedNamespace: string;
setSelectedNamespace: (namespace: string) => void;
}

const NamespaceContext = React.createContext<NamespaceContextType | undefined>(undefined);

export const useNamespaceContext = (): NamespaceContextType => {
const context = useContext(NamespaceContext);
if (!context) {
throw new Error('useNamespaceContext must be used within a NamespaceContextProvider');
}
return context;
};

interface NamespaceContextProviderProps {
children: ReactNode;
}

export const NamespaceContextProvider: React.FC<NamespaceContextProviderProps> = ({ children }) => {
const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNamespace, setSelectedNamespace] = useState<string>('');
const [namespacesData, loaded, loadError] = useNamespaces();

const fetchNamespaces = useCallback(() => {
if (loaded && namespacesData) {
const namespaceNames = namespacesData.map((ns) => ns.name);
setNamespaces(namespaceNames);
setSelectedNamespace(namespaceNames.length > 0 ? namespaceNames[0] : '');
} else {
if (loadError) {
console.error('Error loading namespaces: ', loadError);
}
setNamespaces([]);
setSelectedNamespace('');
}
}, [loaded, namespacesData, loadError]);

useMount(fetchNamespaces);

const namespacesContextValues = useMemo(
() => ({ namespaces, selectedNamespace, setSelectedNamespace }),
[namespaces, selectedNamespace],
);

return (
<NamespaceContext.Provider value={namespacesContextValues}>
{children}
</NamespaceContext.Provider>
);
};
2 changes: 1 addition & 1 deletion workspaces/frontend/src/app/context/NotebookContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const NotebookContext = React.createContext<NotebookContextType>({
});

export const NotebookContextProvider: React.FC = ({ children }) => {
const hostPath = `/api/${BFF_API_VERSION}/`;
const hostPath = `/api/${BFF_API_VERSION}`;

const [apiState, refreshAPIState] = useNotebookAPIState(hostPath);

Expand Down
9 changes: 9 additions & 0 deletions workspaces/frontend/src/app/hooks/useMount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useEffect } from 'react';

const useMount = (callback: () => void): void => {
useEffect(() => {
callback();
}, [callback]);
};

export default useMount;
4 changes: 1 addition & 3 deletions workspaces/frontend/src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ export type Namespace = {
name: string;
};

export type NamespacesList = {
data: Namespace[];
};
export type NamespacesList = Namespace[];

export type GetNamespaces = (opts: APIOptions) => Promise<NamespacesList>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ const APIOptionsMock = {};

describe('getNamespaces', () => {
it('should call restGET and handleRestFailures to fetch namespaces', async () => {
const response = await getNamespaces(`/api/${BFF_API_VERSION}/namespaces/`)(APIOptionsMock);
const response = await getNamespaces(`/api/${BFF_API_VERSION}/namespaces`)(APIOptionsMock);
expect(response).toEqual(mockRestResponse);
expect(restGETMock).toHaveBeenCalledTimes(1);
expect(restGETMock).toHaveBeenCalledWith(
`/api/${BFF_API_VERSION}/namespaces/`,
`/namespaces/`,
`/api/${BFF_API_VERSION}/namespaces`,
`/namespaces`,
{},
APIOptionsMock,
);
Expand Down
2 changes: 1 addition & 1 deletion workspaces/frontend/src/shared/api/notebookService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { handleRestFailures } from '~/shared/api/errorUtils';
export const getNamespaces =
(hostPath: string) =>
(opts: APIOptions): Promise<NamespacesList> =>
handleRestFailures(restGET(hostPath, `/namespaces/`, {}, opts)).then((response) => {
handleRestFailures(restGET(hostPath, `/namespaces`, {}, opts)).then((response) => {
if (isNotebookResponse<NamespacesList>(response)) {
return response.data;
}
Expand Down
135 changes: 135 additions & 0 deletions workspaces/frontend/src/shared/components/NamespaceSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import React, { FC, useMemo, useState, useEffect } from 'react';
import {
Dropdown,
DropdownItem,
MenuToggle,
DropdownList,
DropdownProps,
MenuSearch,
MenuSearchInput,
InputGroup,
InputGroupItem,
SearchInput,
Button,
ButtonVariant,
Divider,
} from '@patternfly/react-core';
import { SearchIcon } from '@patternfly/react-icons/dist/esm/icons/search-icon';
import { useNamespaceContext } from '~/app/context/NamespaceContextProvider';

const NamespaceSelector: FC = () => {
const { namespaces, selectedNamespace, setSelectedNamespace } = useNamespaceContext();
const [isNamespaceDropdownOpen, setIsNamespaceDropdownOpen] = useState<boolean>(false);
const [searchInputValue, setSearchInputValue] = useState<string>('');
const [filteredNamespaces, setFilteredNamespaces] = useState<string[]>(namespaces);

useEffect(() => {
setFilteredNamespaces(namespaces);
}, [namespaces]);

const onToggleClick = () => {
if (!isNamespaceDropdownOpen) {
onClearSearch();
}
setIsNamespaceDropdownOpen(!isNamespaceDropdownOpen);
};

const onSearchInputChange = (value: string) => {
setSearchInputValue(value);
};

const onSearchButtonClick = () => {
const filtered =
searchInputValue === ''
? namespaces
: namespaces.filter((ns) => ns.toLowerCase().includes(searchInputValue.toLowerCase()));
setFilteredNamespaces(filtered);
};

const onEnterPressed = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
onSearchButtonClick();
}
};

const onSelect: DropdownProps['onSelect'] = (_event, value) => {
setSelectedNamespace(value as string);
setIsNamespaceDropdownOpen(false);
};

const onClearSearch = (event?: React.MouseEvent | React.ChangeEvent | React.FormEvent) => {
// Prevent the event from bubbling up and triggering dropdown close
event?.stopPropagation();
setSearchInputValue('');
setFilteredNamespaces(namespaces);
};

const dropdownItems = useMemo(
() =>
filteredNamespaces.map((ns) => (
<DropdownItem
key={ns}
itemId={ns}
className="namespace-list-items"
data-testid={`dropdown-item-${ns}`}
>
{ns}
</DropdownItem>
)),
[filteredNamespaces],
);

return (
<Dropdown
onSelect={onSelect}
toggle={(toggleRef) => (
<MenuToggle
ref={toggleRef}
onClick={onToggleClick}
isExpanded={isNamespaceDropdownOpen}
className="namespace-select-toggle"
data-testid="namespace-toggle"
>
{selectedNamespace}
</MenuToggle>
)}
isOpen={isNamespaceDropdownOpen}
onOpenChange={(isOpen) => setIsNamespaceDropdownOpen(isOpen)}
onOpenChangeKeys={['Escape']}
isScrollable
data-testid="namespace-dropdown"
>
<MenuSearch>
<MenuSearchInput>
<InputGroup>
<InputGroupItem isFill>
<SearchInput
value={searchInputValue}
placeholder="Search Namespace"
onChange={(_event, value) => onSearchInputChange(value)}
onKeyDown={onEnterPressed}
onClear={(event) => onClearSearch(event)}
aria-labelledby="namespace-search-button"
data-testid="namespace-search-input"
/>
</InputGroupItem>
<InputGroupItem>
<Button
variant={ButtonVariant.control}
aria-label="Search namespace"
id="namespace-search-button"
onClick={onSearchButtonClick}
icon={<SearchIcon aria-hidden="true" />}
data-testid="namespace-search-button"
/>
</InputGroupItem>
</InputGroup>
</MenuSearchInput>
</MenuSearch>
<Divider />
<DropdownList>{dropdownItems}</DropdownList>
</Dropdown>
);
};

export default NamespaceSelector;

0 comments on commit 57fc8b1

Please sign in to comment.