1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-01 01:18:10 +02:00

feat: favorite feature table icons (#2525)

This commit is contained in:
Tymoteusz Czech 2022-11-30 13:44:38 +01:00 committed by GitHub
parent dacaaa51b7
commit 5f88269744
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 477 additions and 101 deletions

View File

@ -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<IFavoriteIconCellProps> = ({
value = false,
onClick,
}) => (
<Box sx={{ pl: 1.25 }}>
<ConditionallyRender
condition={value}
show={
<IconButton onClick={onClick} color="primary" size="small">
<StarIcon fontSize="small" />
</IconButton>
}
elseShow={
<InactiveIconButton onClick={onClick} size="small">
<StarBorderIcon fontSize="small" />
</InactiveIconButton>
}
/>
</Box>
);

View File

@ -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<IFavoriteIconHeaderProps> = ({
isActive = false,
onClick,
}) => {
return (
<Tooltip
title={
isActive
? 'Unpin favorite features from the top'
: 'Pin favorite features to the top'
}
placement="bottom-start"
>
<IconButton
sx={{
mx: -0.75,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onClick}
size="small"
>
<ConditionallyRender
condition={isActive}
show={<StarIcon fontSize="small" />}
elseShow={<StarBorderIcon fontSize="small" />}
/>
</IconButton>
</Tooltip>
);
};

View File

@ -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 }) => (
<LinkCell title={value} to={`/projects/${value}`} />
),
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<string> = { 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: (
<FavoriteIconHeader
isActive={isFavoritesPinned}
onClick={onChangeIsFavoritePinned}
/>
),
accessor: 'favorite',
Cell: ({ row: { original: feature } }: any) => (
<FavoriteIconCell
value={feature?.favorite}
onClick={() =>
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 }) => (
<LinkCell title={value} to={`/projects/${value}`} />
),
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 (
<PageContent

View File

@ -0,0 +1,68 @@
import { useCallback } from 'react';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures';
import useAPI from '../useApi/useApi';
export const useFavoriteFeaturesApi = () => {
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,
};
};

View File

@ -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<FeaturesSchema>(
'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,
};
};

View File

@ -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<object>[];
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]);
});

View File

@ -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<object> // TODO: possible type improvement in react-table v8
> = Object.assign(
{},
...Object.entries(sortTypes).map(([key, value]) => ({
[key]: (
v1: Row<WithFavorite>,
v2: Row<WithFavorite>,
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,
};
};

View File

@ -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;
}

View File

@ -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]);
});

View File

@ -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: <D extends object>(
v1: Row<D>,
v2: Row<D>,
id: IdType<D>,
_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: <D extends object>(
v1: Row<D>,
v2: Row<D>,
id: IdType<D>,
_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: <D extends object>(
a: Row<D>,
b: Row<D>,
id: IdType<D>,
_desc?: boolean
) => {
const aVal = `${a?.values?.[id] || ''}`.toLowerCase();
const bVal = `${b?.values?.[id] || ''}`.toLowerCase();
return aVal?.localeCompare(bVal);
},
playgroundResultState: <D extends object>(
v1: Row<D>,
v2: Row<D>,
id: IdType<D>,
_desc?: boolean
) => {
const a = v1?.values?.[id];
const b = v2?.values?.[id];
if (a === b) return 0;

View File

@ -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<void> {
const userName = extractUsername(req);
const { featureName } = req.params;

View File

@ -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