mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01: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