mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: new search for feature toggle list table (#5454)
Filtering of feature toggles list with backend
This commit is contained in:
		
							parent
							
								
									bb03253681
								
							
						
					
					
						commit
						f690fe86da
					
				| @ -13,12 +13,17 @@ import { FilterItemChip } from './FilterItemChip/FilterItemChip'; | ||||
| interface IFilterItemProps { | ||||
|     label: string; | ||||
|     options: Array<{ label: string; value: string }>; | ||||
|     onChange?: (value: string) => void; | ||||
| } | ||||
| 
 | ||||
| const singularOperators = ['IS', 'IS_NOT']; | ||||
| const pluralOperators = ['IS_IN', 'IS_NOT_IN']; | ||||
| const pluralOperators = ['IS_ANY_OF', 'IS_NOT_ANY_OF']; | ||||
| 
 | ||||
| export const FilterItem: FC<IFilterItemProps> = ({ label, options }) => { | ||||
| export const FilterItem: FC<IFilterItemProps> = ({ | ||||
|     label, | ||||
|     options, | ||||
|     onChange, | ||||
| }) => { | ||||
|     const ref = useRef<HTMLDivElement>(null); | ||||
|     const [selectedOptions, setSelectedOptions] = useState<typeof options>([]); | ||||
|     const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(null); | ||||
| @ -35,8 +40,28 @@ export const FilterItem: FC<IFilterItemProps> = ({ label, options }) => { | ||||
|         setAnchorEl(null); | ||||
|     }; | ||||
| 
 | ||||
|     const handleOnChange = ( | ||||
|         op: typeof operator, | ||||
|         values: typeof selectedOptions, | ||||
|     ) => { | ||||
|         const value = values.length | ||||
|             ? `${op}:${values?.map((option) => option.value).join(', ')}` | ||||
|             : ''; | ||||
|         onChange?.(value); | ||||
|     }; | ||||
| 
 | ||||
|     const handleOperatorChange = (value: string) => { | ||||
|         setOperator(value); | ||||
|         handleOnChange(value, selectedOptions); | ||||
|     }; | ||||
| 
 | ||||
|     const handleOptionsChange = (values: typeof selectedOptions) => { | ||||
|         setSelectedOptions(values); | ||||
|         handleOnChange(operator, values); | ||||
|     }; | ||||
| 
 | ||||
|     const onDelete = () => { | ||||
|         setSelectedOptions([]); | ||||
|         handleOptionsChange([]); | ||||
|         onClose(); | ||||
|     }; | ||||
| 
 | ||||
| @ -46,19 +71,19 @@ export const FilterItem: FC<IFilterItemProps> = ({ label, options }) => { | ||||
|                 (selectedOption) => selectedOption.value === value, | ||||
|             ) | ||||
|         ) { | ||||
|             setSelectedOptions( | ||||
|                 selectedOptions?.filter( | ||||
|                     (selectedOption) => selectedOption.value !== value, | ||||
|                 ), | ||||
|             const newOptions = selectedOptions?.filter( | ||||
|                 (selectedOption) => selectedOption.value !== value, | ||||
|             ); | ||||
|             handleOptionsChange(newOptions); | ||||
|         } else { | ||||
|             setSelectedOptions([ | ||||
|             const newOptions = [ | ||||
|                 ...(selectedOptions ?? []), | ||||
|                 options.find((option) => option.value === value) ?? { | ||||
|                     label: '', | ||||
|                     value: '', | ||||
|                 }, | ||||
|             ]); | ||||
|             ]; | ||||
|             handleOptionsChange(newOptions); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
| @ -80,7 +105,7 @@ export const FilterItem: FC<IFilterItemProps> = ({ label, options }) => { | ||||
|                     onClick={onClick} | ||||
|                     operator={operator} | ||||
|                     operatorOptions={currentOperators} | ||||
|                     onChangeOperator={setOperator} | ||||
|                     onChangeOperator={handleOperatorChange} | ||||
|                 /> | ||||
|             </Box> | ||||
|             <StyledPopover | ||||
|  | ||||
| @ -176,7 +176,9 @@ export const Search = ({ | ||||
|             </StyledSearch> | ||||
| 
 | ||||
|             <ConditionallyRender | ||||
|                 condition={Boolean(hasFilters) && showSuggestions} | ||||
|                 condition={ | ||||
|                     Boolean(hasFilters && getSearchContext) && showSuggestions | ||||
|                 } | ||||
|                 show={ | ||||
|                     <SearchSuggestions | ||||
|                         onSuggestion={(suggestion) => { | ||||
|  | ||||
| @ -0,0 +1,41 @@ | ||||
| import { VFC } from 'react'; | ||||
| import { Box } from '@mui/material'; | ||||
| import { FilterItem } from 'component/common/FilterItem/FilterItem'; | ||||
| import useProjects from 'hooks/api/getters/useProjects/useProjects'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| import { useTableState } from 'hooks/useTableState'; | ||||
| 
 | ||||
| export type FeatureTogglesListFilters = { | ||||
|     projectId?: string; | ||||
| }; | ||||
| 
 | ||||
| interface IFeatureToggleFiltersProps { | ||||
|     state: FeatureTogglesListFilters; | ||||
|     onChange: (value: FeatureTogglesListFilters) => void; | ||||
| } | ||||
| 
 | ||||
| export const FeatureToggleFilters: VFC<IFeatureToggleFiltersProps> = ({ | ||||
|     state, | ||||
|     onChange, | ||||
| }) => { | ||||
|     const { projects } = useProjects(); | ||||
|     const projectsOptions = (projects || []).map((project) => ({ | ||||
|         label: project.name, | ||||
|         value: project.id, | ||||
|     })); | ||||
| 
 | ||||
|     return ( | ||||
|         <Box sx={(theme) => ({ marginBottom: theme.spacing(2) })}> | ||||
|             <ConditionallyRender | ||||
|                 condition={projectsOptions.length > 1} | ||||
|                 show={() => ( | ||||
|                     <FilterItem | ||||
|                         label='Project' | ||||
|                         options={projectsOptions} | ||||
|                         onChange={(value) => onChange({ projectId: value })} | ||||
|                     /> | ||||
|                 )} | ||||
|             /> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
| @ -1,37 +1,30 @@ | ||||
| import { useCallback, useEffect, useMemo, useState, VFC } from 'react'; | ||||
| import { | ||||
|     Box, | ||||
|     IconButton, | ||||
|     Link, | ||||
|     Tooltip, | ||||
|     useMediaQuery, | ||||
|     useTheme, | ||||
| } from '@mui/material'; | ||||
| import { Link as RouterLink, useSearchParams } from 'react-router-dom'; | ||||
| import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table'; | ||||
| import { Link as RouterLink } from 'react-router-dom'; | ||||
| import { useFlexLayout, usePagination, useSortBy, useTable } from 'react-table'; | ||||
| import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; | ||||
| import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures'; | ||||
| import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; | ||||
| import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; | ||||
| import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; | ||||
| import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell'; | ||||
| import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; | ||||
| import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| import { PageContent } from 'component/common/PageContent/PageContent'; | ||||
| import { PageHeader } from 'component/common/PageHeader/PageHeader'; | ||||
| import { createLocalStorage } from 'utils/createLocalStorage'; | ||||
| import { FeatureSchema } from 'openapi'; | ||||
| import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton'; | ||||
| 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 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell'; | ||||
| import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader'; | ||||
| import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage'; | ||||
| import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; | ||||
| import FileDownload from '@mui/icons-material/FileDownload'; | ||||
| import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; | ||||
| @ -40,8 +33,20 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||
| import { focusable } from 'themes/themeStyles'; | ||||
| import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell'; | ||||
| import useToast from 'hooks/useToast'; | ||||
| import { FilterItem } from 'component/common/FilterItem/FilterItem'; | ||||
| import { useUiFlag } from 'hooks/useUiFlag'; | ||||
| import { sortTypes } from 'utils/sortTypes'; | ||||
| import { | ||||
|     FeatureToggleFilters, | ||||
|     FeatureTogglesListFilters, | ||||
| } from './FeatureToggleFilters/FeatureToggleFilters'; | ||||
| import { | ||||
|     DEFAULT_PAGE_LIMIT, | ||||
|     useFeatureSearch, | ||||
| } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch'; | ||||
| import { | ||||
|     defaultQueryKeys, | ||||
|     defaultStoredKeys, | ||||
|     useTableState, | ||||
| } from 'hooks/useTableState'; | ||||
| 
 | ||||
| export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({ | ||||
|     name: 'Name of the feature', | ||||
| @ -55,12 +60,15 @@ export type PageQueryType = Partial< | ||||
|     Record<'sort' | 'order' | 'search' | 'favorites', string> | ||||
| >; | ||||
| 
 | ||||
| const defaultSort: SortingRule<string> = { id: 'createdAt', desc: true }; | ||||
| 
 | ||||
| const { value: storedParams, setValue: setStoredParams } = createLocalStorage( | ||||
|     'FeatureToggleListTable:v1', | ||||
|     defaultSort, | ||||
| ); | ||||
| type FeatureToggleListState = { | ||||
|     page: string; | ||||
|     pageSize: string; | ||||
|     sortBy?: string; | ||||
|     sortOrder?: string; | ||||
|     projectId?: string; | ||||
|     search?: string; | ||||
|     favorites?: string; | ||||
| } & FeatureTogglesListFilters; | ||||
| 
 | ||||
| export const FeatureToggleListTable: VFC = () => { | ||||
|     const theme = useTheme(); | ||||
| @ -71,36 +79,54 @@ export const FeatureToggleListTable: VFC = () => { | ||||
|     const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); | ||||
|     const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); | ||||
|     const [showExportDialog, setShowExportDialog] = useState(false); | ||||
|     const { features = [], loading, refetchFeatures } = useFeatures(); | ||||
|     const [searchParams, setSearchParams] = useSearchParams(); | ||||
| 
 | ||||
|     const { setToastApiError } = useToast(); | ||||
|     const { uiConfig } = useUiConfig(); | ||||
| 
 | ||||
|     const featureSearchFrontend = useUiFlag('featureSearchFrontend'); | ||||
|     const [tableState, setTableState] = useTableState<FeatureToggleListState>( | ||||
|         { | ||||
|             page: '1', | ||||
|             pageSize: `${DEFAULT_PAGE_LIMIT}`, | ||||
|             sortBy: 'createdAt', | ||||
|             sortOrder: 'desc', | ||||
|             projectId: '', | ||||
|             search: '', | ||||
|             favorites: 'true', | ||||
|         }, | ||||
|         'featureToggleList', | ||||
|         [...defaultQueryKeys, 'projectId'], | ||||
|         [...defaultStoredKeys, 'projectId'], | ||||
|     ); | ||||
|     const offset = (Number(tableState.page) - 1) * Number(tableState?.pageSize); | ||||
|     const { | ||||
|         features = [], | ||||
|         loading, | ||||
|         refetch: refetchFeatures, | ||||
|     } = useFeatureSearch( | ||||
|         offset, | ||||
|         Number(tableState.pageSize), | ||||
|         { | ||||
|             sortBy: tableState.sortBy || 'createdAt', | ||||
|             sortOrder: tableState.sortOrder || 'desc', | ||||
|             favoritesFirst: tableState.favorites === 'true', | ||||
|         }, | ||||
|         tableState.projectId || undefined, | ||||
|         tableState.search || '', | ||||
|     ); | ||||
|     const [initialState] = useState(() => ({ | ||||
|         sortBy: [ | ||||
|             { | ||||
|                 id: searchParams.get('sort') || storedParams.id, | ||||
|                 desc: searchParams.has('order') | ||||
|                     ? searchParams.get('order') === 'desc' | ||||
|                     : storedParams.desc, | ||||
|                 id: tableState.sortBy || 'createdAt', | ||||
|                 desc: tableState.sortOrder === 'desc', | ||||
|             }, | ||||
|         ], | ||||
|         hiddenColumns: ['description'], | ||||
|         globalFilter: searchParams.get('search') || '', | ||||
|         pageSize: Number(tableState.pageSize), | ||||
|         pageIndex: Number(tableState.page) - 1, | ||||
|     })); | ||||
|     const { value: globalStore, setValue: setGlobalStore } = | ||||
|         useGlobalLocalStorage(); | ||||
|     const { isFavoritesPinned, sortTypes, onChangeIsFavoritePinned } = | ||||
|         usePinnedFavorites( | ||||
|             searchParams.has('favorites') | ||||
|                 ? searchParams.get('favorites') === 'true' | ||||
|                 : globalStore.favorites, | ||||
|         ); | ||||
|     const [searchValue, setSearchValue] = useState(initialState.globalFilter); | ||||
|     const { favorite, unfavorite } = useFavoriteFeaturesApi(); | ||||
|     const onFavorite = useCallback( | ||||
|         async (feature: any) => { | ||||
|             // FIXME: projectId is missing
 | ||||
|             try { | ||||
|                 if (feature?.favorite) { | ||||
|                     await unfavorite(feature.project, feature.name); | ||||
| @ -122,8 +148,15 @@ export const FeatureToggleListTable: VFC = () => { | ||||
|             { | ||||
|                 Header: ( | ||||
|                     <FavoriteIconHeader | ||||
|                         isActive={isFavoritesPinned} | ||||
|                         onClick={onChangeIsFavoritePinned} | ||||
|                         isActive={tableState.favorites === 'true'} | ||||
|                         onClick={() => | ||||
|                             setTableState({ | ||||
|                                 favorites: | ||||
|                                     tableState.favorites === 'true' | ||||
|                                         ? 'false' | ||||
|                                         : 'true', | ||||
|                             }) | ||||
|                         } | ||||
|                     /> | ||||
|                 ), | ||||
|                 accessor: 'favorite', | ||||
| @ -194,38 +227,22 @@ export const FeatureToggleListTable: VFC = () => { | ||||
|                 Cell: FeatureStaleCell, | ||||
|                 sortType: 'boolean', | ||||
|                 maxWidth: 120, | ||||
|                 filterName: 'state', | ||||
|                 filterParsing: (value: any) => (value ? 'stale' : 'active'), | ||||
|             }, | ||||
|             // Always hidden -- for search
 | ||||
|             { | ||||
|                 accessor: 'description', | ||||
|                 Header: 'Description', | ||||
|                 searchable: true, | ||||
|             }, | ||||
|         ], | ||||
|         [isFavoritesPinned], | ||||
|         [tableState.favorites], | ||||
|     ); | ||||
| 
 | ||||
|     const { | ||||
|         data: searchedData, | ||||
|         getSearchText, | ||||
|         getSearchContext, | ||||
|     } = useSearch(columns, searchValue, features); | ||||
| 
 | ||||
|     const data = useMemo( | ||||
|         () => | ||||
|             searchedData?.length === 0 && loading | ||||
|                 ? featuresPlaceholder | ||||
|                 : searchedData, | ||||
|         [searchedData, loading], | ||||
|             features?.length === 0 && loading ? featuresPlaceholder : features, | ||||
|         [features, loading], | ||||
|     ); | ||||
| 
 | ||||
|     const { | ||||
|         headerGroups, | ||||
|         rows, | ||||
|         prepareRow, | ||||
|         state: { sortBy }, | ||||
|         state: { pageIndex, pageSize, sortBy }, | ||||
|         setHiddenColumns, | ||||
|     } = useTable( | ||||
|         { | ||||
| @ -237,11 +254,23 @@ export const FeatureToggleListTable: VFC = () => { | ||||
|             autoResetSortBy: false, | ||||
|             disableSortRemove: true, | ||||
|             disableMultiSort: true, | ||||
|             manualSortBy: true, | ||||
|             manualPagination: true, | ||||
|         }, | ||||
|         useSortBy, | ||||
|         useFlexLayout, | ||||
|         usePagination, | ||||
|     ); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         setTableState({ | ||||
|             page: `${pageIndex + 1}`, | ||||
|             pageSize: `${pageSize}`, | ||||
|             sortBy: sortBy[0]?.id || 'createdAt', | ||||
|             sortOrder: sortBy[0]?.desc ? 'desc' : 'asc', | ||||
|         }); | ||||
|     }, [pageIndex, pageSize, sortBy]); | ||||
| 
 | ||||
|     useConditionallyHiddenColumns( | ||||
|         [ | ||||
|             { | ||||
| @ -260,32 +289,7 @@ export const FeatureToggleListTable: VFC = () => { | ||||
|         setHiddenColumns, | ||||
|         columns, | ||||
|     ); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         const tableState: PageQueryType = {}; | ||||
|         tableState.sort = sortBy[0].id; | ||||
|         if (sortBy[0].desc) { | ||||
|             tableState.order = 'desc'; | ||||
|         } | ||||
|         if (searchValue) { | ||||
|             tableState.search = searchValue; | ||||
|         } | ||||
|         if (isFavoritesPinned) { | ||||
|             tableState.favorites = 'true'; | ||||
|         } | ||||
| 
 | ||||
|         setSearchParams(tableState, { | ||||
|             replace: true, | ||||
|         }); | ||||
|         setStoredParams({ | ||||
|             id: sortBy[0].id, | ||||
|             desc: sortBy[0].desc || false, | ||||
|         }); | ||||
|         setGlobalStore((params) => ({ | ||||
|             ...params, | ||||
|             favorites: Boolean(isFavoritesPinned), | ||||
|         })); | ||||
|     }, [sortBy, searchValue, setSearchParams, isFavoritesPinned]); | ||||
|     const setSearchValue = (search = '') => setTableState({ search }); | ||||
| 
 | ||||
|     if (!(environments.length > 0)) { | ||||
|         return null; | ||||
| @ -308,12 +312,10 @@ export const FeatureToggleListTable: VFC = () => { | ||||
|                                 show={ | ||||
|                                     <> | ||||
|                                         <Search | ||||
|                                             placeholder='Search and Filter' | ||||
|                                             placeholder='Search' | ||||
|                                             expandable | ||||
|                                             initialValue={searchValue} | ||||
|                                             initialValue={tableState.search} | ||||
|                                             onChange={setSearchValue} | ||||
|                                             hasFilters | ||||
|                                             getSearchContext={getSearchContext} | ||||
|                                         /> | ||||
|                                         <PageHeader.Divider /> | ||||
|                                     </> | ||||
| @ -361,38 +363,16 @@ export const FeatureToggleListTable: VFC = () => { | ||||
|                         condition={isSmallScreen} | ||||
|                         show={ | ||||
|                             <Search | ||||
|                                 initialValue={searchValue} | ||||
|                                 initialValue={tableState.search} | ||||
|                                 onChange={setSearchValue} | ||||
|                                 hasFilters | ||||
|                                 getSearchContext={getSearchContext} | ||||
|                             /> | ||||
|                         } | ||||
|                     /> | ||||
|                 </PageHeader> | ||||
|             } | ||||
|         > | ||||
|             {featureSearchFrontend && ( | ||||
|                 <Box sx={(theme) => ({ marginBottom: theme.spacing(2) })}> | ||||
|                     <FilterItem | ||||
|                         label='Project' | ||||
|                         options={[ | ||||
|                             { | ||||
|                                 label: 'Project 1', | ||||
|                                 value: '1', | ||||
|                             }, | ||||
|                             { | ||||
|                                 label: 'Test', | ||||
|                                 value: '2', | ||||
|                             }, | ||||
|                             { | ||||
|                                 label: 'Default', | ||||
|                                 value: '3', | ||||
|                             }, | ||||
|                         ]} | ||||
|                     /> | ||||
|                 </Box> | ||||
|             )} | ||||
|             <SearchHighlightProvider value={getSearchText(searchValue)}> | ||||
|             <FeatureToggleFilters state={tableState} onChange={setTableState} /> | ||||
|             <SearchHighlightProvider value={tableState.search || ''}> | ||||
|                 <VirtualizedTable | ||||
|                     rows={rows} | ||||
|                     headerGroups={headerGroups} | ||||
| @ -403,11 +383,11 @@ export const FeatureToggleListTable: VFC = () => { | ||||
|                 condition={rows.length === 0} | ||||
|                 show={ | ||||
|                     <ConditionallyRender | ||||
|                         condition={searchValue?.length > 0} | ||||
|                         condition={(tableState.search || '')?.length > 0} | ||||
|                         show={ | ||||
|                             <TablePlaceholder> | ||||
|                                 No feature toggles found matching “ | ||||
|                                 {searchValue} | ||||
|                                 {tableState.search} | ||||
|                                 ” | ||||
|                             </TablePlaceholder> | ||||
|                         } | ||||
|  | ||||
| @ -0,0 +1,414 @@ | ||||
| import { useCallback, useEffect, useMemo, useState, VFC } from 'react'; | ||||
| import { | ||||
|     Box, | ||||
|     IconButton, | ||||
|     Link, | ||||
|     Tooltip, | ||||
|     useMediaQuery, | ||||
|     useTheme, | ||||
| } from '@mui/material'; | ||||
| import { Link as RouterLink, useSearchParams } from 'react-router-dom'; | ||||
| import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table'; | ||||
| import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; | ||||
| import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures'; | ||||
| import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; | ||||
| import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; | ||||
| import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; | ||||
| import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; | ||||
| import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| import { PageContent } from 'component/common/PageContent/PageContent'; | ||||
| import { PageHeader } from 'component/common/PageHeader/PageHeader'; | ||||
| import { createLocalStorage } from 'utils/createLocalStorage'; | ||||
| import { FeatureSchema } from 'openapi'; | ||||
| import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton'; | ||||
| 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 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell'; | ||||
| import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader'; | ||||
| import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage'; | ||||
| import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; | ||||
| import FileDownload from '@mui/icons-material/FileDownload'; | ||||
| import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; | ||||
| import { ExportDialog } from './ExportDialog'; | ||||
| import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||
| import { focusable } from 'themes/themeStyles'; | ||||
| import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell'; | ||||
| import useToast from 'hooks/useToast'; | ||||
| 
 | ||||
| export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({ | ||||
|     name: 'Name of the feature', | ||||
|     description: 'Short description of the feature', | ||||
|     type: '-', | ||||
|     createdAt: new Date(2022, 1, 1), | ||||
|     project: 'projectID', | ||||
| }); | ||||
| 
 | ||||
| export type PageQueryType = Partial< | ||||
|     Record<'sort' | 'order' | 'search' | 'favorites', string> | ||||
| >; | ||||
| 
 | ||||
| const defaultSort: SortingRule<string> = { id: 'createdAt', desc: true }; | ||||
| 
 | ||||
| const { value: storedParams, setValue: setStoredParams } = createLocalStorage( | ||||
|     'FeatureToggleListTable:v1', | ||||
|     defaultSort, | ||||
| ); | ||||
| 
 | ||||
| /** | ||||
|  * @deprecated remove with flag `featureSearchFrontend` | ||||
|  */ | ||||
| export const FeatureToggleListTable: VFC = () => { | ||||
|     const theme = useTheme(); | ||||
|     const { environments } = useEnvironments(); | ||||
|     const enabledEnvironments = environments | ||||
|         .filter((env) => env.enabled) | ||||
|         .map((env) => env.name); | ||||
|     const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); | ||||
|     const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); | ||||
|     const [showExportDialog, setShowExportDialog] = useState(false); | ||||
|     const { features = [], loading, refetchFeatures } = useFeatures(); | ||||
|     const [searchParams, setSearchParams] = useSearchParams(); | ||||
|     const { setToastApiError } = useToast(); | ||||
|     const { uiConfig } = useUiConfig(); | ||||
| 
 | ||||
|     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 { value: globalStore, setValue: setGlobalStore } = | ||||
|         useGlobalLocalStorage(); | ||||
|     const { isFavoritesPinned, sortTypes, onChangeIsFavoritePinned } = | ||||
|         usePinnedFavorites( | ||||
|             searchParams.has('favorites') | ||||
|                 ? searchParams.get('favorites') === 'true' | ||||
|                 : globalStore.favorites, | ||||
|         ); | ||||
|     const [searchValue, setSearchValue] = useState(initialState.globalFilter); | ||||
|     const { favorite, unfavorite } = useFavoriteFeaturesApi(); | ||||
|     const onFavorite = useCallback( | ||||
|         async (feature: any) => { | ||||
|             try { | ||||
|                 if (feature?.favorite) { | ||||
|                     await unfavorite(feature.project, feature.name); | ||||
|                 } else { | ||||
|                     await favorite(feature.project, feature.name); | ||||
|                 } | ||||
|                 refetchFeatures(); | ||||
|             } catch (error) { | ||||
|                 setToastApiError( | ||||
|                     'Something went wrong, could not update favorite', | ||||
|                 ); | ||||
|             } | ||||
|         }, | ||||
|         [favorite, refetchFeatures, unfavorite, setToastApiError], | ||||
|     ); | ||||
| 
 | ||||
|     const columns = useMemo( | ||||
|         () => [ | ||||
|             { | ||||
|                 Header: ( | ||||
|                     <FavoriteIconHeader | ||||
|                         isActive={isFavoritesPinned} | ||||
|                         onClick={onChangeIsFavoritePinned} | ||||
|                     /> | ||||
|                 ), | ||||
|                 accessor: 'favorite', | ||||
|                 Cell: ({ row: { original: feature } }: any) => ( | ||||
|                     <FavoriteIconCell | ||||
|                         value={feature?.favorite} | ||||
|                         onClick={() => onFavorite(feature)} | ||||
|                     /> | ||||
|                 ), | ||||
|                 maxWidth: 50, | ||||
|                 disableSortBy: true, | ||||
|             }, | ||||
|             { | ||||
|                 Header: 'Seen', | ||||
|                 accessor: 'lastSeenAt', | ||||
|                 Cell: ({ value, row: { original: feature } }: any) => { | ||||
|                     return <FeatureEnvironmentSeenCell feature={feature} />; | ||||
|                 }, | ||||
|                 align: 'center', | ||||
|                 maxWidth: 80, | ||||
|             }, | ||||
|             { | ||||
|                 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, | ||||
|                 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', | ||||
|                 Header: 'Description', | ||||
|                 searchable: true, | ||||
|             }, | ||||
|         ], | ||||
|         [isFavoritesPinned], | ||||
|     ); | ||||
| 
 | ||||
|     const { | ||||
|         data: searchedData, | ||||
|         getSearchText, | ||||
|         getSearchContext, | ||||
|     } = useSearch(columns, searchValue, features); | ||||
| 
 | ||||
|     const data = useMemo( | ||||
|         () => | ||||
|             searchedData?.length === 0 && loading | ||||
|                 ? featuresPlaceholder | ||||
|                 : searchedData, | ||||
|         [searchedData, loading], | ||||
|     ); | ||||
| 
 | ||||
|     const { | ||||
|         headerGroups, | ||||
|         rows, | ||||
|         prepareRow, | ||||
|         state: { sortBy }, | ||||
|         setHiddenColumns, | ||||
|     } = useTable( | ||||
|         { | ||||
|             columns: columns as any[], | ||||
|             data, | ||||
|             initialState, | ||||
|             sortTypes, | ||||
|             autoResetHiddenColumns: false, | ||||
|             autoResetSortBy: false, | ||||
|             disableSortRemove: true, | ||||
|             disableMultiSort: true, | ||||
|         }, | ||||
|         useSortBy, | ||||
|         useFlexLayout, | ||||
|     ); | ||||
| 
 | ||||
|     useConditionallyHiddenColumns( | ||||
|         [ | ||||
|             { | ||||
|                 condition: !features.some(({ tags }) => tags?.length), | ||||
|                 columns: ['tags'], | ||||
|             }, | ||||
|             { | ||||
|                 condition: isSmallScreen, | ||||
|                 columns: ['type', 'createdAt', 'tags'], | ||||
|             }, | ||||
|             { | ||||
|                 condition: isMediumScreen, | ||||
|                 columns: ['lastSeenAt', 'stale'], | ||||
|             }, | ||||
|         ], | ||||
|         setHiddenColumns, | ||||
|         columns, | ||||
|     ); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         const tableState: PageQueryType = {}; | ||||
|         tableState.sort = sortBy[0].id; | ||||
|         if (sortBy[0].desc) { | ||||
|             tableState.order = 'desc'; | ||||
|         } | ||||
|         if (searchValue) { | ||||
|             tableState.search = searchValue; | ||||
|         } | ||||
|         if (isFavoritesPinned) { | ||||
|             tableState.favorites = 'true'; | ||||
|         } | ||||
| 
 | ||||
|         setSearchParams(tableState, { | ||||
|             replace: true, | ||||
|         }); | ||||
|         setStoredParams({ | ||||
|             id: sortBy[0].id, | ||||
|             desc: sortBy[0].desc || false, | ||||
|         }); | ||||
|         setGlobalStore((params) => ({ | ||||
|             ...params, | ||||
|             favorites: Boolean(isFavoritesPinned), | ||||
|         })); | ||||
|     }, [sortBy, searchValue, setSearchParams, isFavoritesPinned]); | ||||
| 
 | ||||
|     if (!(environments.length > 0)) { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <PageContent | ||||
|             isLoading={loading} | ||||
|             header={ | ||||
|                 <PageHeader | ||||
|                     title={`Feature toggles (${ | ||||
|                         rows.length < data.length | ||||
|                             ? `${rows.length} of ${data.length}` | ||||
|                             : data.length | ||||
|                     })`}
 | ||||
|                     actions={ | ||||
|                         <> | ||||
|                             <ConditionallyRender | ||||
|                                 condition={!isSmallScreen} | ||||
|                                 show={ | ||||
|                                     <> | ||||
|                                         <Search | ||||
|                                             placeholder='Search and Filter' | ||||
|                                             expandable | ||||
|                                             initialValue={searchValue} | ||||
|                                             onChange={setSearchValue} | ||||
|                                             hasFilters | ||||
|                                             getSearchContext={getSearchContext} | ||||
|                                         /> | ||||
|                                         <PageHeader.Divider /> | ||||
|                                     </> | ||||
|                                 } | ||||
|                             /> | ||||
|                             <Link | ||||
|                                 component={RouterLink} | ||||
|                                 to='/archive' | ||||
|                                 underline='always' | ||||
|                                 sx={{ marginRight: 2, ...focusable(theme) }} | ||||
|                             > | ||||
|                                 View archive | ||||
|                             </Link> | ||||
|                             <ConditionallyRender | ||||
|                                 condition={Boolean( | ||||
|                                     uiConfig?.flags?.featuresExportImport, | ||||
|                                 )} | ||||
|                                 show={ | ||||
|                                     <Tooltip | ||||
|                                         title='Export current selection' | ||||
|                                         arrow | ||||
|                                     > | ||||
|                                         <IconButton | ||||
|                                             onClick={() => | ||||
|                                                 setShowExportDialog(true) | ||||
|                                             } | ||||
|                                             sx={(theme) => ({ | ||||
|                                                 marginRight: theme.spacing(2), | ||||
|                                             })} | ||||
|                                         > | ||||
|                                             <FileDownload /> | ||||
|                                         </IconButton> | ||||
|                                     </Tooltip> | ||||
|                                 } | ||||
|                             /> | ||||
| 
 | ||||
|                             <CreateFeatureButton | ||||
|                                 loading={false} | ||||
|                                 filter={{ query: '', project: 'default' }} | ||||
|                             /> | ||||
|                         </> | ||||
|                     } | ||||
|                 > | ||||
|                     <ConditionallyRender | ||||
|                         condition={isSmallScreen} | ||||
|                         show={ | ||||
|                             <Search | ||||
|                                 initialValue={searchValue} | ||||
|                                 onChange={setSearchValue} | ||||
|                                 hasFilters | ||||
|                                 getSearchContext={getSearchContext} | ||||
|                             /> | ||||
|                         } | ||||
|                     /> | ||||
|                 </PageHeader> | ||||
|             } | ||||
|         > | ||||
|             <SearchHighlightProvider value={getSearchText(searchValue)}> | ||||
|                 <VirtualizedTable | ||||
|                     rows={rows} | ||||
|                     headerGroups={headerGroups} | ||||
|                     prepareRow={prepareRow} | ||||
|                 /> | ||||
|             </SearchHighlightProvider> | ||||
|             <ConditionallyRender | ||||
|                 condition={rows.length === 0} | ||||
|                 show={ | ||||
|                     <ConditionallyRender | ||||
|                         condition={searchValue?.length > 0} | ||||
|                         show={ | ||||
|                             <TablePlaceholder> | ||||
|                                 No feature toggles found matching “ | ||||
|                                 {searchValue} | ||||
|                                 ” | ||||
|                             </TablePlaceholder> | ||||
|                         } | ||||
|                         elseShow={ | ||||
|                             <TablePlaceholder> | ||||
|                                 No feature toggles available. Get started by | ||||
|                                 adding a new feature toggle. | ||||
|                             </TablePlaceholder> | ||||
|                         } | ||||
|                     /> | ||||
|                 } | ||||
|             /> | ||||
|             <ConditionallyRender | ||||
|                 condition={Boolean(uiConfig?.flags?.featuresExportImport)} | ||||
|                 show={ | ||||
|                     <ExportDialog | ||||
|                         showExportDialog={showExportDialog} | ||||
|                         data={data} | ||||
|                         onClose={() => setShowExportDialog(false)} | ||||
|                         environments={enabledEnvironments} | ||||
|                     /> | ||||
|                 } | ||||
|             /> | ||||
|         </PageContent> | ||||
|     ); | ||||
| }; | ||||
| @ -115,6 +115,7 @@ const StyledIconButton = styled(IconButton)<{ | ||||
| })); | ||||
| 
 | ||||
| const Header: VFC = () => { | ||||
|     const featureSearchFrontend = useUiFlag('featureSearchFrontend'); | ||||
|     const { onSetThemeMode, themeMode } = useThemeMode(); | ||||
|     const theme = useTheme(); | ||||
|     const adminId = useId(); | ||||
| @ -191,7 +192,15 @@ const Header: VFC = () => { | ||||
|                 <StyledNav> | ||||
|                     <StyledLinks> | ||||
|                         <StyledLink to='/projects'>Projects</StyledLink> | ||||
|                         <StyledLink to='/features'>Feature toggles</StyledLink> | ||||
|                         <StyledLink | ||||
|                             to={ | ||||
|                                 featureSearchFrontend | ||||
|                                     ? '/features-new' | ||||
|                                     : '/features' | ||||
|                             } | ||||
|                         > | ||||
|                             Feature toggles | ||||
|                         </StyledLink> | ||||
|                         <StyledLink to='/playground'>Playground</StyledLink> | ||||
|                         <StyledAdvancedNavButton | ||||
|                             onClick={(e) => setConfigRef(e.currentTarget)} | ||||
|  | ||||
| @ -123,6 +123,16 @@ exports[`returns all baseRoutes 1`] = ` | ||||
|     "title": "Feature toggles", | ||||
|     "type": "protected", | ||||
|   }, | ||||
|   { | ||||
|     "component": [Function], | ||||
|     "flag": "featureSearchFrontend", | ||||
|     "menu": { | ||||
|       "mobile": true, | ||||
|     }, | ||||
|     "path": "/features-new", | ||||
|     "title": "Feature toggles", | ||||
|     "type": "protected", | ||||
|   }, | ||||
|   { | ||||
|     "component": { | ||||
|       "$$typeof": Symbol(react.lazy), | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| import { FeatureToggleListTable } from 'component/feature/FeatureToggleList/FeatureToggleListTable'; | ||||
| import { FeatureToggleListTable as LegacyFeatureToggleListTable } from 'component/feature/FeatureToggleList/LegacyFeatureToggleListTable'; | ||||
| import { StrategyView } from 'component/strategies/StrategyView/StrategyView'; | ||||
| import { StrategiesList } from 'component/strategies/StrategiesList/StrategiesList'; | ||||
| import { TagTypeList } from 'component/tags/TagTypeList/TagTypeList'; | ||||
| @ -144,9 +145,17 @@ export const routes: IRoute[] = [ | ||||
|     { | ||||
|         path: '/features', | ||||
|         title: 'Feature toggles', | ||||
|         component: LegacyFeatureToggleListTable, | ||||
|         type: 'protected', | ||||
|         menu: { mobile: true }, | ||||
|     }, | ||||
|     { | ||||
|         path: '/features-new', | ||||
|         title: 'Feature toggles', | ||||
|         component: FeatureToggleListTable, | ||||
|         type: 'protected', | ||||
|         menu: { mobile: true }, | ||||
|         flag: 'featureSearchFrontend', | ||||
|     }, | ||||
| 
 | ||||
|     // Playground
 | ||||
|  | ||||
| @ -128,7 +128,6 @@ const getFeatureSearchFetcher = ( | ||||
|     const searchQueryParams = translateToQueryParams(searchValue); | ||||
|     const sortQueryParams = translateToSortQueryParams(sortingRules); | ||||
|     const project = projectId ? `projectId=${projectId}&` : ''; | ||||
| 
 | ||||
|     const KEY = `api/admin/search/features?${project}offset=${offset}&limit=${limit}&${searchQueryParams}&${sortQueryParams}`; | ||||
|     const fetcher = () => { | ||||
|         const path = formatApiPath(KEY); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user