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:
parent
0f363c04e7
commit
57fc8b17e5
|
|
@ -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' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
const useMount = (callback: () => void): void => {
|
||||
useEffect(() => {
|
||||
callback();
|
||||
}, [callback]);
|
||||
};
|
||||
|
||||
export default useMount;
|
||||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue