mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	refactor: improve feature toggle search state (#741)
* refactor: rename createPersistentGlobalStateHook helper * refactor: move features filter state out of localStorage * refactor: show search state in page title * refactor: remove unused import * refactor: add a state chip to SearchField * refactor: improve var names
This commit is contained in:
		
							parent
							
								
									38c26ec052
								
							
						
					
					
						commit
						94ecaa80a8
					
				| @ -1,9 +1,8 @@ | ||||
| import { useMemo, useState } from 'react'; | ||||
| import { CircularProgress } from '@material-ui/core'; | ||||
| import { Warning } from '@material-ui/icons'; | ||||
| 
 | ||||
| import { AppsLinkList, styles as commonStyles } from '../../common'; | ||||
| import SearchField from '../../common/SearchField/SearchField'; | ||||
| import { SearchField } from 'component/common/SearchField/SearchField'; | ||||
| import PageContent from '../../common/PageContent/PageContent'; | ||||
| import HeaderTitle from '../../common/HeaderTitle'; | ||||
| import useApplications from '../../../hooks/api/getters/useApplications/useApplications'; | ||||
|  | ||||
| @ -1,59 +0,0 @@ | ||||
| import React, { useState } from 'react'; | ||||
| import classnames from 'classnames'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { debounce } from 'debounce'; | ||||
| import { InputBase } from '@material-ui/core'; | ||||
| import SearchIcon from '@material-ui/icons/Search'; | ||||
| 
 | ||||
| import { useStyles } from './styles'; | ||||
| 
 | ||||
| function SearchField({ initialValue = '', updateValue, className = '' }) { | ||||
|     const styles = useStyles(); | ||||
| 
 | ||||
|     const [localValue, setLocalValue] = useState(initialValue); | ||||
|     const debounceUpdateValue = debounce(updateValue, 500); | ||||
| 
 | ||||
|     const handleChange = e => { | ||||
|         e.preventDefault(); | ||||
|         const v = e.target.value || ''; | ||||
|         setLocalValue(v); | ||||
|         debounceUpdateValue(v); | ||||
|     }; | ||||
| 
 | ||||
|     const handleKeyPress = e => { | ||||
|         if (e.key === 'Enter') { | ||||
|             updateValue(localValue); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const updateNow = () => { | ||||
|         updateValue(localValue); | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <div> | ||||
|             <div className={classnames(styles.search, className)}> | ||||
|                 <SearchIcon className={styles.searchIcon} /> | ||||
|                 <InputBase | ||||
|                     placeholder="Search…" | ||||
|                     classes={{ | ||||
|                         root: styles.inputRoot, | ||||
|                         input: styles.input, | ||||
|                     }} | ||||
|                     inputProps={{ 'aria-label': 'search' }} | ||||
|                     value={localValue} | ||||
|                     onChange={handleChange} | ||||
|                     onBlur={updateNow} | ||||
|                     onKeyPress={handleKeyPress} | ||||
|                 /> | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| SearchField.propTypes = { | ||||
|     value: PropTypes.string, | ||||
|     updateValue: PropTypes.func.isRequired, | ||||
| }; | ||||
| 
 | ||||
| export default SearchField; | ||||
							
								
								
									
										74
									
								
								frontend/src/component/common/SearchField/SearchField.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								frontend/src/component/common/SearchField/SearchField.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,74 @@ | ||||
| import React, { useState } from 'react'; | ||||
| import classnames from 'classnames'; | ||||
| import { debounce } from 'debounce'; | ||||
| import { InputBase, Chip } from '@material-ui/core'; | ||||
| import SearchIcon from '@material-ui/icons/Search'; | ||||
| import { useStyles } from './styles'; | ||||
| import ConditionallyRender from 'component/common/ConditionallyRender'; | ||||
| 
 | ||||
| interface ISearchFieldProps { | ||||
|     updateValue: React.Dispatch<React.SetStateAction<string>>; | ||||
|     initialValue?: string; | ||||
|     className?: string; | ||||
|     showValueChip?: boolean; | ||||
| } | ||||
| 
 | ||||
| export const SearchField = ({ | ||||
|     updateValue, | ||||
|     initialValue = '', | ||||
|     className = '', | ||||
|     showValueChip, | ||||
| }: ISearchFieldProps) => { | ||||
|     const styles = useStyles(); | ||||
|     const [localValue, setLocalValue] = useState(initialValue); | ||||
|     const debounceUpdateValue = debounce(updateValue, 500); | ||||
| 
 | ||||
|     const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|         event.preventDefault(); | ||||
|         const value = event.target.value || ''; | ||||
|         setLocalValue(value); | ||||
|         debounceUpdateValue(value); | ||||
|     }; | ||||
| 
 | ||||
|     const handleKeyPress = (event: React.KeyboardEvent) => { | ||||
|         if (event.key === 'Enter') { | ||||
|             updateValue(localValue); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const updateNow = () => { | ||||
|         updateValue(localValue); | ||||
|     }; | ||||
| 
 | ||||
|     const onDelete = () => { | ||||
|         setLocalValue(''); | ||||
|         updateValue(''); | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <div className={styles.container}> | ||||
|             <div className={classnames(styles.search, className)}> | ||||
|                 <SearchIcon className={styles.searchIcon} /> | ||||
|                 <InputBase | ||||
|                     placeholder="Search..." | ||||
|                     classes={{ root: styles.inputRoot }} | ||||
|                     inputProps={{ 'aria-label': 'search' }} | ||||
|                     value={localValue} | ||||
|                     onChange={handleChange} | ||||
|                     onBlur={updateNow} | ||||
|                     onKeyPress={handleKeyPress} | ||||
|                 /> | ||||
|             </div> | ||||
|             <ConditionallyRender | ||||
|                 condition={Boolean(showValueChip && localValue)} | ||||
|                 show={ | ||||
|                     <Chip | ||||
|                         label={localValue} | ||||
|                         onDelete={onDelete} | ||||
|                         title="Clear search query" | ||||
|                     /> | ||||
|                 } | ||||
|             /> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @ -1,6 +1,12 @@ | ||||
| import { makeStyles } from '@material-ui/styles'; | ||||
| 
 | ||||
| export const useStyles = makeStyles(theme => ({ | ||||
|     container: { | ||||
|         display: 'flex', | ||||
|         alignItems: 'center', | ||||
|         flexWrap: 'wrap', | ||||
|         gap: '1rem', | ||||
|     }, | ||||
|     search: { | ||||
|         display: 'flex', | ||||
|         alignItems: 'center', | ||||
| @ -8,9 +14,6 @@ export const useStyles = makeStyles(theme => ({ | ||||
|         borderRadius: '25px', | ||||
|         padding: '0.25rem 0.5rem', | ||||
|         maxWidth: '450px', | ||||
|         [theme.breakpoints.down('sm')]: { | ||||
|             margin: '0 auto', | ||||
|         }, | ||||
|         [theme.breakpoints.down('xs')]: { | ||||
|             width: '100%', | ||||
|         }, | ||||
|  | ||||
| @ -5,20 +5,15 @@ import { Link } from 'react-router-dom'; | ||||
| import { Button, IconButton, List, ListItem, Tooltip } from '@material-ui/core'; | ||||
| import useMediaQuery from '@material-ui/core/useMediaQuery'; | ||||
| import { Add } from '@material-ui/icons'; | ||||
| 
 | ||||
| import FeatureToggleListItem from './FeatureToggleListItem'; | ||||
| import SearchField from '../../common/SearchField/SearchField'; | ||||
| import { SearchField } from '../../common/SearchField/SearchField'; | ||||
| import FeatureToggleListActions from './FeatureToggleListActions'; | ||||
| import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender'; | ||||
| import PageContent from '../../common/PageContent/PageContent'; | ||||
| import HeaderTitle from '../../common/HeaderTitle'; | ||||
| 
 | ||||
| import loadingFeatures from './loadingFeatures'; | ||||
| 
 | ||||
| import { CREATE_FEATURE } from '../../providers/AccessProvider/permissions'; | ||||
| 
 | ||||
| import AccessContext from '../../../contexts/AccessContext'; | ||||
| 
 | ||||
| import { useStyles } from './styles'; | ||||
| import ListPlaceholder from '../../common/ListPlaceholder/ListPlaceholder'; | ||||
| import { getCreateTogglePath } from '../../../utils/route-path-helpers'; | ||||
| @ -101,7 +96,11 @@ const FeatureToggleList = ({ | ||||
|         ); | ||||
|     }; | ||||
| 
 | ||||
|     const headerTitle = archive ? 'Archived Features' : 'Features'; | ||||
|     const headerTitle = filter.query | ||||
|         ? 'Search results' | ||||
|         : archive | ||||
|         ? 'Archived Features' | ||||
|         : 'Features'; | ||||
| 
 | ||||
|     return ( | ||||
|         <div className={styles.featureContainer}> | ||||
| @ -109,6 +108,7 @@ const FeatureToggleList = ({ | ||||
|                 <SearchField | ||||
|                     initialValue={filter.query} | ||||
|                     updateValue={setFilterQuery} | ||||
|                     showValueChip={!mobileView} | ||||
|                     className={classnames(styles.searchBar, { | ||||
|                         skeleton: loading, | ||||
|                     })} | ||||
|  | ||||
| @ -5,13 +5,15 @@ exports[`renders correctly with one feature 1`] = ` | ||||
|   <div | ||||
|     className="makeStyles-searchBarContainer-3" | ||||
|   > | ||||
|     <div> | ||||
|     <div | ||||
|       className="makeStyles-container-6" | ||||
|     > | ||||
|       <div | ||||
|         className="makeStyles-search-6 makeStyles-searchBar-4" | ||||
|         className="makeStyles-search-7 makeStyles-searchBar-4" | ||||
|       > | ||||
|         <svg | ||||
|           aria-hidden={true} | ||||
|           className="MuiSvgIcon-root makeStyles-searchIcon-7" | ||||
|           className="MuiSvgIcon-root makeStyles-searchIcon-8" | ||||
|           focusable="false" | ||||
|           viewBox="0 0 24 24" | ||||
|         > | ||||
| @ -20,7 +22,7 @@ exports[`renders correctly with one feature 1`] = ` | ||||
|           /> | ||||
|         </svg> | ||||
|         <div | ||||
|           className="MuiInputBase-root makeStyles-inputRoot-8" | ||||
|           className="MuiInputBase-root makeStyles-inputRoot-9" | ||||
|           onClick={[Function]} | ||||
|           onKeyPress={[Function]} | ||||
|         > | ||||
| @ -31,7 +33,7 @@ exports[`renders correctly with one feature 1`] = ` | ||||
|             onBlur={[Function]} | ||||
|             onChange={[Function]} | ||||
|             onFocus={[Function]} | ||||
|             placeholder="Search…" | ||||
|             placeholder="Search..." | ||||
|             type="text" | ||||
|             value="" | ||||
|           /> | ||||
| @ -55,29 +57,29 @@ exports[`renders correctly with one feature 1`] = ` | ||||
|     } | ||||
|   > | ||||
|     <div | ||||
|       className="makeStyles-headerContainer-9" | ||||
|       className="makeStyles-headerContainer-10" | ||||
|     > | ||||
|       <div | ||||
|         className="makeStyles-headerTitleContainer-13" | ||||
|         className="makeStyles-headerTitleContainer-14" | ||||
|       > | ||||
|         <div | ||||
|           className="" | ||||
|           data-loading={true} | ||||
|         > | ||||
|           <h2 | ||||
|             className="MuiTypography-root makeStyles-headerTitle-14 MuiTypography-h2" | ||||
|             className="MuiTypography-root makeStyles-headerTitle-15 MuiTypography-h2" | ||||
|           > | ||||
|             Features | ||||
|           </h2> | ||||
|         </div> | ||||
|         <div | ||||
|           className="makeStyles-headerActions-15" | ||||
|           className="makeStyles-headerActions-16" | ||||
|         > | ||||
|           <div | ||||
|             className="makeStyles-actionsContainer-1" | ||||
|           > | ||||
|             <div | ||||
|               className="makeStyles-actions-16" | ||||
|               className="makeStyles-actions-17" | ||||
|             > | ||||
|               <p | ||||
|                 className="MuiTypography-root MuiTypography-body2" | ||||
| @ -171,7 +173,7 @@ exports[`renders correctly with one feature 1`] = ` | ||||
|       </div> | ||||
|     </div> | ||||
|     <div | ||||
|       className="makeStyles-bodyContainer-10" | ||||
|       className="makeStyles-bodyContainer-11" | ||||
|     > | ||||
|       <ul | ||||
|         className="MuiList-root MuiList-padding" | ||||
| @ -197,13 +199,15 @@ exports[`renders correctly with one feature without permissions 1`] = ` | ||||
|   <div | ||||
|     className="makeStyles-searchBarContainer-3" | ||||
|   > | ||||
|     <div> | ||||
|     <div | ||||
|       className="makeStyles-container-6" | ||||
|     > | ||||
|       <div | ||||
|         className="makeStyles-search-6 makeStyles-searchBar-4" | ||||
|         className="makeStyles-search-7 makeStyles-searchBar-4" | ||||
|       > | ||||
|         <svg | ||||
|           aria-hidden={true} | ||||
|           className="MuiSvgIcon-root makeStyles-searchIcon-7" | ||||
|           className="MuiSvgIcon-root makeStyles-searchIcon-8" | ||||
|           focusable="false" | ||||
|           viewBox="0 0 24 24" | ||||
|         > | ||||
| @ -212,7 +216,7 @@ exports[`renders correctly with one feature without permissions 1`] = ` | ||||
|           /> | ||||
|         </svg> | ||||
|         <div | ||||
|           className="MuiInputBase-root makeStyles-inputRoot-8" | ||||
|           className="MuiInputBase-root makeStyles-inputRoot-9" | ||||
|           onClick={[Function]} | ||||
|           onKeyPress={[Function]} | ||||
|         > | ||||
| @ -223,7 +227,7 @@ exports[`renders correctly with one feature without permissions 1`] = ` | ||||
|             onBlur={[Function]} | ||||
|             onChange={[Function]} | ||||
|             onFocus={[Function]} | ||||
|             placeholder="Search…" | ||||
|             placeholder="Search..." | ||||
|             type="text" | ||||
|             value="" | ||||
|           /> | ||||
| @ -247,29 +251,29 @@ exports[`renders correctly with one feature without permissions 1`] = ` | ||||
|     } | ||||
|   > | ||||
|     <div | ||||
|       className="makeStyles-headerContainer-9" | ||||
|       className="makeStyles-headerContainer-10" | ||||
|     > | ||||
|       <div | ||||
|         className="makeStyles-headerTitleContainer-13" | ||||
|         className="makeStyles-headerTitleContainer-14" | ||||
|       > | ||||
|         <div | ||||
|           className="" | ||||
|           data-loading={true} | ||||
|         > | ||||
|           <h2 | ||||
|             className="MuiTypography-root makeStyles-headerTitle-14 MuiTypography-h2" | ||||
|             className="MuiTypography-root makeStyles-headerTitle-15 MuiTypography-h2" | ||||
|           > | ||||
|             Features | ||||
|           </h2> | ||||
|         </div> | ||||
|         <div | ||||
|           className="makeStyles-headerActions-15" | ||||
|           className="makeStyles-headerActions-16" | ||||
|         > | ||||
|           <div | ||||
|             className="makeStyles-actionsContainer-1" | ||||
|           > | ||||
|             <div | ||||
|               className="makeStyles-actions-16" | ||||
|               className="makeStyles-actions-17" | ||||
|             > | ||||
|               <p | ||||
|                 className="MuiTypography-root MuiTypography-body2" | ||||
| @ -366,7 +370,7 @@ exports[`renders correctly with one feature without permissions 1`] = ` | ||||
|       </div> | ||||
|     </div> | ||||
|     <div | ||||
|       className="makeStyles-bodyContainer-10" | ||||
|       className="makeStyles-bodyContainer-11" | ||||
|     > | ||||
|       <ul | ||||
|         className="MuiList-root MuiList-padding" | ||||
|  | ||||
| @ -11,6 +11,7 @@ export const useStyles = makeStyles(theme => ({ | ||||
|     searchBarContainer: { | ||||
|         marginBottom: '2rem', | ||||
|         display: 'flex', | ||||
|         gap: '1rem', | ||||
|         justifyContent: 'space-between', | ||||
|         alignItems: 'center', | ||||
|         [theme.breakpoints.down('xs')]: { | ||||
|  | ||||
| @ -15,7 +15,6 @@ import { Tooltip } from '@material-ui/core'; | ||||
| import ConditionallyRender from '../../../../../common/ConditionallyRender'; | ||||
| import { useStyles } from './FeatureStrategyEditable.styles'; | ||||
| import { Delete } from '@material-ui/icons'; | ||||
| import { PRODUCTION } from '../../../../../../constants/environmentTypes'; | ||||
| import { | ||||
|     DELETE_STRATEGY_ID, | ||||
|     STRATEGY_ACCORDION_ID, | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { getBasePath } from '../utils/format-path'; | ||||
| import { createPersistentGlobalState } from './usePersistentGlobalState'; | ||||
| import { createPersistentGlobalStateHook } from './usePersistentGlobalState'; | ||||
| import React from 'react'; | ||||
| 
 | ||||
| export interface IEventSettings { | ||||
| @ -21,7 +21,7 @@ const createInitialValue = (): IEventSettings => { | ||||
|     return { showData: false }; | ||||
| }; | ||||
| 
 | ||||
| const useGlobalState = createPersistentGlobalState<IEventSettings>( | ||||
| const useGlobalState = createPersistentGlobalStateHook<IEventSettings>( | ||||
|     `${getBasePath()}:useEventSettings:v1`, | ||||
|     createInitialValue() | ||||
| ); | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| import { IFeatureToggle } from '../interfaces/featureToggle'; | ||||
| import { IFeatureToggle } from 'interfaces/featureToggle'; | ||||
| import React, { useMemo } from 'react'; | ||||
| import { getBasePath } from '../utils/format-path'; | ||||
| import { createPersistentGlobalState } from './usePersistentGlobalState'; | ||||
| import { createGlobalStateHook } from 'hooks/useGlobalState'; | ||||
| 
 | ||||
| export interface IFeaturesFilter { | ||||
|     query?: string; | ||||
| @ -16,8 +15,8 @@ export interface IFeaturesSortOutput { | ||||
| 
 | ||||
| // Store the features filter state globally, and in localStorage.
 | ||||
| // When changing the format of IFeaturesFilter, change the version as well.
 | ||||
| const useFeaturesFilterState = createPersistentGlobalState<IFeaturesFilter>( | ||||
|     `${getBasePath()}:useFeaturesFilter:v1`, | ||||
| const useFeaturesFilterState = createGlobalStateHook<IFeaturesFilter>( | ||||
|     'useFeaturesFilterState', | ||||
|     { project: '*' } | ||||
| ); | ||||
| 
 | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| import { IFeatureToggle } from '../interfaces/featureToggle'; | ||||
| import React, { useMemo } from 'react'; | ||||
| import { getBasePath } from '../utils/format-path'; | ||||
| import { createPersistentGlobalState } from './usePersistentGlobalState'; | ||||
| import { createPersistentGlobalStateHook } from './usePersistentGlobalState'; | ||||
| 
 | ||||
| type FeaturesSortType = | ||||
|     | 'name' | ||||
| @ -29,7 +29,7 @@ export interface IFeaturesFilterSortOption { | ||||
| 
 | ||||
| // Store the features sort state globally, and in localStorage.
 | ||||
| // When changing the format of IFeaturesSort, change the version as well.
 | ||||
| const useFeaturesSortState = createPersistentGlobalState<IFeaturesSort>( | ||||
| const useFeaturesSortState = createPersistentGlobalStateHook<IFeaturesSort>( | ||||
|     `${getBasePath()}:useFeaturesSort:v1`, | ||||
|     { type: 'name' } | ||||
| ); | ||||
|  | ||||
							
								
								
									
										23
									
								
								frontend/src/hooks/useGlobalState.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/src/hooks/useGlobalState.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| import React from 'react'; | ||||
| import { createGlobalState } from 'react-hooks-global-state'; | ||||
| 
 | ||||
| type UseGlobalState<T> = () => [ | ||||
|     value: T, | ||||
|     setValue: React.Dispatch<React.SetStateAction<T>> | ||||
| ]; | ||||
| 
 | ||||
| // Create a hook that stores global state (shared across all hook instances).
 | ||||
| export const createGlobalStateHook = <T>( | ||||
|     key: string, | ||||
|     initialValue: T | ||||
| ): UseGlobalState<T> => { | ||||
|     const container = createGlobalState<{ [key: string]: T }>({ | ||||
|         [key]: initialValue, | ||||
|     }); | ||||
| 
 | ||||
|     const setGlobalState = (value: React.SetStateAction<T>) => { | ||||
|         container.setGlobalState(key, value); | ||||
|     }; | ||||
| 
 | ||||
|     return () => [container.useGlobalState(key)[0], setGlobalState]; | ||||
| }; | ||||
| @ -1,5 +1,5 @@ | ||||
| import { getBasePath } from '../utils/format-path'; | ||||
| import { createPersistentGlobalState } from './usePersistentGlobalState'; | ||||
| import { createPersistentGlobalStateHook } from './usePersistentGlobalState'; | ||||
| import React from 'react'; | ||||
| 
 | ||||
| export interface ILocationSettings { | ||||
| @ -23,7 +23,7 @@ const createInitialValue = (): ILocationSettings => { | ||||
|     return { locale: navigator.language }; | ||||
| }; | ||||
| 
 | ||||
| const useGlobalState = createPersistentGlobalState<ILocationSettings>( | ||||
| const useGlobalState = createPersistentGlobalStateHook<ILocationSettings>( | ||||
|     `${getBasePath()}:useLocationSettings:v1`, | ||||
|     createInitialValue() | ||||
| ); | ||||
|  | ||||
| @ -10,7 +10,7 @@ type UsePersistentGlobalState<T> = () => [ | ||||
| // Create a hook that stores global state (shared across all hook instances).
 | ||||
| // The state is also persisted to localStorage and restored on page load.
 | ||||
| // The localStorage state is not synced between tabs.
 | ||||
| export const createPersistentGlobalState = <T extends object>( | ||||
| export const createPersistentGlobalStateHook = <T extends object>( | ||||
|     key: string, | ||||
|     initialValue: T | ||||
| ): UsePersistentGlobalState<T> => { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user