From c1111b86cdb027fd83f7315d20751c46abde73bd Mon Sep 17 00:00:00 2001 From: yelias Date: Wed, 27 Nov 2024 16:50:56 +0200 Subject: [PATCH 1/3] feat(ws): Notebooks 2.0 // Frontend // Namespace selector Signed-off-by: yelias --- .../cypress/tests/e2e/NamespaceSelector.cy.ts | 41 +++++++++++++ workspaces/frontend/src/app/App.tsx | 25 +++++--- workspaces/frontend/src/app/NavSidebar.tsx | 5 +- .../app/context/NamespaceContextProvider.tsx | 59 +++++++++++++++++++ .../frontend/src/app/hooks/useMount.tsx | 9 +++ .../shared/components/NamespaceSelector.tsx | 57 ++++++++++++++++++ 6 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/NamespaceSelector.cy.ts create mode 100644 workspaces/frontend/src/app/context/NamespaceContextProvider.tsx create mode 100644 workspaces/frontend/src/app/hooks/useMount.tsx create mode 100644 workspaces/frontend/src/shared/components/NamespaceSelector.tsx 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..769e6a57 --- /dev/null +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/NamespaceSelector.cy.ts @@ -0,0 +1,41 @@ +const namespaces = ['default', 'kubeflow', 'custom-namespace']; +const mockNamespaces = { + data: [{ name: 'default' }, { name: 'kubeflow' }, { name: 'custom-namespace' }], +}; + +describe('Namespace Selector Dropdown', () => { + beforeEach(() => { + // Mock the namespaces and selected namespace + cy.intercept('GET', '/api/v1/namespaces', { + body: mockNamespaces, + }); + cy.visit('/'); + }); + + it('should open the namespace dropdown and select a namespace', () => { + cy.get('[data-testid="namespace-toggle"]').click(); + cy.get('[data-testid="namespace-dropdown"]').should('be.visible'); + namespaces.forEach((ns) => { + cy.get(`[data-testid="dropdown-item-${ns}"]`).should('exist').and('contain', ns); + }); + + cy.get('[data-testid="dropdown-item-kubeflow"]').click(); + + // Assert the selected namespace is updated + cy.get('[data-testid="namespace-toggle"]').should('contain', 'kubeflow'); + }); + + it('should display the default namespace initially', () => { + cy.get('[data-testid="namespace-toggle"]').should('contain', 'default'); + }); + + it('should navigate to notebook settings and retain the namespace', () => { + cy.get('[data-testid="namespace-toggle"]').click(); + cy.get('[data-testid="dropdown-item-custom-namespace"]').click(); + cy.get('[data-testid="namespace-toggle"]').should('contain', 'custom-namespace'); + // Click on navigation button + cy.get('#Settings').click(); + cy.get('[data-testid="nav-link-/notebookSettings"]').click(); + cy.get('[data-testid="namespace-toggle"]').should('contain', 'custom-namespace'); + }); +}); diff --git a/workspaces/frontend/src/app/App.tsx b/workspaces/frontend/src/app/App.tsx index 3912b445..c934cf02 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 { NamespaceProvider } from './context/NamespaceContextProvider'; +import NamespaceSelector from '../shared/components/NamespaceSelector'; import AppRoutes from './AppRoutes'; import NavSidebar from './NavSidebar'; import { NotebookContextProvider } from './context/NotebookContext'; @@ -19,7 +21,11 @@ const App: React.FC = () => { const masthead = ( - + @@ -29,6 +35,7 @@ const App: React.FC = () => { Kubeflow Notebooks 2.0 + @@ -36,14 +43,16 @@ const App: React.FC = () => { return ( + } - > - - + mainContainerId="primary-app-container" + masthead={masthead} + isManagedSidebar + sidebar={} + > + + + ); }; 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..201e6d44 --- /dev/null +++ b/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx @@ -0,0 +1,59 @@ +import React, { + useState, + useContext, + ReactNode, + useMemo, + useCallback, +} from 'react'; +import useMount from '../hooks/useMount'; + +interface NamespaceContextState { + namespaces: string[]; + selectedNamespace: string; + setSelectedNamespace: (namespace: string) => void; +} + +const NamespaceContext = React.createContext( + undefined +); + +export const useNamespaceContext = () => { + const context = useContext(NamespaceContext); + if (!context) { + throw new Error( + "useNamespaceContext must be used within a NamespaceProvider" + ); + } + return context; +}; + +interface NamespaceProviderProps { + children: ReactNode; +} + +export const NamespaceProvider: React.FC = ({ + children, +}) => { + const [namespaces, setNamespaces] = useState([]); + const [selectedNamespace, setSelectedNamespace] = useState(""); + + // Todo: Need to replace with actual API call + const fetchNamespaces = useCallback(async () => { + const mockNamespaces = { + data: [{ name: 'default' }, { name: 'kubeflow' }, { name: 'custom-namespace' }], + }; + const namespaceNames = mockNamespaces.data.map((ns) => ns.name); + setNamespaces(namespaceNames); + setSelectedNamespace(namespaceNames.length > 0 ? namespaceNames[0] : ""); + }, []); + useMount(fetchNamespaces); + const namespacesContextValues = useMemo( + () => ({ namespaces, selectedNamespace, setSelectedNamespace }), + [namespaces, selectedNamespace] + ); + return ( + + {children} + + ); +}; diff --git a/workspaces/frontend/src/app/hooks/useMount.tsx b/workspaces/frontend/src/app/hooks/useMount.tsx new file mode 100644 index 00000000..1760bc0f --- /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(); + }, []); +} + +export default useMount; \ No newline at end of file diff --git a/workspaces/frontend/src/shared/components/NamespaceSelector.tsx b/workspaces/frontend/src/shared/components/NamespaceSelector.tsx new file mode 100644 index 00000000..ebc54213 --- /dev/null +++ b/workspaces/frontend/src/shared/components/NamespaceSelector.tsx @@ -0,0 +1,57 @@ +import React, { FC, useMemo, useState } from 'react'; +import { + Dropdown, + DropdownItem, + MenuToggle, + DropdownList, + DropdownProps, +} from '@patternfly/react-core'; +import { useNamespaceContext } from '../../app/context/NamespaceContextProvider'; + +const NamespaceSelector: FC = () => { + const { namespaces, selectedNamespace, setSelectedNamespace } = useNamespaceContext(); + const [isOpen, setIsOpen] = useState(false); + + const onSelect: DropdownProps['onSelect'] = (_event, value) => { + setSelectedNamespace(value as string); + setIsOpen(false); + }; + + const dropdownItems = useMemo( + () => + namespaces.map((ns) => ( + + {ns} + + )), + [namespaces], + ); + + return ( + ( + setIsOpen(!isOpen)} + isExpanded={isOpen} + className="namespace-select-toggle" + data-testid="namespace-toggle" + > + {selectedNamespace} + + )} + isOpen={isOpen} + data-testid="namespace-dropdown" + > + {dropdownItems} + + ); +}; + +export default NamespaceSelector; From ca76781f3b11450ebc9cbeee68a1ee020dd50d22 Mon Sep 17 00:00:00 2001 From: yelias Date: Sun, 22 Dec 2024 13:28:47 +0200 Subject: [PATCH 2/3] add MenuSearch Signed-off-by: yelias --- workspaces/frontend/src/app/App.tsx | 8 +- .../app/context/NamespaceContextProvider.tsx | 30 ++----- .../frontend/src/app/hooks/useMount.tsx | 14 ++-- .../shared/components/NamespaceSelector.tsx | 83 +++++++++++++++++-- 4 files changed, 96 insertions(+), 39 deletions(-) diff --git a/workspaces/frontend/src/app/App.tsx b/workspaces/frontend/src/app/App.tsx index c934cf02..be71d9a4 100644 --- a/workspaces/frontend/src/app/App.tsx +++ b/workspaces/frontend/src/app/App.tsx @@ -11,8 +11,8 @@ import { Title, } from '@patternfly/react-core'; import { BarsIcon } from '@patternfly/react-icons'; +import NamespaceSelector from '~/shared/components/NamespaceSelector'; import { NamespaceProvider } from './context/NamespaceContextProvider'; -import NamespaceSelector from '../shared/components/NamespaceSelector'; import AppRoutes from './AppRoutes'; import NavSidebar from './NavSidebar'; import { NotebookContextProvider } from './context/NotebookContext'; @@ -21,11 +21,7 @@ const App: React.FC = () => { const masthead = ( - + diff --git a/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx b/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx index 201e6d44..16d6ba14 100644 --- a/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx +++ b/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx @@ -1,11 +1,5 @@ -import React, { - useState, - useContext, - ReactNode, - useMemo, - useCallback, -} from 'react'; -import useMount from '../hooks/useMount'; +import React, { useState, useContext, ReactNode, useMemo, useCallback } from 'react'; +import useMount from '~/app/hooks/useMount'; interface NamespaceContextState { namespaces: string[]; @@ -13,16 +7,12 @@ interface NamespaceContextState { setSelectedNamespace: (namespace: string) => void; } -const NamespaceContext = React.createContext( - undefined -); +const NamespaceContext = React.createContext(undefined); -export const useNamespaceContext = () => { +export const useNamespaceContext = (): NamespaceContextState => { const context = useContext(NamespaceContext); if (!context) { - throw new Error( - "useNamespaceContext must be used within a NamespaceProvider" - ); + throw new Error('useNamespaceContext must be used within a NamespaceProvider'); } return context; }; @@ -31,11 +21,9 @@ interface NamespaceProviderProps { children: ReactNode; } -export const NamespaceProvider: React.FC = ({ - children, -}) => { +export const NamespaceProvider: React.FC = ({ children }) => { const [namespaces, setNamespaces] = useState([]); - const [selectedNamespace, setSelectedNamespace] = useState(""); + const [selectedNamespace, setSelectedNamespace] = useState(''); // Todo: Need to replace with actual API call const fetchNamespaces = useCallback(async () => { @@ -44,12 +32,12 @@ export const NamespaceProvider: React.FC = ({ }; const namespaceNames = mockNamespaces.data.map((ns) => ns.name); setNamespaces(namespaceNames); - setSelectedNamespace(namespaceNames.length > 0 ? namespaceNames[0] : ""); + setSelectedNamespace(namespaceNames.length > 0 ? namespaceNames[0] : ''); }, []); useMount(fetchNamespaces); const namespacesContextValues = useMemo( () => ({ namespaces, selectedNamespace, setSelectedNamespace }), - [namespaces, selectedNamespace] + [namespaces, selectedNamespace], ); return ( diff --git a/workspaces/frontend/src/app/hooks/useMount.tsx b/workspaces/frontend/src/app/hooks/useMount.tsx index 1760bc0f..283bfd1c 100644 --- a/workspaces/frontend/src/app/hooks/useMount.tsx +++ b/workspaces/frontend/src/app/hooks/useMount.tsx @@ -1,9 +1,9 @@ -import { useEffect } from "react" +import { useEffect } from 'react'; -const useMount = (callback:()=>void): void => { - useEffect(() => { - callback(); - }, []); -} +const useMount = (callback: () => void): void => { + useEffect(() => { + callback(); + }, [callback]); +}; -export default useMount; \ No newline at end of file +export default useMount; diff --git a/workspaces/frontend/src/shared/components/NamespaceSelector.tsx b/workspaces/frontend/src/shared/components/NamespaceSelector.tsx index ebc54213..1c510aa0 100644 --- a/workspaces/frontend/src/shared/components/NamespaceSelector.tsx +++ b/workspaces/frontend/src/shared/components/NamespaceSelector.tsx @@ -1,25 +1,70 @@ -import React, { FC, useMemo, useState } from 'react'; +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 { useNamespaceContext } from '../../app/context/NamespaceContextProvider'; +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 [isOpen, setIsOpen] = useState(false); + const [searchInputValue, setSearchInputValue] = useState(''); + const [filteredNamespaces, setFilteredNamespaces] = useState(namespaces); + + useEffect(() => { + setFilteredNamespaces(namespaces); + }, [namespaces]); + + const onToggleClick = () => { + if (!isOpen) { + onClearSearch(); + } + setIsOpen(!isOpen); + }; + + 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); setIsOpen(false); }; + const onClearSearch = () => { + setSearchInputValue(''); + setFilteredNamespaces(namespaces); + }; + const dropdownItems = useMemo( () => - namespaces.map((ns) => ( + filteredNamespaces.map((ns) => ( { {ns} )), - [namespaces], + [filteredNamespaces], ); return ( @@ -38,7 +83,7 @@ const NamespaceSelector: FC = () => { toggle={(toggleRef) => ( setIsOpen(!isOpen)} + onClick={onToggleClick} isExpanded={isOpen} className="namespace-select-toggle" data-testid="namespace-toggle" @@ -47,8 +92,36 @@ const NamespaceSelector: FC = () => { )} isOpen={isOpen} + isScrollable data-testid="namespace-dropdown" > + + + + + onSearchInputChange(value)} + onKeyDown={onEnterPressed} + onClear={onClearSearch} + // resetButtonLabel="Clear search" + aria-labelledby="namespace-search-button" + /> + + +