mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-04 13:48:56 +02:00
feat: favorite feature table icons (#2525)
This commit is contained in:
parent
dacaaa51b7
commit
5f88269744
@ -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>
|
||||||
|
);
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -13,7 +13,6 @@ import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/Fe
|
|||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
import { sortTypes } from 'utils/sortTypes';
|
|
||||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||||
import { FeatureSchema } from 'openapi';
|
import { FeatureSchema } from 'openapi';
|
||||||
import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton';
|
import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton';
|
||||||
@ -21,6 +20,11 @@ import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell';
|
|||||||
import { useSearch } from 'hooks/useSearch';
|
import { useSearch } from 'hooks/useSearch';
|
||||||
import { Search } from 'component/common/Search/Search';
|
import { Search } from 'component/common/Search/Search';
|
||||||
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
|
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({
|
export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
|
||||||
name: 'Name of the feature',
|
name: 'Name of the feature',
|
||||||
@ -31,10 +35,77 @@ export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type PageQueryType = Partial<
|
export type PageQueryType = Partial<
|
||||||
Record<'sort' | 'order' | 'search', string>
|
Record<'sort' | 'order' | 'search' | 'favorites', string>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const columns = [
|
const defaultSort: SortingRule<string> = { id: 'createdAt' };
|
||||||
|
|
||||||
|
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
|
||||||
|
'FeatureToggleListTable:v1',
|
||||||
|
{ ...defaultSort, favorites: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FeatureToggleListTable: VFC = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
|
||||||
|
const { features = [], loading } = useFeatures();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [initialState] = useState(() => ({
|
||||||
|
sortBy: [
|
||||||
|
{
|
||||||
|
id: searchParams.get('sort') || storedParams.id,
|
||||||
|
desc: searchParams.has('order')
|
||||||
|
? searchParams.get('order') === 'desc'
|
||||||
|
: storedParams.desc,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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',
|
Header: 'Seen',
|
||||||
accessor: 'lastSeenAt',
|
accessor: 'lastSeenAt',
|
||||||
@ -62,8 +133,9 @@ const columns = [
|
|||||||
id: 'tags',
|
id: 'tags',
|
||||||
Header: 'Tags',
|
Header: 'Tags',
|
||||||
accessor: (row: FeatureSchema) =>
|
accessor: (row: FeatureSchema) =>
|
||||||
row.tags?.map(({ type, value }) => `${type}:${value}`).join('\n') ||
|
row.tags
|
||||||
'',
|
?.map(({ type, value }) => `${type}:${value}`)
|
||||||
|
.join('\n') || '',
|
||||||
Cell: FeatureTagCell,
|
Cell: FeatureTagCell,
|
||||||
width: 80,
|
width: 80,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
@ -99,34 +171,9 @@ const columns = [
|
|||||||
{
|
{
|
||||||
accessor: 'description',
|
accessor: 'description',
|
||||||
},
|
},
|
||||||
];
|
|
||||||
|
|
||||||
const defaultSort: SortingRule<string> = { id: 'createdAt' };
|
|
||||||
|
|
||||||
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
|
|
||||||
'FeatureToggleListTable:v1',
|
|
||||||
defaultSort
|
|
||||||
);
|
|
||||||
|
|
||||||
export const FeatureToggleListTable: VFC = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
|
|
||||||
const { features = [], loading } = useFeatures();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [initialState] = useState(() => ({
|
|
||||||
sortBy: [
|
|
||||||
{
|
|
||||||
id: searchParams.get('sort') || storedParams.id,
|
|
||||||
desc: searchParams.has('order')
|
|
||||||
? searchParams.get('order') === 'desc'
|
|
||||||
: storedParams.desc,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
hiddenColumns: ['description'],
|
[isFavoritesPinned]
|
||||||
globalFilter: searchParams.get('search') || '',
|
);
|
||||||
}));
|
|
||||||
const [searchValue, setSearchValue] = useState(initialState.globalFilter);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: searchedData,
|
data: searchedData,
|
||||||
@ -174,7 +221,7 @@ export const FeatureToggleListTable: VFC = () => {
|
|||||||
hiddenColumns.push('type', 'createdAt', 'tags');
|
hiddenColumns.push('type', 'createdAt', 'tags');
|
||||||
}
|
}
|
||||||
setHiddenColumns(hiddenColumns);
|
setHiddenColumns(hiddenColumns);
|
||||||
}, [setHiddenColumns, isSmallScreen, isMediumScreen, features]);
|
}, [setHiddenColumns, isSmallScreen, isMediumScreen, features, columns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tableState: PageQueryType = {};
|
const tableState: PageQueryType = {};
|
||||||
@ -185,12 +232,19 @@ export const FeatureToggleListTable: VFC = () => {
|
|||||||
if (searchValue) {
|
if (searchValue) {
|
||||||
tableState.search = searchValue;
|
tableState.search = searchValue;
|
||||||
}
|
}
|
||||||
|
if (isFavoritesPinned) {
|
||||||
|
tableState.favorites = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
setSearchParams(tableState, {
|
setSearchParams(tableState, {
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
|
setStoredParams({
|
||||||
}, [sortBy, searchValue, setSearchParams]);
|
id: sortBy[0].id,
|
||||||
|
desc: sortBy[0].desc || false,
|
||||||
|
favorites: isFavoritesPinned || false,
|
||||||
|
});
|
||||||
|
}, [sortBy, searchValue, setSearchParams, isFavoritesPinned]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent
|
<PageContent
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
@ -1,18 +1,17 @@
|
|||||||
import { FeatureSchema } from 'openapi';
|
import { FeaturesSchema } from 'openapi';
|
||||||
import { openApiAdmin } from 'utils/openapiClient';
|
import useSWR from 'swr';
|
||||||
import { useApiGetter } from 'hooks/api/getters/useApiGetter/useApiGetter';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
|
||||||
export interface IUseFeaturesOutput {
|
const fetcher = (path: string) => {
|
||||||
features?: FeatureSchema[];
|
return fetch(path)
|
||||||
refetchFeatures: () => void;
|
.then(handleErrorResponses('Feature toggle'))
|
||||||
loading: boolean;
|
.then(res => res.json());
|
||||||
error?: Error;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export const useFeatures = (): IUseFeaturesOutput => {
|
export const useFeatures = () => {
|
||||||
const { data, refetch, loading, error } = useApiGetter(
|
const { data, error, mutate } = useSWR<FeaturesSchema>(
|
||||||
'apiAdminFeaturesGet',
|
'api/admin/features',
|
||||||
() => openApiAdmin.getAllToggles(),
|
fetcher,
|
||||||
{
|
{
|
||||||
refreshInterval: 15 * 1000, // ms
|
refreshInterval: 15 * 1000, // ms
|
||||||
}
|
}
|
||||||
@ -20,8 +19,8 @@ export const useFeatures = (): IUseFeaturesOutput => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
features: data?.features,
|
features: data?.features,
|
||||||
refetchFeatures: refetch,
|
loading: !error && !data,
|
||||||
loading,
|
refetchFeatures: mutate,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
47
frontend/src/hooks/usePinnedFavorites.test.ts
Normal file
47
frontend/src/hooks/usePinnedFavorites.test.ts
Normal 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]);
|
||||||
|
});
|
51
frontend/src/hooks/usePinnedFavorites.ts
Normal file
51
frontend/src/hooks/usePinnedFavorites.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
@ -41,11 +41,11 @@ export interface IFlags {
|
|||||||
ENABLE_DARK_MODE_SUPPORT?: boolean;
|
ENABLE_DARK_MODE_SUPPORT?: boolean;
|
||||||
embedProxyFrontend?: boolean;
|
embedProxyFrontend?: boolean;
|
||||||
syncSSOGroups?: boolean;
|
syncSSOGroups?: boolean;
|
||||||
favorites?: boolean;
|
|
||||||
changeRequests?: boolean;
|
changeRequests?: boolean;
|
||||||
cloneEnvironment?: boolean;
|
cloneEnvironment?: boolean;
|
||||||
variantsPerEnvironment?: boolean;
|
variantsPerEnvironment?: boolean;
|
||||||
tokensLastSeen?: boolean;
|
tokensLastSeen?: boolean;
|
||||||
|
favorites?: boolean;
|
||||||
networkView?: boolean;
|
networkView?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
48
frontend/src/utils/sortTypes.test.ts
Normal file
48
frontend/src/utils/sortTypes.test.ts
Normal 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]);
|
||||||
|
});
|
@ -1,24 +1,47 @@
|
|||||||
|
import { IdType, Row } from 'react-table';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For `react-table`.
|
* For `react-table`.
|
||||||
*
|
*
|
||||||
* @see https://react-table.tanstack.com/docs/api/useSortBy#table-options
|
* @see https://react-table.tanstack.com/docs/api/useSortBy#table-options
|
||||||
*/
|
*/
|
||||||
export const sortTypes = {
|
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 a = new Date(v1?.values?.[id] || 0);
|
||||||
const b = new Date(v2?.values?.[id] || 0);
|
const b = new Date(v2?.values?.[id] || 0);
|
||||||
return b?.getTime() - a?.getTime(); // newest first by default
|
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 a = v1?.values?.[id];
|
||||||
const b = v2?.values?.[id];
|
const b = v2?.values?.[id];
|
||||||
return a === b ? 0 : a ? 1 : -1;
|
return a === b ? 0 : a ? 1 : -1;
|
||||||
},
|
},
|
||||||
alphanumeric: (a: any, b: any, id: string) =>
|
alphanumeric: <D extends object>(
|
||||||
(a?.values?.[id] || '')
|
a: Row<D>,
|
||||||
?.toLowerCase()
|
b: Row<D>,
|
||||||
.localeCompare(b?.values?.[id]?.toLowerCase() || ''),
|
id: IdType<D>,
|
||||||
playgroundResultState: (v1: any, v2: any, id: string) => {
|
_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 a = v1?.values?.[id];
|
||||||
const b = v2?.values?.[id];
|
const b = v2?.values?.[id];
|
||||||
if (a === b) return 0;
|
if (a === b) return 0;
|
||||||
|
@ -345,8 +345,11 @@ class FeatureController extends Controller {
|
|||||||
res.status(200).json(feature);
|
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> {
|
async toggle(req: IAuthRequest, res: Response): Promise<void> {
|
||||||
const userName = extractUsername(req);
|
const userName = extractUsername(req);
|
||||||
const { featureName } = req.params;
|
const { featureName } = req.params;
|
||||||
|
@ -675,9 +675,7 @@ class FeatureToggleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* @deprecated Legacy!
|
||||||
* Warn: Legacy!
|
|
||||||
*
|
|
||||||
*
|
*
|
||||||
* Used to retrieve metadata of all feature toggles defined in Unleash.
|
* 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
|
* @param query - Allow you to limit search based on criteria such as project, tags, namePrefix. See @IFeatureToggleQuery
|
||||||
|
Loading…
Reference in New Issue
Block a user