Skip to content

Commit

Permalink
Added favorites navigation menu
Browse files Browse the repository at this point in the history
  • Loading branch information
lokanandaprabhu committed Feb 13, 2025
1 parent f77b81f commit b3c5f17
Show file tree
Hide file tree
Showing 12 changed files with 409 additions and 25 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as React from 'react';
import { NavItem } from '@patternfly/react-core';
import { NavLink } from 'react-router-dom';
import { ResourceNSNavItem } from '@console/dynamic-plugin-sdk';

export const FavoriteNavItemResource: React.FC<FavoriteNavItemResourceProps> = ({
className,
dataAttributes,
isActive,
to,
...navLinkProps
}) => {
return (
<NavItem className={className} isActive={isActive}>
<NavLink {...navLinkProps} {...dataAttributes} to={to} />
</NavItem>
);
};

export type FavoriteNavItemResourceProps = {
to: string;
dataAttributes?: ResourceNSNavItem['properties']['dataAttributes'];
isActive: boolean;
className: string;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
.oc-favorite-resource.pf-v6-c-nav__item {
.pf-v6-c-nav__link {
display: flex;
align-items: center;
justify-content: space-between;
flex-grow: 1;
overflow: hidden;
position: relative;
text-overflow: ellipsis;
white-space: nowrap;
padding-top: 0;
padding-bottom: 0;
padding-right: 0;
&:hover {
--pf-v6-c-nav__section-title--PaddingRight: 30px;
.oc-favorite-delete-button {
.pf-v6-c-button__text {
opacity: 1;
}
}
}
}
}

.oc-favorite-delete-button {
background-color: transparent !important;
margin-left: auto;
margin-right: var(--pf-t--global--spacer--lg);
}

.oc-favorite-menu {
padding-top: 0 !important;
padding-bottom: 0 !important;
}

.oc-no-favorites-message {
padding-left: var(--pf-t--global--spacer--md);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as React from 'react';
import { Nav, NavExpandable, NavList, Button } from '@patternfly/react-core';
import { StarIcon } from '@patternfly/react-icons';
import * as classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { FAVORITES_CONFIG_MAP_KEY, FavoritesType } from '@console/internal/components/utils';
import { FAVORITES_LOCAL_STORAGE_KEY, useUserSettingsCompatibility } from '@console/shared';
import { FavoriteNavItemResource } from './FavoriteNavItemResource';

import './FavoriteNavItemResources.scss';

export const FavoriteNavItemResources: React.FC = () => {
const { t } = useTranslation();
const [activeGroup, setActiveGroup] = React.useState('');
const [activeItem, setActiveItem] = React.useState('');
const currentUrlPath = window.location.pathname;

const onSelect = (
_event: React.FormEvent<HTMLInputElement>,
result: { itemId: number | string; groupId: number | string | null },
) => {
setActiveGroup(result.groupId as string);
setActiveItem(result.itemId as string);
};

const [favorites, setFavorites, loaded] = useUserSettingsCompatibility<FavoritesType>(
FAVORITES_CONFIG_MAP_KEY,
FAVORITES_LOCAL_STORAGE_KEY,
null,
true,
);

React.useEffect(() => {
if (loaded && favorites) {
const currentFavorite = favorites.find((favorite) => favorite.url === currentUrlPath);
if (currentFavorite) {
setActiveGroup('favorites-group');
setActiveItem(`favorites-item-${currentFavorite.url}`);
}
}
}, [loaded, favorites, currentUrlPath]);

const navList = React.useMemo(() => {
const handleUnfavorite = (favoriteUrl: string) => {
const updatedFavorites = favorites?.filter((favorite) => favorite.url !== favoriteUrl);
setFavorites(updatedFavorites);
if (activeItem === `favorites-item-${favoriteUrl}`) {
setActiveItem('');
}
};
if (!loaded) return null;
if (!favorites || favorites.length === 0) {
return <div className="oc-no-favorites-message">{t('public~No favorites added')}</div>;
}

return favorites.map((favorite) => (
<FavoriteNavItemResource
key={favorite.url}
dataAttributes={{
'data-test': 'favorite-resource-item',
}}
className={classNames('oc-favorite-resource')}
to={favorite.url}
isActive={activeItem === `favorites-item-${favorite.url}`}
>
{favorite.name}
<Button
variant="plain"
aria-label={`Unfavorite ${favorite.name}`}
onClick={(e) => {
e.preventDefault();
handleUnfavorite(favorite.url);
}}
className="oc-favorite-delete-button"
icon={<StarIcon color="gold" />}
/>
</FavoriteNavItemResource>
));
}, [favorites, activeItem, loaded, t, setFavorites]);

return (
<Nav onSelect={onSelect} aria-label="favorite-resources" className="oc-favorite-menu">
<NavList>
<NavExpandable
title={t('public~Favorites')}
groupId="favorites-group"
isActive={activeGroup === 'favorites-group'}
isExpanded={activeGroup === 'favorites-group'}
>
{navList}
</NavExpandable>
</NavList>
</Nav>
);
};
18 changes: 13 additions & 5 deletions frontend/packages/console-app/src/components/nav/PluginNavItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@ import {
isHrefNavItem,
isResourceNSNavItem,
isResourceNavItem,
useActivePerspective,
} from '@console/dynamic-plugin-sdk';
import { LoadedExtension } from '@console/dynamic-plugin-sdk/src/types';
import { FavoriteNavItemResources } from './FavoriteNavItemResources';
import { NavItemHref } from './NavItemHref';
import { NavItemResource } from './NavItemResource';
import { NavSection } from './NavSection';

export const PluginNavItem: React.FC<PluginNavItemProps> = ({ extension }) => {
const [activePerspective] = useActivePerspective();
if (isNavSection(extension)) {
return (
<NavSection
id={extension.properties.id}
name={extension.properties.name}
dataAttributes={extension.properties.dataAttributes}
/>
<>
<NavSection
id={extension.properties.id}
name={extension.properties.name}
dataAttributes={extension.properties.dataAttributes}
/>
{extension.properties.id === 'home' && activePerspective === 'admin' && (
<FavoriteNavItemResources />
)}
</>
);
}
if (isSeparator(extension)) {
Expand Down
1 change: 1 addition & 0 deletions frontend/packages/console-shared/src/constants/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const LOG_WRAP_LINES_USERSETTINGS_KEY = `${USERSETTINGS_PREFIX}.log.wrapL
export const SHOW_YAML_EDITOR_TOOLTIPS_USER_SETTING_KEY = `${USERSETTINGS_PREFIX}.showYAMLEditorTooltips`;
export const SHOW_YAML_EDITOR_TOOLTIPS_LOCAL_STORAGE_KEY = `${STORAGE_PREFIX}/showYAMLEditorTooltips`;
export const SHOW_FULL_LOG_USERSETTINGS_KEY = `${USERSETTINGS_PREFIX}.show.full.log`;
export const FAVORITES_LOCAL_STORAGE_KEY = `${STORAGE_PREFIX}/favorites`;
// Bootstrap user for OpenShift 4.0 clusters (kube:admin)
export const KUBE_ADMIN_USERNAMES = ['kube:admin'];

Expand Down
11 changes: 11 additions & 0 deletions frontend/public/components/utils/_favorite-button.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.co-actions-icon {
background: none;
background-color: transparent !important;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font-size: x-large;
}
182 changes: 182 additions & 0 deletions frontend/public/components/utils/favorite-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Form,
FormGroup,
FormHelperText,
HelperText,
HelperTextItem,
TextInput,
Tooltip,
} from '@patternfly/react-core';
import { ModalVariant } from '@patternfly/react-core/deprecated';
import {
FAVORITES_LOCAL_STORAGE_KEY,
Modal,
RedExclamationCircleIcon,
useUserSettingsCompatibility,
} from '@console/shared';
import { connectToModel } from '../../kinds';
import { StarIcon } from '@patternfly/react-icons';

export type FavoritesType = Array<{
name: string;
url: string;
}>;

export const FAVORITES_CONFIG_MAP_KEY = 'console.favorites';
const MAX_FAVORITE_COUNT = 10;

export const FavoriteButton = connectToModel(() => {
const { t } = useTranslation();
const ref = React.useRef();
const [isStarred, setIsStarred] = React.useState(false);
const [isModalOpen, setIsModalOpen] = React.useState(false);
const [name, setName] = React.useState<string>('');
const [error, setError] = React.useState<string | null>(null);
const [favorites, setFavorites, loaded] = useUserSettingsCompatibility<FavoritesType>(
FAVORITES_CONFIG_MAP_KEY,
FAVORITES_LOCAL_STORAGE_KEY,
null,
true,
);

const currentUrlPath = window.location.pathname;

React.useEffect(() => {
if (loaded) {
const isCurrentlyFavorited = favorites?.some((favorite) => favorite.url === currentUrlPath);
setIsStarred(isCurrentlyFavorited);
}
}, [loaded, favorites, currentUrlPath]);

const handleStarClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
const isCurrentlyFavorited = favorites?.some((favorite) => favorite.url === currentUrlPath);
if (isCurrentlyFavorited) {
const updatedFavorites = favorites?.filter((favorite) => favorite.url !== currentUrlPath);
setFavorites(updatedFavorites);
setIsStarred(false);
} else {
setIsModalOpen(true);
}
};

const handleModalClose = () => {
setError('');
setName('');
setIsModalOpen(false);
};

const handleConfirmStar = () => {
if (!name.trim()) {
setError(t('public~Name is required.'));
return;
}
const nameExists = favorites?.some((favorite) => favorite.name === name.trim());
if (nameExists) {
setError(
t(
'public~The name {{favoriteName}} already exists in your favorites. Choose a unique name to save to your favorites.',
{ favoriteName: name },
),
);
return;
}

const newFavorite = { name: name.trim(), url: currentUrlPath };
const updatedFavorites = [...(favorites || []), newFavorite];
setFavorites(updatedFavorites);
setIsStarred((prev) => !prev);
setError('');
setName('');
setIsModalOpen(false);
};

const handleNameChange = (value: string) => {
const alphanumericRegex = /^[a-zA-Z0-9- ]*$/;
if (!alphanumericRegex.test(value)) {
setError(t('Name can only contain letters, numbers, spaces, and hyphens.'));
} else {
setError(null);
setName(value);
}
};

const tooltipText = t(
'public~Maximum number of favorites ({{maxCount}}) reached. To add another favorite, remove an existing page from your favorites.',
{ maxCount: MAX_FAVORITE_COUNT },
);

return (
<>
{favorites?.length >= MAX_FAVORITE_COUNT && !isStarred ? (
<Tooltip content={tooltipText} triggerRef={ref} position="left">
<Button
ref={ref}
icon={<StarIcon color={isStarred ? 'gold' : 'gray'} />}
className="co-actions-icon"
variant="link"
aria-label="save-favorite"
aria-pressed={isStarred}
onClick={handleStarClick}
isDisabled={true}
/>
</Tooltip>
) : (
<Button
icon={<StarIcon color={isStarred ? 'gold' : 'gray'} />}
className="co-actions-icon"
variant="link"
aria-label="save-favorite"
aria-pressed={isStarred}
onClick={handleStarClick}
/>
)}

{isModalOpen && (
<Modal
title={t('public~Add to favorites')}
isOpen={isModalOpen}
onClose={handleModalClose}
actions={[
<Button key="confirm" variant="primary" onClick={handleConfirmStar}>
{t('public~Save')}
</Button>,
<Button key="cancel" variant="link" onClick={handleModalClose}>
{t('public~Cancel')}
</Button>,
]}
variant={ModalVariant.small}
>
<Form>
<FormGroup label={t('public~Name')} isRequired fieldId="input-name">
<TextInput
id="input-name"
data-test="input-name"
name="name"
type="text"
onChange={(e, v) => handleNameChange(v)}
value={name || ''}
autoFocus
required
maxLength={20}
/>
{error && (
<FormHelperText>
<HelperText>
<HelperTextItem variant="error" icon={<RedExclamationCircleIcon />}>
{error}
</HelperTextItem>
</HelperText>
</FormHelperText>
)}
</FormGroup>
</Form>
</Modal>
)}
</>
);
});
Loading

0 comments on commit b3c5f17

Please sign in to comment.