Skip to content

Commit

Permalink
feat: Bookmark items in the UI (#2306)
Browse files Browse the repository at this point in the history
* feat: Bookmark items in the UI
  • Loading branch information
aleixhub authored Dec 12, 2024
1 parent 5e9bf76 commit d72efe0
Show file tree
Hide file tree
Showing 9 changed files with 157 additions and 39 deletions.
23 changes: 20 additions & 3 deletions catalog/ui/src/app/Catalog/Catalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import ThIcon from '@patternfly/react-icons/dist/js/icons/th-icon';
import useSWRImmutable from 'swr/immutable';
import { AsyncParser } from 'json2csv';
import { apiPaths, fetcher, fetcherItemsInAllPages } from '@app/api';
import { CatalogItem, CatalogItemIncidents } from '@app/types';
import { Bookmark, BookmarkList, CatalogItem, CatalogItemIncidents } from '@app/types';
import useSession from '@app/utils/useSession';
import SearchInputString from '@app/components/SearchInputString';
import {
Expand Down Expand Up @@ -65,6 +65,7 @@ import CatalogContent from './CatalogContent';
import IncidentsBanner from '@app/components/IncidentsBanner';
import useInterfaceConfig from '@app/utils/useInterfaceConfig';
import LoadingSection from '@app/components/LoadingSection';
import useSWR from 'swr';

import './catalog.css';

Expand Down Expand Up @@ -147,6 +148,9 @@ function filterCatalogItemByAccessControl(catalogItem: CatalogItem, userGroups:
function filterCatalogItemByCategory(catalogItem: CatalogItem, selectedCategory: string) {
return selectedCategory === getCategory(catalogItem);
}
function filterFavorites(catalogItem: CatalogItem, favList: Bookmark[] = []) {
return favList.some((f) => f.asset_uuid === catalogItem.metadata?.labels?.['gpte.redhat.com/asset-uuid']);
}

function filterCatalogItemByLabels(catalogItem: CatalogItem, labelFilter: { [attr: string]: string[] }): boolean {
for (const [attr, values] of Object.entries(labelFilter)) {
Expand Down Expand Up @@ -349,6 +353,7 @@ const Catalog: React.FC<{ userHasRequiredPropertiesToAccess: boolean }> = ({ use
}),
() => fetchCatalog(catalogNamespaceName ? [catalogNamespaceName] : catalogNamespaceNames)
);
const { data: assetsFavList } = useSWRImmutable<BookmarkList>(apiPaths.FAVORITES({}), fetcher);

const catalogItems = useMemo(
() => catalogItemsArr.filter((ci) => filterCatalogItemByAccessControl(ci, groups, isAdmin)),
Expand Down Expand Up @@ -415,7 +420,11 @@ const Catalog: React.FC<{ userHasRequiredPropertiesToAccess: boolean }> = ({ use
};
const catalogItemsFuse = new Fuse(catalogItemsCpy, options);
if (selectedCategory) {
catalogItemsFuse.remove((ci) => !filterCatalogItemByCategory(ci, selectedCategory));
if (selectedCategory === 'favorites' && assetsFavList?.bookmarks) {
catalogItemsFuse.remove((ci) => !filterFavorites(ci, assetsFavList?.bookmarks));
} else {
catalogItemsFuse.remove((ci) => !filterCatalogItemByCategory(ci, selectedCategory));
}
}
if (selectedLabels) {
catalogItemsFuse.remove((ci) => !filterCatalogItemByLabels(ci, selectedLabels));
Expand All @@ -424,7 +433,15 @@ const Catalog: React.FC<{ userHasRequiredPropertiesToAccess: boolean }> = ({ use
catalogItemsFuse.remove((ci) => !filterCatalogItemByAdminFilter(ci, selectedAdminFilter));
}
return [catalogItemsFuse, catalogItemsCpy];
}, [catalogItems, selectedCategory, selectedLabels, compareCatalogItems, selectedAdminFilter, activeIncidents]);
}, [
catalogItems,
selectedCategory,
selectedLabels,
compareCatalogItems,
selectedAdminFilter,
activeIncidents,
assetsFavList,
]);

const catalogItemsResult = useMemo(() => {
const items = searchString
Expand Down
24 changes: 21 additions & 3 deletions catalog/ui/src/app/Catalog/CatalogCategorySelector.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-nocheck
import React from 'react';
import { Tab, Tabs, TabTitleText, Tooltip } from '@patternfly/react-core';
import { CatalogItem } from '@app/types';
Expand All @@ -22,7 +23,7 @@ const CatalogCategorySelector: React.FC<{
selected: string;
}> = ({ catalogItems, onSelect, selected }) => {
const categories = Array.from(
new Set((catalogItems || []).map((ci) => getCategory(ci)).filter((category) => category !== null)),
new Set((catalogItems || []).map((ci) => getCategory(ci)).filter((category) => category !== null))
);
categories.sort((a, b) => {
const av = (a as string).toUpperCase();
Expand Down Expand Up @@ -51,11 +52,28 @@ const CatalogCategorySelector: React.FC<{
'2xl': 'insetNone',
}}
>
{/* @ts-ignore */}
<Tab key="all" eventKey="all" title={<TabTitleText>All Items</TabTitleText>} aria-controls=""></Tab>
<Tab
key="favorites"
eventKey="favorites"
title={
<TabTitleText>
Favorites{' '}
<Tooltip content="Items marked as favorite">
<InfoAltIcon
style={{
paddingTop: 'var(--pf-v5-global--spacer--xs)',
marginLeft: 'var(--pf-v5-global--spacer--sm)',
width: 'var(--pf-v5-global--icon--FontSize--sm)',
}}
/>
</Tooltip>
</TabTitleText>
}
aria-controls=""
></Tab>
{categories.map((category) => {
return (
/* @ts-ignore */
<Tab
key={category}
eventKey={category}
Expand Down
12 changes: 10 additions & 2 deletions catalog/ui/src/app/Catalog/CatalogItemCard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import React from 'react';
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom';
import { Badge, CardBody, CardHeader, Split, SplitItem, Title, Tooltip } from '@patternfly/react-core';
import { CatalogItem } from '@app/types';
import { BookmarkList, CatalogItem } from '@app/types';
import StatusPageIcons from '@app/components/StatusPageIcons';
import { displayName, renderContent, stripHtml } from '@app/util';
import StarRating from '@app/components/StarRating';
import { formatString, getDescription, getProvider, getRating, getStage, getStatus, getSLA } from './catalog-utils';
import CatalogItemIcon from './CatalogItemIcon';
import useSWRImmutable from 'swr/immutable';
import { apiPaths, fetcher } from '@app/api';
import { StarIcon } from '@patternfly/react-icons/dist/js/icons/star-icon';

import './catalog-item-card.css';

Expand All @@ -20,7 +23,10 @@ const CatalogItemCard: React.FC<{ catalogItem: CatalogItem }> = ({ catalogItem }
const rating = getRating(catalogItem);
const status = getStatus(catalogItem);
const sla = getSLA(catalogItem);

const { data: assetsFavList } = useSWRImmutable<BookmarkList>(apiPaths.FAVORITES({}), fetcher);
const isFavorite = assetsFavList.bookmarks.some(
(b) => b.asset_uuid === catalogItem.metadata?.labels?.['gpte.redhat.com/asset-uuid']
);
if (namespace) {
searchParams.set('item', catalogItem.metadata.name);
} else {
Expand Down Expand Up @@ -54,6 +60,8 @@ const CatalogItemCard: React.FC<{ catalogItem: CatalogItem }> = ({ catalogItem }
<CatalogItemIcon catalogItem={catalogItem} />
{status && status.name !== 'Operational' ? (
<StatusPageIcons status={status.name} className="catalog-item-card__statusPageIcon" />
) : isFavorite ? (
<StarIcon className="catalog-item-card__statusPageIcon" style={{ fill: '#06c' }} />
) : null}
</SplitItem>
</Split>
Expand Down
56 changes: 52 additions & 4 deletions catalog/ui/src/app/Catalog/CatalogItemDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import parseDuration from 'parse-duration';
import { Link, useNavigate } from 'react-router-dom';
import {
Expand All @@ -22,11 +22,12 @@ import {
Title,
Label,
Tooltip,
Spinner,
} from '@patternfly/react-core';
import InfoAltIcon from '@patternfly/react-icons/dist/js/icons/info-alt-icon';
import useSWR from 'swr';
import { apiPaths, fetcher, fetcherItemsInAllPages } from '@app/api';
import { AssetMetrics, CatalogItem, CatalogItemIncident, ResourceClaim } from '@app/types';
import { AssetMetrics, BookmarkList, CatalogItem, CatalogItemIncident, ResourceClaim } from '@app/types';
import LoadingIcon from '@app/components/LoadingIcon';
import StatusPageIcons from '@app/components/StatusPageIcons';
import useSession from '@app/utils/useSession';
Expand Down Expand Up @@ -61,12 +62,14 @@ import {
} from './catalog-utils';
import CatalogItemIcon from './CatalogItemIcon';
import CatalogItemHealthDisplay from './CatalogItemHealthDisplay';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import useHelpLink from '@app/utils/useHelpLink';
import useSWRImmutable from 'swr/immutable';
import UptimeDisplay from '@app/components/UptimeDisplay';
import { StarIcon } from '@patternfly/react-icons/dist/js/icons/star-icon';
import { OutlinedStarIcon } from '@patternfly/react-icons/dist/js/icons/outlined-star-icon';
import { ExternalLinkAltIcon } from '@patternfly/react-icons/dist/js/icons/external-link-alt-icon';

import './catalog-item-details.css';
import UptimeDisplay from '@app/components/UptimeDisplay';

enum CatalogItemAccess {
Allow,
Expand All @@ -79,6 +82,7 @@ const CatalogItemDetails: React.FC<{ catalogItem: CatalogItem; onClose: () => vo
const { userNamespace, isAdmin, groups } = useSession().getSession();
const { accessControl, lastUpdate } = catalogItem.spec;
const { labels, namespace, name } = catalogItem.metadata;
const [isLoadingFavorites, setIsLoadingFavorites] = useState(false);
const stage = getStageFromK8sObject(catalogItem);
const provider = getProvider(catalogItem);
const catalogItemName = displayName(catalogItem);
Expand All @@ -102,6 +106,14 @@ const CatalogItemDetails: React.FC<{ catalogItem: CatalogItem; onClose: () => vo
suspense: false,
}
);
const { data: assetsFavList, mutate: mutateFavorites } = useSWRImmutable<BookmarkList>(
asset_uuid ? apiPaths.FAVORITES({}) : null,
fetcher
);
let isFavorite = false;
if (asset_uuid && assetsFavList) {
isFavorite = assetsFavList.bookmarks.some((b) => b.asset_uuid === asset_uuid);
}
const catalogItemCpy = useMemo(() => {
const cpy = Object.assign({}, catalogItem);
cpy.metadata.annotations[`${BABYLON_DOMAIN}/incident`] = JSON.stringify(catalogItemIncident);
Expand Down Expand Up @@ -212,6 +224,25 @@ const CatalogItemDetails: React.FC<{ catalogItem: CatalogItem; onClose: () => vo
window.open(helpLink, '_blank');
}

async function toggleFavorite() {
if (!asset_uuid || isLoadingFavorites) {
return null;
}
setIsLoadingFavorites(true);
const fav = await fetcher(apiPaths.FAVORITES({}), {
method: isFavorite ? 'DELETE' : 'POST',
body: JSON.stringify({
asset_uuid,
}),
headers: {
'Content-Type': 'application/json',
},
});
mutateFavorites(fav);
setIsLoadingFavorites(false);
return null;
}

return (
<DrawerPanelContent
className="catalog-item-details"
Expand Down Expand Up @@ -289,6 +320,23 @@ const CatalogItemDetails: React.FC<{ catalogItem: CatalogItem; onClose: () => vo
</Label>
</div>
) : null}
<div className="catalog-item-details__bookmark">
<Button
onClick={toggleFavorite}
variant="link"
icon={
isLoadingFavorites ? (
<Spinner key="spinner" size="md" />
) : !isFavorite ? (
<OutlinedStarIcon />
) : (
<StarIcon />
)
}
>
{!isFavorite ? 'Save as favorite' : 'Remove from favorites'}
</Button>
</div>
</>
) : catalogItemAccess === CatalogItemAccess.Deny ? (
<>
Expand Down
1 change: 1 addition & 0 deletions catalog/ui/src/app/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1749,4 +1749,5 @@ export const apiPaths: { [key in ResourceType]: (args: any) => string } = {
`/api/salesforce/accounts?sales_type=${sales_type}&value=${account_value}`,
SFDC_BY_ACCOUNT: ({ sales_type, account_id }: { sales_type: string; account_id: string }) =>
`/api/salesforce/accounts/${account_id}?sales_type=${sales_type}`,
FAVORITES: () => `/api/user-manager/bookmarks`,
};
10 changes: 9 additions & 1 deletion catalog/ui/src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,8 @@ export type ResourceType =
| 'WORKSHOP_SUPPORT'
| 'WORKSHOP_USER_ASSIGNMENTS'
| 'SFDC_ACCOUNTS'
| 'SFDC_BY_ACCOUNT';
| 'SFDC_BY_ACCOUNT'
| 'FAVORITES';

export type ServiceActionActions = 'start' | 'stop' | 'delete' | 'rate' | 'retirement';

Expand Down Expand Up @@ -717,3 +718,10 @@ export type SalesforceAccount = {
};

export type SfdcType = 'campaign' | 'cdh' | 'project' | 'opportunity';

export type Bookmark = {
asset_uuid: string;
}
export type BookmarkList = {
bookmarks: Bookmark[]
}
50 changes: 28 additions & 22 deletions ratings/api/routers/bookmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from fastapi import APIRouter, HTTPException, Depends
from schemas import (
BookmarkSchema,
BookmarkListSchema
BookmarkRequestSchema,
BookmarkListResponseSchema
)
from models import Bookmark, User
logger = logging.getLogger('babylon-ratings')
Expand All @@ -15,59 +16,64 @@


@router.get("/api/user-manager/v1/bookmarks/{email}",
response_model=BookmarkListSchema,
response_model=BookmarkListResponseSchema,
summary="Get favorites catalog item asset")
async def bookmarks_get(email: str) -> BookmarkListSchema:
async def bookmarks_get(email: str) -> BookmarkListResponseSchema:

logger.info(f"Getting favorites for user {email}")
try:
user = await User.get_by_email(email)
if user:
logger.info(user.bookmarks)
return BookmarkListSchema(bookmarks=user.bookmarks)
bookmarks_response = [
BookmarkSchema.from_orm(bookmark).dict(exclude={"user_id"}) for bookmark in user.bookmarks
]
return BookmarkListResponseSchema(bookmarks=bookmarks_response)
else:
raise HTTPException(status_code=404, detail="User email doesn't exists") from e
except Exception as e:
logger.error(f"Error getting favorite: {e}", stack_info=True)
raise HTTPException(status_code=500, detail="Error getting favorites") from e

@router.post("/api/user-manager/v1/bookmarks",
response_model=BookmarkListSchema,
response_model=BookmarkListResponseSchema,
summary="Add bookmark",
)
async def bookmarks_post(email: str,
asset_uuid: str) -> {}:
async def bookmarks_post(bookmark_obj: BookmarkRequestSchema) -> BookmarkListResponseSchema:

logger.info(f"Add favorite item for user {email}")
logger.info(f"Add favorite item for user {bookmark_obj.email}")
try:
user = await User.get_by_email(email)
user = await User.get_by_email(bookmark_obj.email)
if user:
logger.info(user)
bookmark = Bookmark.from_dict({"user_id": user.id, "asset_uuid": asset_uuid})
bookmark = Bookmark.from_dict({"user_id": user.id, "asset_uuid": bookmark_obj.asset_uuid})
await bookmark.save()
user = await User.get_by_email(email)
return BookmarkListSchema(bookmarks=user.bookmarks)
user = await User.get_by_email(bookmark_obj.email)
bookmarks_response = [
BookmarkSchema.from_orm(bookmark).dict(exclude={"user_id"}) for bookmark in user.bookmarks
]
return BookmarkListResponseSchema(bookmarks=bookmarks_response)
else:
raise HTTPException(status_code=404, detail="User email doesn't exists") from e
except Exception as e:
logger.error(f"Error saving favorite: {e}", stack_info=True)
raise HTTPException(status_code=500, detail="Error saving favorites") from e

@router.delete("/api/user-manager/v1/bookmarks",
response_model={},
response_model=BookmarkListResponseSchema,
summary="Delete bookmark",
)
async def bookmarks_delete(email: str,
asset_uuid: str) -> BookmarkListSchema:
async def bookmarks_delete(bookmark_obj: BookmarkRequestSchema) -> BookmarkListResponseSchema:

logger.info(f"Delete favorite item for user {email}")
logger.info(f"Delete favorite item for user {bookmark_obj.email}")
try:
user = await User.get_by_email(email)
user = await User.get_by_email(bookmark_obj.email)
if user:
bookmark = Bookmark.from_dict({"user_id": user.id, "asset_uuid": asset_uuid})
bookmark = Bookmark.from_dict({"user_id": user.id, "asset_uuid": bookmark_obj.asset_uuid})
await bookmark.delete()
user = await User.get_by_email(email)
return BookmarkListSchema(bookmarks=user.bookmarks)
user = await User.get_by_email(bookmark_obj.email)
bookmarks_response = [
BookmarkSchema.from_orm(bookmark).dict(exclude={"user_id"}) for bookmark in user.bookmarks
]
return BookmarkListResponseSchema(bookmarks=bookmarks_response)
else:
raise HTTPException(status_code=404, detail="User email doesn't exists") from e

Expand Down
2 changes: 1 addition & 1 deletion ratings/api/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
from .request import RequestSchema
from .user import UserSchema
from .workshop import WorkshopSchema, WorkshopRequestSchema
from .bookmarks import BookmarkSchema, BookmarkListSchema
from .bookmarks import BookmarkSchema, BookmarkRequestSchema, BookmarkListResponseSchema
Loading

0 comments on commit d72efe0

Please sign in to comment.