feat(ws): add namespace dropdown to UI (#154)

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

Signed-off-by: yelias <yossi.elias@nokia.com>

* add MenuSearch

Signed-off-by: yelias <yossi.elias@nokia.com>

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

Signed-off-by: yelias <yossi.elias@nokia.com>

---------

Signed-off-by: yelias <yossi.elias@nokia.com>
Co-authored-by: yelias <yossi.elias@nokia.com>
This commit is contained in:
YosiElias 2025-01-09 15:26:22 +02:00 committed by GitHub
parent 0f363c04e7
commit 57fc8b17e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 295 additions and 21 deletions

View File

@ -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' },
];

View File

@ -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');
}
});
});
});

View File

@ -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();
});

View File

@ -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';
@ -29,6 +31,7 @@ const App: React.FC = () => {
<Title headingLevel="h2" size="3xl">
Kubeflow Notebooks 2.0
</Title>
<NamespaceSelector />
</Flex>
</MastheadContent>
</Masthead>
@ -36,14 +39,16 @@ const App: React.FC = () => {
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>
);
};

View File

@ -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>
);
@ -40,7 +42,6 @@ const NavGroup: React.FC<{ item: NavDataGroup }> = ({ item }) => {
const NavSidebar: React.FC = () => {
const navData = useNavData();
return (
<PageSidebar>
<PageSidebarBody>

View File

@ -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>
);
};

View File

@ -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);

View File

@ -0,0 +1,9 @@
import { useEffect } from 'react';
const useMount = (callback: () => void): void => {
useEffect(() => {
callback();
}, [callback]);
};
export default useMount;

View File

@ -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>;

View File

@ -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,
);

View File

@ -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;
}

View File

@ -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;