Skip to content

Commit

Permalink
update user preference namespace dropdown design
Browse files Browse the repository at this point in the history
  • Loading branch information
nemesis09 committed Nov 24, 2021
1 parent d482d27 commit 1e81415
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 96 deletions.
5 changes: 0 additions & 5 deletions frontend/packages/console-app/locales/en/console-app.json
Expand Up @@ -305,11 +305,6 @@
"guided tour {{step, number}}": "guided tour {{step, number}}",
"Use the default browser language setting.": "Use the default browser language setting.",
"Select a language": "Select a language",
"Search project": "Search project",
"Search namespace": "Search namespace",
"No projects found": "No projects found",
"No namespaces found": "No namespaces found",
"Create Namespace": "Create Namespace",
"Projects failed to load. Check your connection and reload the page.": "Projects failed to load. Check your connection and reload the page.",
"Namespaces failed to load. Check your connection and reload the page.": "Namespaces failed to load. Check your connection and reload the page.",
"Unable to load": "Unable to load",
Expand Down
@@ -0,0 +1,16 @@
.co-user-preference__namespace-menu-toggle {
padding: var(--pf-global--spacer--form-element) var(--pf-global--spacer--sm)
var(--pf-global--spacer--form-element) var(--pf-global--spacer--sm) !important;
&::before,
&::after {
border-width: var(--pf-global--BorderWidth--sm) !important;
}
.pf-c-menu-toggle__text {
font-size: var(--pf-global--FontSize--md);
}
}

.co-user-preference__namespace-menu__last-viewed {
padding-top: 0 !important;
margin-bottom: 0 !important;
}
@@ -1,76 +1,84 @@
import * as React from 'react';
import {
Button,
Skeleton,
EmptyState,
EmptyStateIcon,
Title,
EmptyStateBody,
SelectOption,
Select,
SelectVariant,
Divider,
Menu,
MenuItem,
MenuContent,
MenuList,
} from '@patternfly/react-core';
import { ExclamationCircleIcon } from '@patternfly/react-icons';
import fuzzysearch from 'fuzzysearch';
import { useTranslation } from 'react-i18next';
import { createProjectModal } from '@console/internal/components/modals';
import { useProjectOrNamespaceModel } from '@console/internal/components/utils';
import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook';
import { ProjectModel } from '@console/internal/models';
import { K8sKind, K8sResourceKind } from '@console/internal/module/k8s';
import {
Filter,
Footer,
NamespaceGroup,
NoResults,
} from '@console/shared/src/components/namespace/NamespaceDropdown';
import NamespaceMenuToggle from '@console/shared/src/components/namespace/NamespaceMenuToggle';
import { usePreferredNamespace } from './usePreferredNamespace';
import './NamespaceDropdown.scss';

type OptionItem = {
title: string;
key: string;
};

