From 5f8826974470ead8f1a95830e1cf1d39fe62b640 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Wed, 30 Nov 2022 13:44:38 +0100 Subject: [PATCH] feat: favorite feature table icons (#2525) --- .../FavoriteIconCell/FavoriteIconCell.tsx | 40 ++++ .../FavoriteIconHeader/FavoriteIconHeader.tsx | 45 ++++ .../FeatureToggleListTable.tsx | 200 +++++++++++------- .../useFavoriteFeaturesApi.ts | 68 ++++++ .../api/getters/useFeatures/useFeatures.ts | 29 ++- frontend/src/hooks/usePinnedFavorites.test.ts | 47 ++++ frontend/src/hooks/usePinnedFavorites.ts | 51 +++++ frontend/src/interfaces/uiConfig.ts | 2 +- frontend/src/utils/sortTypes.test.ts | 48 +++++ frontend/src/utils/sortTypes.ts | 37 +++- src/lib/routes/admin-api/feature.ts | 7 +- src/lib/services/feature-toggle-service.ts | 4 +- 12 files changed, 477 insertions(+), 101 deletions(-) create mode 100644 frontend/src/component/feature/FeatureToggleList/FavoriteIconCell/FavoriteIconCell.tsx create mode 100644 frontend/src/component/feature/FeatureToggleList/FavoriteIconHeader/FavoriteIconHeader.tsx create mode 100644 frontend/src/hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi.ts create mode 100644 frontend/src/hooks/usePinnedFavorites.test.ts create mode 100644 frontend/src/hooks/usePinnedFavorites.ts create mode 100644 frontend/src/utils/sortTypes.test.ts diff --git a/frontend/src/component/feature/FeatureToggleList/FavoriteIconCell/FavoriteIconCell.tsx b/frontend/src/component/feature/FeatureToggleList/FavoriteIconCell/FavoriteIconCell.tsx new file mode 100644 index 0000000000..ba46ba0a33 --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/FavoriteIconCell/FavoriteIconCell.tsx @@ -0,0 +1,40 @@ +import { VFC } from 'react'; +import { Box, IconButton, styled } from '@mui/material'; +import { + Star as StarIcon, + StarBorder as StarBorderIcon, +} from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +interface IFavoriteIconCellProps { + value?: boolean; + onClick?: () => void; +} + +const InactiveIconButton = styled(IconButton)(({ theme }) => ({ + color: 'transparent', + '&:hover, &:focus': { + color: theme.palette.primary.main, + }, +})); + +export const FavoriteIconCell: VFC = ({ + value = false, + onClick, +}) => ( + + + + + } + elseShow={ + + + + } + /> + +); diff --git a/frontend/src/component/feature/FeatureToggleList/FavoriteIconHeader/FavoriteIconHeader.tsx b/frontend/src/component/feature/FeatureToggleList/FavoriteIconHeader/FavoriteIconHeader.tsx new file mode 100644 index 0000000000..28668bce38 --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/FavoriteIconHeader/FavoriteIconHeader.tsx @@ -0,0 +1,45 @@ +import { VFC } from 'react'; +import { IconButton, Tooltip } from '@mui/material'; +import { + Star as StarIcon, + StarBorder as StarBorderIcon, +} from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +interface IFavoriteIconHeaderProps { + isActive: boolean; + onClick: () => void; +} + +export const FavoriteIconHeader: VFC = ({ + isActive = false, + onClick, +}) => { + return ( + + + } + elseShow={} + /> + + + ); +}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx index 6f87400a28..ed066d15c6 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -13,7 +13,6 @@ import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/Fe import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { PageContent } from 'component/common/PageContent/PageContent'; import { PageHeader } from 'component/common/PageHeader/PageHeader'; -import { sortTypes } from 'utils/sortTypes'; import { createLocalStorage } from 'utils/createLocalStorage'; import { FeatureSchema } from 'openapi'; import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton'; @@ -21,6 +20,11 @@ import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell'; import { useSearch } from 'hooks/useSearch'; import { Search } from 'component/common/Search/Search'; import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell'; +import { usePinnedFavorites } from 'hooks/usePinnedFavorites'; +import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi'; +import { FavoriteIconCell } from './FavoriteIconCell/FavoriteIconCell'; +import { FavoriteIconHeader } from './FavoriteIconHeader/FavoriteIconHeader'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({ name: 'Name of the feature', @@ -31,81 +35,14 @@ export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({ }); export type PageQueryType = Partial< - Record<'sort' | 'order' | 'search', string> + Record<'sort' | 'order' | 'search' | 'favorites', string> >; -const columns = [ - { - Header: 'Seen', - accessor: 'lastSeenAt', - Cell: FeatureSeenCell, - sortType: 'date', - align: 'center', - maxWidth: 85, - }, - { - Header: 'Type', - accessor: 'type', - Cell: FeatureTypeCell, - align: 'center', - maxWidth: 85, - }, - { - Header: 'Name', - accessor: 'name', - minWidth: 150, - Cell: FeatureNameCell, - sortType: 'alphanumeric', - searchable: true, - }, - { - id: 'tags', - Header: 'Tags', - accessor: (row: FeatureSchema) => - row.tags?.map(({ type, value }) => `${type}:${value}`).join('\n') || - '', - Cell: FeatureTagCell, - width: 80, - searchable: true, - }, - { - Header: 'Created', - accessor: 'createdAt', - Cell: DateCell, - sortType: 'date', - maxWidth: 150, - }, - { - Header: 'Project ID', - accessor: 'project', - Cell: ({ value }: { value: string }) => ( - - ), - sortType: 'alphanumeric', - maxWidth: 150, - filterName: 'project', - searchable: true, - }, - { - Header: 'State', - accessor: 'stale', - Cell: FeatureStaleCell, - sortType: 'boolean', - maxWidth: 120, - filterName: 'state', - filterParsing: (value: any) => (value ? 'stale' : 'active'), - }, - // Always hidden -- for search - { - accessor: 'description', - }, -]; - const defaultSort: SortingRule = { id: 'createdAt' }; const { value: storedParams, setValue: setStoredParams } = createLocalStorage( 'FeatureToggleListTable:v1', - defaultSort + { ...defaultSort, favorites: false } ); export const FeatureToggleListTable: VFC = () => { @@ -126,7 +63,117 @@ export const FeatureToggleListTable: VFC = () => { hiddenColumns: ['description'], globalFilter: searchParams.get('search') || '', })); + const { isFavoritesPinned, sortTypes, onChangeIsFavoritePinned } = + usePinnedFavorites( + searchParams.has('favorites') + ? searchParams.get('favorites') === 'true' + : storedParams.favorites + ); const [searchValue, setSearchValue] = useState(initialState.globalFilter); + const { favorite, unfavorite } = useFavoriteFeaturesApi(); + const { uiConfig } = useUiConfig(); + + const columns = useMemo( + () => [ + ...(uiConfig?.flags?.favorites + ? [ + { + Header: ( + + ), + accessor: 'favorite', + Cell: ({ row: { original: feature } }: any) => ( + + feature?.favorite + ? unfavorite( + feature.project, + feature.name + ) + : favorite( + feature.project, + feature.name + ) + } + /> + ), + maxWidth: 50, + disableSortBy: true, + }, + ] + : []), + { + Header: 'Seen', + accessor: 'lastSeenAt', + Cell: FeatureSeenCell, + sortType: 'date', + align: 'center', + maxWidth: 85, + }, + { + Header: 'Type', + accessor: 'type', + Cell: FeatureTypeCell, + align: 'center', + maxWidth: 85, + }, + { + Header: 'Name', + accessor: 'name', + minWidth: 150, + Cell: FeatureNameCell, + sortType: 'alphanumeric', + searchable: true, + }, + { + id: 'tags', + Header: 'Tags', + accessor: (row: FeatureSchema) => + row.tags + ?.map(({ type, value }) => `${type}:${value}`) + .join('\n') || '', + Cell: FeatureTagCell, + width: 80, + searchable: true, + }, + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + sortType: 'date', + maxWidth: 150, + }, + { + Header: 'Project ID', + accessor: 'project', + Cell: ({ value }: { value: string }) => ( + + ), + sortType: 'alphanumeric', + maxWidth: 150, + filterName: 'project', + searchable: true, + }, + { + Header: 'State', + accessor: 'stale', + Cell: FeatureStaleCell, + sortType: 'boolean', + maxWidth: 120, + filterName: 'state', + filterParsing: (value: any) => (value ? 'stale' : 'active'), + }, + // Always hidden -- for search + { + accessor: 'description', + }, + ], + [isFavoritesPinned] + ); const { data: searchedData, @@ -174,7 +221,7 @@ export const FeatureToggleListTable: VFC = () => { hiddenColumns.push('type', 'createdAt', 'tags'); } setHiddenColumns(hiddenColumns); - }, [setHiddenColumns, isSmallScreen, isMediumScreen, features]); + }, [setHiddenColumns, isSmallScreen, isMediumScreen, features, columns]); useEffect(() => { const tableState: PageQueryType = {}; @@ -185,12 +232,19 @@ export const FeatureToggleListTable: VFC = () => { if (searchValue) { tableState.search = searchValue; } + if (isFavoritesPinned) { + tableState.favorites = 'true'; + } setSearchParams(tableState, { replace: true, }); - setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false }); - }, [sortBy, searchValue, setSearchParams]); + setStoredParams({ + id: sortBy[0].id, + desc: sortBy[0].desc || false, + favorites: isFavoritesPinned || false, + }); + }, [sortBy, searchValue, setSearchParams, isFavoritesPinned]); return ( { + const { makeRequest, createRequest, errors, loading } = useAPI({ + propagateErrors: true, + }); + const { setToastData, setToastApiError } = useToast(); + const { refetchFeatures } = useFeatures(); + + const favorite = useCallback( + async (projectId: string, featureName: string) => { + const path = `api/admin/projects/${projectId}/features/${featureName}/favorites`; + const req = createRequest( + path, + { method: 'POST' }, + 'addFavoriteFeature' + ); + + try { + await makeRequest(req.caller, req.id); + + setToastData({ + title: 'Toggle added to favorites', + type: 'success', + }); + refetchFeatures(); + } catch (error) { + setToastApiError(formatUnknownError(error)); + } + }, + [createRequest, makeRequest] + ); + + const unfavorite = useCallback( + async (projectId: string, featureName: string) => { + const path = `api/admin/projects/${projectId}/features/${featureName}/favorites`; + const req = createRequest( + path, + { method: 'DELETE' }, + 'removeFavoriteFeature' + ); + + try { + await makeRequest(req.caller, req.id); + + setToastData({ + title: 'Toggle removed from favorites', + type: 'success', + }); + refetchFeatures(); + } catch (error) { + setToastApiError(formatUnknownError(error)); + } + }, + [createRequest, makeRequest] + ); + + return { + favorite, + unfavorite, + errors, + loading, + }; +}; diff --git a/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts b/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts index 2b9269bdb6..056b0b94a2 100644 --- a/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts +++ b/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts @@ -1,18 +1,17 @@ -import { FeatureSchema } from 'openapi'; -import { openApiAdmin } from 'utils/openapiClient'; -import { useApiGetter } from 'hooks/api/getters/useApiGetter/useApiGetter'; +import { FeaturesSchema } from 'openapi'; +import useSWR from 'swr'; +import handleErrorResponses from '../httpErrorResponseHandler'; -export interface IUseFeaturesOutput { - features?: FeatureSchema[]; - refetchFeatures: () => void; - loading: boolean; - error?: Error; -} +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Feature toggle')) + .then(res => res.json()); +}; -export const useFeatures = (): IUseFeaturesOutput => { - const { data, refetch, loading, error } = useApiGetter( - 'apiAdminFeaturesGet', - () => openApiAdmin.getAllToggles(), +export const useFeatures = () => { + const { data, error, mutate } = useSWR( + 'api/admin/features', + fetcher, { refreshInterval: 15 * 1000, // ms } @@ -20,8 +19,8 @@ export const useFeatures = (): IUseFeaturesOutput => { return { features: data?.features, - refetchFeatures: refetch, - loading, + loading: !error && !data, + refetchFeatures: mutate, error, }; }; diff --git a/frontend/src/hooks/usePinnedFavorites.test.ts b/frontend/src/hooks/usePinnedFavorites.test.ts new file mode 100644 index 0000000000..4c29ef245c --- /dev/null +++ b/frontend/src/hooks/usePinnedFavorites.test.ts @@ -0,0 +1,47 @@ +import { Row } from 'react-table'; +import { sortTypesWithFavorites } from './usePinnedFavorites'; + +const data = [ + { + id: 1, + favorite: true, + }, + { + id: 2, + favorite: false, + }, + { + id: 3, + favorite: true, + }, + { + id: 4, + favorite: false, + }, + { + id: 5, + favorite: false, + }, +].map(d => ({ values: d })) as unknown as Row[]; + +test('puts favorite items first', () => { + const output = data.sort((a, b) => + sortTypesWithFavorites.alphanumeric(a, b, 'id') + ); + const ids = output.map(({ values: { id } }) => id); + const favorites = output.map(({ values: { favorite } }) => favorite); + + expect(ids).toEqual([1, 3, 2, 4, 5]); + expect(favorites).toEqual([true, true, false, false, false]); +}); + +test('in descending order put favorites last (react-table will reverse order)', () => { + const output = data.sort((a, b) => + sortTypesWithFavorites.alphanumeric(a, b, 'id', true) + ); + const ids = output.map(({ values: { id } }) => id); + const favorites = output.map(({ values: { favorite } }) => favorite); + + expect(ids).toEqual([2, 4, 5, 1, 3]); + expect(favorites).toEqual([false, false, false, true, true]); +}); diff --git a/frontend/src/hooks/usePinnedFavorites.ts b/frontend/src/hooks/usePinnedFavorites.ts new file mode 100644 index 0000000000..9b28355055 --- /dev/null +++ b/frontend/src/hooks/usePinnedFavorites.ts @@ -0,0 +1,51 @@ +import { useMemo, useState } from 'react'; +import { sortTypes } from 'utils/sortTypes'; +import type { Row, SortByFn } from 'react-table'; + +type WithFavorite = { + favorite: boolean; + [key: string]: any; +}; + +export const sortTypesWithFavorites: Record< + keyof typeof sortTypes, + SortByFn // TODO: possible type improvement in react-table v8 +> = Object.assign( + {}, + ...Object.entries(sortTypes).map(([key, value]) => ({ + [key]: ( + v1: Row, + v2: Row, + id: string, + desc?: boolean + ) => { + if (v1?.values?.favorite && !v2?.values?.favorite) + return desc ? 1 : -1; + if (!v1?.values?.favorite && v2?.values?.favorite) + return desc ? -1 : 1; + return value(v1, v2, id, desc); + }, + })) +); + +/** + * Move favorites to the top of the list. + */ +export const usePinnedFavorites = (initialState = false) => { + const [isFavoritesPinned, setIsFavoritesPinned] = useState(initialState); + + const onChangeIsFavoritePinned = () => { + setIsFavoritesPinned(!isFavoritesPinned); + }; + + const enhancedSortTypes = useMemo( + () => (isFavoritesPinned ? sortTypesWithFavorites : sortTypes), + [isFavoritesPinned] + ); + + return { + isFavoritesPinned, + onChangeIsFavoritePinned, + sortTypes: enhancedSortTypes, + }; +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 11ce82b51d..2ca982c270 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -41,11 +41,11 @@ export interface IFlags { ENABLE_DARK_MODE_SUPPORT?: boolean; embedProxyFrontend?: boolean; syncSSOGroups?: boolean; - favorites?: boolean; changeRequests?: boolean; cloneEnvironment?: boolean; variantsPerEnvironment?: boolean; tokensLastSeen?: boolean; + favorites?: boolean; networkView?: boolean; } diff --git a/frontend/src/utils/sortTypes.test.ts b/frontend/src/utils/sortTypes.test.ts new file mode 100644 index 0000000000..461b501f2c --- /dev/null +++ b/frontend/src/utils/sortTypes.test.ts @@ -0,0 +1,48 @@ +import { Row } from 'react-table'; +import { sortTypes } from './sortTypes'; + +const data = [ + { + id: 1, + age: 42, + bool: true, + }, + { + id: 2, + age: 35, + bool: false, + }, + { + id: 3, + age: 25, + bool: true, + }, + { + id: 4, + age: 32, + bool: false, + }, + { + id: 5, + age: 18, + bool: true, + }, +].map(d => ({ values: d })) as unknown as Row<{ + id: number; + age: number; + bool: boolean; +}>[]; + +test('sortTypes', () => { + expect( + data + .sort((a, b) => sortTypes.boolean(a, b, 'bool')) + .map(({ values: { id } }) => id) + ).toEqual([2, 4, 1, 3, 5]); + + expect( + data + .sort((a, b) => sortTypes.alphanumeric(a, b, 'age')) + .map(({ values: { age } }) => age) + ).toEqual([18, 25, 32, 35, 42]); +}); diff --git a/frontend/src/utils/sortTypes.ts b/frontend/src/utils/sortTypes.ts index 3dd6a6c1eb..7e5fbfe9de 100644 --- a/frontend/src/utils/sortTypes.ts +++ b/frontend/src/utils/sortTypes.ts @@ -1,24 +1,47 @@ +import { IdType, Row } from 'react-table'; + /** * For `react-table`. * * @see https://react-table.tanstack.com/docs/api/useSortBy#table-options */ export const sortTypes = { - date: (v1: any, v2: any, id: string) => { + date: ( + v1: Row, + v2: Row, + id: IdType, + _desc?: boolean + ) => { const a = new Date(v1?.values?.[id] || 0); const b = new Date(v2?.values?.[id] || 0); return b?.getTime() - a?.getTime(); // newest first by default }, - boolean: (v1: any, v2: any, id: string) => { + boolean: ( + v1: Row, + v2: Row, + id: IdType, + _desc?: boolean + ) => { const a = v1?.values?.[id]; const b = v2?.values?.[id]; return a === b ? 0 : a ? 1 : -1; }, - alphanumeric: (a: any, b: any, id: string) => - (a?.values?.[id] || '') - ?.toLowerCase() - .localeCompare(b?.values?.[id]?.toLowerCase() || ''), - playgroundResultState: (v1: any, v2: any, id: string) => { + alphanumeric: ( + a: Row, + b: Row, + id: IdType, + _desc?: boolean + ) => { + const aVal = `${a?.values?.[id] || ''}`.toLowerCase(); + const bVal = `${b?.values?.[id] || ''}`.toLowerCase(); + return aVal?.localeCompare(bVal); + }, + playgroundResultState: ( + v1: Row, + v2: Row, + id: IdType, + _desc?: boolean + ) => { const a = v1?.values?.[id]; const b = v2?.values?.[id]; if (a === b) return 0; diff --git a/src/lib/routes/admin-api/feature.ts b/src/lib/routes/admin-api/feature.ts index ea6b827359..ecc7e9ec44 100644 --- a/src/lib/routes/admin-api/feature.ts +++ b/src/lib/routes/admin-api/feature.ts @@ -345,8 +345,11 @@ class FeatureController extends Controller { res.status(200).json(feature); } - // TODO: remove? - // Kept to keep backward compatibility + /** + * @deprecated TODO: remove? + * + * Kept to keep backward compatibility + */ async toggle(req: IAuthRequest, res: Response): Promise { const userName = extractUsername(req); const { featureName } = req.params; diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index 016cd7a642..9ce07b8589 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -675,9 +675,7 @@ class FeatureToggleService { } /** - * - * Warn: Legacy! - * + * @deprecated Legacy! * * Used to retrieve metadata of all feature toggles defined in Unleash. * @param query - Allow you to limit search based on criteria such as project, tags, namePrefix. See @IFeatureToggleQuery