mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +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