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"
+ />
+
+
+ }
+ data-testid="namespace-search-button"
+ />
+
+
+
+
+
+ {dropdownItems}
+
+ );
+};
+
+export default NamespaceSelector;