const NamespaceDropdown: React.FC = () => {
// resources and calls to hooks
const { t } = useTranslation();
const [model, canCreate] = useProjectOrNamespaceModel() as [K8sKind, boolean];
const isProject: boolean = model?.kind === ProjectModel.kind;
const [allNamespaces, allNamespacesLoaded, allNamespacesLoadError] = useK8sWatchResource<
K8sResourceKind[]
>({
const [options, optionsLoaded, optionsLoadError] = useK8sWatchResource<K8sResourceKind[]>({
kind: model?.kind,
isList: true,
optional: true,
});
const [
preferredNamespace,
setPreferredNamespace,
preferredNamespaceLoaded,
] = usePreferredNamespace();

const [dropdownOpen, setDropdownOpen] = React.useState(false);
const [filterText, setFilterText] = React.useState('');
const menuRef = React.useRef(null);
const filterRef = React.useRef(null);

const loaded: boolean = model && preferredNamespaceLoaded && allNamespacesLoaded;
const optionItems: OptionItem[] = React.useMemo(() => {
if (!optionsLoaded) {
return [];
}
const items: OptionItem[] = options.map((item) => {
const { name } = item.metadata;
return { title: name, key: name };
});
items.sort((a, b) => a.title.localeCompare(b.title));
return items;
}, [options, optionsLoaded]);

const lastViewedLabel: string = t('console-app~Last viewed');
const namespaceSearchLabel = isProject
? t('console-app~Search project')
: t('console-app~Search namespace');
const loaded: boolean = model && preferredNamespaceLoaded && optionsLoaded;

const noResultsFoundText: string = isProject
? t('console-app~No projects found')
: t('console-app~No namespaces found');
const filteredOptions: OptionItem[] = React.useMemo(() => {
const lowerCaseFilterText = filterText.toLowerCase();
return optionItems.filter((option: OptionItem) =>
fuzzysearch(lowerCaseFilterText, option.title.toLowerCase()),
);
}, [optionItems, filterText]);

const selectOptions: JSX.Element[] = React.useMemo(() => {
if (!allNamespacesLoaded) {
return [];
}
const lastNamespaceOption = <SelectOption key={'lastViewed'} value={lastViewedLabel} />;
const dividerOption = <Divider component="li" key={'divider'} />;
const allNamespaceOptions = allNamespaces
.sort((currNamespace, nextNamespace) => {
const {
metadata: { name: currNamespaceName },
} = currNamespace;
const {
metadata: { name: nextNamespaceName },
} = nextNamespace;
if (currNamespaceName === nextNamespaceName) {
return 0;
}
return currNamespaceName > nextNamespaceName ? 1 : -1;
})
.map(({ metadata: { name } }) => <SelectOption key={name} value={name} />);
return [lastNamespaceOption, dividerOption, ...allNamespaceOptions];
}, [allNamespaces, allNamespacesLoaded, lastViewedLabel]);
const lastViewedOption: OptionItem = {
title: t('console-app~Last viewed'),
key: '##lastViewed##',
};

const onCreateNamespace = React.useCallback(
() =>
Expand All @@ -82,68 +90,99 @@ const NamespaceDropdown: React.FC = () => {
}),
[setPreferredNamespace],
);
const createNamespaceButton = canCreate ? (
<Button
type="button"
variant="secondary"
isInline
onClick={onCreateNamespace}
data-test="footer create-namespace-button"
>
{isProject ? t('console-app~Create Project') : t('console-app~Create Namespace')}
</Button>
) : null;

const emptyStateLoadErrorDescription: string = isProject
const loadErrorDescription: string = isProject
? t('console-app~Projects failed to load. Check your connection and reload the page.')
: t('console-app~Namespaces failed to load. Check your connection and reload the page.');
const loadErrorState: JSX.Element = allNamespacesLoadError ? (
const loadErrorState: JSX.Element = optionsLoadError ? (
<EmptyState data-test={'dropdown console.preferredNamespace error'}>
<EmptyStateIcon icon={ExclamationCircleIcon} />
<Title size="md" headingLevel="h4">
{t('console-app~Unable to load')}
</Title>
<EmptyStateBody>{emptyStateLoadErrorDescription}</EmptyStateBody>
<EmptyStateBody>{loadErrorDescription}</EmptyStateBody>
</EmptyState>
) : null;

// utils and callbacks
const namespaceFilter = (_, value) => {
if (!value) {
return selectOptions;
}
const filterRegex = new RegExp(value, 'i');
return selectOptions.filter((option) => filterRegex.test(option.props.value));
};
const getDropdownLabelForValue = (): string => preferredNamespace || lastViewedLabel;
const emptyState: JSX.Element =
!optionsLoadError && filteredOptions.length === 0 ? (
<NoResults
isProjects={isProject}
onClear={(event) => {
event.preventDefault();
event.stopPropagation();
setFilterText('');
filterRef.current?.focus();
}}
/>
) : null;

const getDropdownLabelForValue = (): string => preferredNamespace || lastViewedOption.title;
const getDropdownValueForLabel = (selectedLabel: string): string =>
selectedLabel === lastViewedLabel ? null : selectedLabel;
selectedLabel === lastViewedOption.key ? null : selectedLabel;
const onToggle = (isOpen: boolean) => setDropdownOpen(isOpen);
const onSelect = (_, selection) => {
const onSelect = (_, selection: string) => {
const selectedValue = getDropdownValueForLabel(selection);
selectedValue !== preferredNamespace && setPreferredNamespace(selectedValue);
setDropdownOpen(false);
};

const selected = getDropdownLabelForValue();

const lastNamespaceOption: JSX.Element = (
<MenuList className="co-user-preference__namespace-menu__last-viewed">
<Divider component="li" key={'divider'} />
<MenuItem
key={lastViewedOption.key}
itemId={lastViewedOption.key}
isSelected={selected === lastViewedOption.key}
data-test="dropdown-menu-item-lastViewed"
>
{lastViewedOption.title}
</MenuItem>
</MenuList>
);

const namespaceMenu: JSX.Element = (
<Menu
className="co-namespace-dropdown__menu"
ref={menuRef}
onSelect={onSelect}
activeItemId={selected}
data-test="dropdown menu console.preferredNamespace"
onActionClick={() => {}}
>
<MenuContent menuHeight="40vh" translate="no">
<Filter
filterRef={filterRef}
onFilterChange={setFilterText}
filterText={filterText}
isProject={isProject}
/>
{lastNamespaceOption}
{loadErrorState || emptyState}
<NamespaceGroup options={filteredOptions} selectedKey={selected} canFavorite={false} />
</MenuContent>
<Footer
canCreateNew={canCreate}
isProject={isProject}
onCreateNew={onCreateNamespace}
setOpen={setDropdownOpen}
/>
</Menu>
);

return loaded ? (
<Select
variant={SelectVariant.typeahead}
<NamespaceMenuToggle
disabled={false}
menu={namespaceMenu}
menuRef={menuRef}
isOpen={dropdownOpen}
selections={getDropdownLabelForValue()}
toggleId={'console.preferredNamespace'}
title={selected}
onToggle={onToggle}
onSelect={onSelect}
onFilter={namespaceFilter}
typeAheadAriaLabel={namespaceSearchLabel}
placeholderText={namespaceSearchLabel}
noResultsFoundText={noResultsFoundText}
footer={createNamespaceButton}
customContent={loadErrorState}
data-test={'dropdown console.preferredNamespace'}
maxHeight={300}
>
{selectOptions}
</Select>
className="co-user-preference__namespace-menu-toggle"
/>
) : (
<Skeleton
height="30px"
Expand Down
@@ -1,9 +1,9 @@
import * as React from 'react';
import { Select } from '@patternfly/react-core';
import { shallow, ShallowWrapper } from 'enzyme';
import { useProjectOrNamespaceModel } from '@console/internal/components/utils';
import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook';
import { NamespaceModel } from '@console/internal/models';
import NamespaceMenuToggle from '@console/shared/src/components/namespace/NamespaceMenuToggle';
import NamespaceDropdown from '../NamespaceDropdown';
import { usePreferredNamespace } from '../usePreferredNamespace';
import { mockNamespaces } from './namespace.data';
Expand All @@ -20,6 +20,10 @@ jest.mock('../usePreferredNamespace', () => ({
usePreferredNamespace: jest.fn(),
}));

jest.mock('fuzzysearch', () => {
return { default: jest.fn() };
});

const mockProjectOrNamespaceModel = useProjectOrNamespaceModel as jest.Mock;
const mockK8sWatchResource = useK8sWatchResource as jest.Mock;
const mockUsePreferredNamespace = usePreferredNamespace as jest.Mock;
Expand All @@ -42,13 +46,13 @@ describe('NamespaceDropdown', () => {
).toBeTruthy();
});

it('should render select with preferred namespace if extensions have loaded and user preference for namespace is defined', () => {
it('should render menu with preferred namespace if extensions have loaded and user preference for namespace is defined', () => {
mockProjectOrNamespaceModel.mockReturnValue([NamespaceModel, true]);
mockK8sWatchResource.mockReturnValue([mockNamespaces, true, false]);
mockUsePreferredNamespace.mockReturnValue([preferredNamespace, jest.fn(), true]);
wrapper = shallow(<NamespaceDropdown />);
expect(wrapper.find('[data-test="dropdown console.preferredNamespace"]').exists()).toBeTruthy();
expect(wrapper.find(Select).props().selections).toEqual(preferredNamespace);
expect(wrapper.find(NamespaceMenuToggle).props().title).toEqual(preferredNamespace);
});

it('should render select with "Last viewed" if extensions have loaded but user preference for namespace is not defined', () => {
Expand All @@ -57,6 +61,6 @@ describe('NamespaceDropdown', () => {
mockUsePreferredNamespace.mockReturnValue([undefined, jest.fn(), true]);
wrapper = shallow(<NamespaceDropdown />);
expect(wrapper.find('[data-test="dropdown console.preferredNamespace"]').exists()).toBeTruthy();
expect(wrapper.find(Select).props().selections).toEqual('Last viewed');
expect(wrapper.find(NamespaceMenuToggle).props().title).toEqual('Last viewed');
});
});
Expand Up @@ -34,7 +34,7 @@ import { isSystemNamespace } from './filters';
import NamespaceMenuToggle from './NamespaceMenuToggle';
import './NamespaceDropdown.scss';

const NoResults: React.FC<{
export const NoResults: React.FC<{
isProjects: boolean;
onClear: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}> = ({ isProjects, onClear }) => {
Expand All @@ -61,7 +61,7 @@ const NoResults: React.FC<{

/* ****************************************** */

const Filter: React.FC<{
export const Filter: React.FC<{
filterRef: React.Ref<any>;
onFilterChange: (filterText: string) => void;
filterText: string;
Expand Down Expand Up @@ -128,12 +128,13 @@ const SystemSwitch: React.FC<{

/* ****************************************** */

const NamespaceGroup: React.FC<{
export const NamespaceGroup: React.FC<{
isFavorites?: boolean;
options: { key: string; title: string }[];
selectedKey: string;
favorites: { [key: string]: boolean }[];
}> = ({ isFavorites, options, selectedKey, favorites }) => {
favorites?: { [key: string]: boolean }[];
canFavorite?: boolean;
}> = ({ isFavorites, options, selectedKey, favorites, canFavorite = true }) => {
const { t } = useTranslation();
const label = isFavorites ? t('console-shared~Favorites') : t('console-shared~Projects');

Expand All @@ -152,7 +153,7 @@ const NamespaceGroup: React.FC<{
<MenuItem
key={option.key}
itemId={option.key}
isFavorited={!!favorites?.[option.key]}
isFavorited={canFavorite ? !!favorites?.[option.key] : undefined}
isSelected={selectedKey === option.key}
data-test="dropdown-menu-item-link"
>
Expand All @@ -171,7 +172,7 @@ const NamespaceGroup: React.FC<{
// The items in the footer are not accessible via the keyboard.
// This is being tracked in: https://github.com/patternfly/patternfly-react/issues/6031

const Footer: React.FC<{
export const Footer: React.FC<{
canCreateNew: boolean;
isProject?: boolean;
setOpen: (isOpen: boolean) => void;
Expand Down

0 comments on commit 1e81415

Please sign in to comment.