diff --git a/workspaces/frontend/src/__mocks__/mockNamespaces.ts b/workspaces/frontend/src/__mocks__/mockNamespaces.ts index 66b1d61a..6b6761fb 100644 --- a/workspaces/frontend/src/__mocks__/mockNamespaces.ts +++ b/workspaces/frontend/src/__mocks__/mockNamespaces.ts @@ -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' }, +]; diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/NamespaceSelector.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/NamespaceSelector.cy.ts new file mode 100644 index 00000000..4a9cfdca --- /dev/null +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/NamespaceSelector.cy.ts @@ -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'); + } + }); + }); +}); diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts index 80404940..b784a2e2 100644 --- a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts @@ -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(); }); diff --git a/workspaces/frontend/src/app/App.tsx b/workspaces/frontend/src/app/App.tsx index 3912b445..dabd3b3e 100644 --- a/workspaces/frontend/src/app/App.tsx +++ b/workspaces/frontend/src/app/App.tsx @@ -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 = () => { Kubeflow Notebooks 2.0 + @@ -36,14 +39,16 @@ const App: React.FC = () => { return ( - } - > - - + + } + > + + + ); }; diff --git a/workspaces/frontend/src/app/NavSidebar.tsx b/workspaces/frontend/src/app/NavSidebar.tsx index bfa9ab1a..651c5d40 100644 --- a/workspaces/frontend/src/app/NavSidebar.tsx +++ b/workspaces/frontend/src/app/NavSidebar.tsx @@ -12,7 +12,9 @@ import { useNavData, isNavDataGroup, NavDataHref, NavDataGroup } from './AppRout const NavHref: React.FC<{ item: NavDataHref }> = ({ item }) => ( - {item.label} + + {item.label} + ); @@ -40,7 +42,6 @@ const NavGroup: React.FC<{ item: NavDataGroup }> = ({ item }) => { const NavSidebar: React.FC = () => { const navData = useNavData(); - return ( diff --git a/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx b/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx new file mode 100644 index 00000000..b91d5453 --- /dev/null +++ b/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx @@ -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(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 = ({ children }) => { + const [namespaces, setNamespaces] = useState([]); + const [selectedNamespace, setSelectedNamespace] = useState(''); + 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 ( + + {children} + + ); +}; diff --git a/workspaces/frontend/src/app/context/NotebookContext.tsx b/workspaces/frontend/src/app/context/NotebookContext.tsx index a9eecfd1..f187489f 100644 --- a/workspaces/frontend/src/app/context/NotebookContext.tsx +++ b/workspaces/frontend/src/app/context/NotebookContext.tsx @@ -14,7 +14,7 @@ export const NotebookContext = React.createContext({ }); export const NotebookContextProvider: React.FC = ({ children }) => { - const hostPath = `/api/${BFF_API_VERSION}/`; + const hostPath = `/api/${BFF_API_VERSION}`; const [apiState, refreshAPIState] = useNotebookAPIState(hostPath); diff --git a/workspaces/frontend/src/app/hooks/useMount.tsx b/workspaces/frontend/src/app/hooks/useMount.tsx new file mode 100644 index 00000000..283bfd1c --- /dev/null +++ b/workspaces/frontend/src/app/hooks/useMount.tsx @@ -0,0 +1,9 @@ +import { useEffect } from 'react'; + +const useMount = (callback: () => void): void => { + useEffect(() => { + callback(); + }, [callback]); +}; + +export default useMount; diff --git a/workspaces/frontend/src/app/types.ts b/workspaces/frontend/src/app/types.ts index fe4e8bd4..ca80e9cd 100644 --- a/workspaces/frontend/src/app/types.ts +++ b/workspaces/frontend/src/app/types.ts @@ -60,9 +60,7 @@ export type Namespace = { name: string; }; -export type NamespacesList = { - data: Namespace[]; -}; +export type NamespacesList = Namespace[]; export type GetNamespaces = (opts: APIOptions) => Promise; diff --git a/workspaces/frontend/src/shared/api/__tests__/notebookService.spec.ts b/workspaces/frontend/src/shared/api/__tests__/notebookService.spec.ts index 041f0d4f..50f12a9e 100644 --- a/workspaces/frontend/src/shared/api/__tests__/notebookService.spec.ts +++ b/workspaces/frontend/src/shared/api/__tests__/notebookService.spec.ts @@ -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, ); diff --git a/workspaces/frontend/src/shared/api/notebookService.ts b/workspaces/frontend/src/shared/api/notebookService.ts index e28a9945..5f38a5cc 100644 --- a/workspaces/frontend/src/shared/api/notebookService.ts +++ b/workspaces/frontend/src/shared/api/notebookService.ts @@ -6,7 +6,7 @@ import { handleRestFailures } from '~/shared/api/errorUtils'; export const getNamespaces = (hostPath: string) => (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/namespaces/`, {}, opts)).then((response) => { + handleRestFailures(restGET(hostPath, `/namespaces`, {}, opts)).then((response) => { if (isNotebookResponse(response)) { return response.data; } diff --git a/workspaces/frontend/src/shared/components/NamespaceSelector.tsx b/workspaces/frontend/src/shared/components/NamespaceSelector.tsx new file mode 100644 index 00000000..ee4ebd2d --- /dev/null +++ b/workspaces/frontend/src/shared/components/NamespaceSelector.tsx @@ -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(false); + const [searchInputValue, setSearchInputValue] = useState(''); + const [filteredNamespaces, setFilteredNamespaces] = useState(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) => ( + + {ns} + + )), + [filteredNamespaces], + ); + + return ( + ( + + {selectedNamespace} + + )} + isOpen={isNamespaceDropdownOpen} + onOpenChange={(isOpen) => setIsNamespaceDropdownOpen(isOpen)} + onOpenChangeKeys={['Escape']} + isScrollable + data-testid="namespace-dropdown" + > + + + + + onSearchInputChange(value)} + onKeyDown={onEnterPressed} + onClear={(event) => onClearSearch(event)} + aria-labelledby="namespace-search-button" + data-testid="namespace-search-input" + /> + + +