-
Notifications
You must be signed in to change notification settings - Fork 33
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(ws): add namespace dropdown to UI (#154)
* feat(ws): Notebooks 2.0 // Frontend // Namespace selector Signed-off-by: yelias <[email protected]> * add MenuSearch Signed-off-by: yelias <[email protected]> * feat(ws): Notebooks 2.0 // Frontend // Namespace selector Signed-off-by: yelias <[email protected]> --------- Signed-off-by: yelias <[email protected]> Co-authored-by: yelias <[email protected]>
- Loading branch information
Showing
12 changed files
with
295 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' }, | ||
]; |
57 changes: 57 additions & 0 deletions
57
workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/NamespaceSelector.cy.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'); | ||
} | ||
}); | ||
}); | ||
}); |
11 changes: 11 additions & 0 deletions
11
workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
56 changes: 56 additions & 0 deletions
56
workspaces/frontend/src/app/context/NamespaceContextProvider.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import { useEffect } from 'react'; | ||
|
||
const useMount = (callback: () => void): void => { | ||
useEffect(() => { | ||
callback(); | ||
}, [callback]); | ||
}; | ||
|
||
export default useMount; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
135 changes: 135 additions & 0 deletions
135
workspaces/frontend/src/shared/components/NamespaceSelector.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |