Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ws): add namespace dropdown to UI #154

Merged
merged 3 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions workspaces/frontend/src/__mocks__/mockNamespaces.ts
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' },
];
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');
}
});
});
});
Original file line number Diff line number Diff line change
@@ -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();
});
Expand Down
21 changes: 13 additions & 8 deletions workspaces/frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,21 +31,24 @@ const App: React.FC = () => {
<Title headingLevel="h2" size="3xl">
Kubeflow Notebooks 2.0
</Title>
<NamespaceSelector />
</Flex>
</MastheadContent>
</Masthead>
);

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>
);
};
Expand Down
5 changes: 3 additions & 2 deletions workspaces/frontend/src/app/NavSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);

Expand Down Expand Up @@ -40,7 +42,6 @@ const NavGroup: React.FC<{ item: NavDataGroup }> = ({ item }) => {

const NavSidebar: React.FC = () => {
const navData = useNavData();

return (
<PageSidebar>
<PageSidebarBody>
Expand Down
56 changes: 56 additions & 0 deletions workspaces/frontend/src/app/context/NamespaceContextProvider.tsx
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>
);
};
2 changes: 1 addition & 1 deletion workspaces/frontend/src/app/context/NotebookContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
9 changes: 9 additions & 0 deletions workspaces/frontend/src/app/hooks/useMount.tsx
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;
4 changes: 1 addition & 3 deletions workspaces/frontend/src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
2 changes: 1 addition & 1 deletion workspaces/frontend/src/shared/api/notebookService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
135 changes: 135 additions & 0 deletions workspaces/frontend/src/shared/components/NamespaceSelector.tsx
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
YosiElias marked this conversation as resolved.
Show resolved Hide resolved
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