diff --git a/frontend/package.json b/frontend/package.json index 4ae3bffda7..f44dd856b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -76,6 +76,7 @@ "react-dnd": "14.0.5", "react-dnd-html5-backend": "14.1.0", "react-dom": "17.0.2", + "react-hooks-global-state": "^1.0.2", "react-outside-click-handler": "1.3.0", "react-redux": "7.2.6", "react-router-dom": "5.3.0", diff --git a/frontend/src/component/archive/ArchiveListContainer.tsx b/frontend/src/component/archive/ArchiveListContainer.tsx new file mode 100644 index 0000000000..3ce133bc57 --- /dev/null +++ b/frontend/src/component/archive/ArchiveListContainer.tsx @@ -0,0 +1,36 @@ +import { useFeaturesArchive } from '../../hooks/api/getters/useFeaturesArchive/useFeaturesArchive'; +import FeatureToggleList from '../feature/FeatureToggleList/FeatureToggleList'; +import useUiConfig from '../../hooks/api/getters/useUiConfig/useUiConfig'; +import { useFeaturesFilter } from '../../hooks/useFeaturesFilter'; +import { useFeatureArchiveApi } from '../../hooks/api/actions/useFeatureArchiveApi/useReviveFeatureApi'; +import useToast from '../../hooks/useToast'; +import { useFeaturesSort } from '../../hooks/useFeaturesSort'; + +export const ArchiveListContainer = () => { + const { setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const { reviveFeature } = useFeatureArchiveApi(); + const { archivedFeatures, loading, refetchArchived } = useFeaturesArchive(); + const { filtered, filter, setFilter } = useFeaturesFilter(archivedFeatures); + const { sorted, sort, setSort } = useFeaturesSort(filtered); + + const revive = (feature: string) => { + reviveFeature(feature) + .then(refetchArchived) + .catch(e => setToastApiError(e.toString())); + }; + + return ( + + ); +}; diff --git a/frontend/src/component/feature/RedirectArchive/RedirectArchive.tsx b/frontend/src/component/archive/RedirectArchive.tsx similarity index 100% rename from frontend/src/component/feature/RedirectArchive/RedirectArchive.tsx rename to frontend/src/component/archive/RedirectArchive.tsx diff --git a/frontend/src/component/archive/archive-list-container.js b/frontend/src/component/archive/archive-list-container.js deleted file mode 100644 index 44804295bf..0000000000 --- a/frontend/src/component/archive/archive-list-container.js +++ /dev/null @@ -1,19 +0,0 @@ -import { connect } from 'react-redux'; -import FeatureListComponent from '../feature/FeatureToggleList/FeatureToggleList'; -import { fetchArchive, revive } from './../../store/archive/actions'; -import { updateSettingForGroup } from './../../store/settings/actions'; -import { mapStateToPropsConfigurable } from '../feature/FeatureToggleList'; - -const mapStateToProps = mapStateToPropsConfigurable(false); -const mapDispatchToProps = { - fetcher: () => fetchArchive(), - revive, - updateSetting: updateSettingForGroup('feature'), -}; - -const ArchiveListContainer = connect( - mapStateToProps, - mapDispatchToProps -)(FeatureListComponent); - -export default ArchiveListContainer; diff --git a/frontend/src/component/archive/archive.module.scss b/frontend/src/component/archive/archive.module.scss deleted file mode 100644 index eee33f7c27..0000000000 --- a/frontend/src/component/archive/archive.module.scss +++ /dev/null @@ -1,37 +0,0 @@ -.archiveList { - background-color: #fff; - color: rgba(0, 0, 0, 0.54); - align-items: center; - padding: 0 16px 0 18px; -} - -.listItemToggle { - width: 40%; - flex-shrink: 0; - margin-right: 20%; -} -.listItemCreated { - width: 10%; - flex-shrink: 0; - margin-right: 2px; -} -.listItemRevive { - width: 5%; - flex-shrink: 0; - margin-right: 10%; -} -.toggleDetails { - font-size: 14px; - font-weight: 400; - line-height: 24px; - letter-spacing: 0; - line-height: 18px; - color: rgba(0, 0, 0, 0.54); - display: block; - padding: 0; -} -.strategiesList { - flex-shrink: 0; - float: right; - margin-left: 8px !important; -} diff --git a/frontend/src/component/common/ProjectSelect/ProjectSelect.jsx b/frontend/src/component/common/ProjectSelect/ProjectSelect.jsx index bdca6ac04c..e82bd8bc60 100644 --- a/frontend/src/component/common/ProjectSelect/ProjectSelect.jsx +++ b/frontend/src/component/common/ProjectSelect/ProjectSelect.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { MenuItem } from '@material-ui/core'; import PropTypes from 'prop-types'; import DropdownMenu from '../DropdownMenu/DropdownMenu'; @@ -9,20 +9,8 @@ const ALL_PROJECTS = { id: '*', name: '> All projects' }; const ProjectSelect = ({ currentProjectId, updateCurrentProject, ...rest }) => { const { projects } = useProjects(); - useEffect(() => { - let currentProject = projects.find(i => i.id === currentProjectId); - - if (currentProject) { - setProject(currentProject.id); - return; - } - - setProject('*'); - /* eslint-disable-next-line */ - }, []); - const setProject = v => { - const id = typeof v === 'string' ? v.trim() : ''; + const id = v && typeof v === 'string' ? v.trim() : '*'; updateCurrentProject(id); }; diff --git a/frontend/src/component/common/ProjectSelect/index.jsx b/frontend/src/component/common/ProjectSelect/index.jsx index a7fa1bda65..9718602fea 100644 --- a/frontend/src/component/common/ProjectSelect/index.jsx +++ b/frontend/src/component/common/ProjectSelect/index.jsx @@ -3,9 +3,8 @@ import ProjectSelect from './ProjectSelect'; import { fetchProjects } from '../../../store/project/actions'; const mapStateToProps = (state, ownProps) => ({ + ...ownProps, projects: state.projects.toJS(), - currentProjectId: ownProps.settings.currentProjectId || '*', - updateCurrentProject: id => ownProps.updateSetting('currentProjectId', id), }); export default connect(mapStateToProps, { fetchProjects })(ProjectSelect); diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleList.jsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleList.jsx index c123bdb4ae..1bd1d1701f 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleList.jsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleList.jsx @@ -1,8 +1,8 @@ -import { useContext, useLayoutEffect, useEffect } from 'react'; +import { useContext } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { Link } from 'react-router-dom'; -import { Button, List, Tooltip, IconButton, ListItem } from '@material-ui/core'; +import { Button, IconButton, List, ListItem, Tooltip } from '@material-ui/core'; import useMediaQuery from '@material-ui/core/useMediaQuery'; import { Add } from '@material-ui/icons'; @@ -23,43 +23,31 @@ import { useStyles } from './styles'; import ListPlaceholder from '../../common/ListPlaceholder/ListPlaceholder'; import { getCreateTogglePath } from '../../../utils/route-path-helpers'; import { NAVIGATE_TO_CREATE_FEATURE } from '../../../testIds'; +import { resolveFilteredProjectId } from '../../../hooks/useFeaturesFilter'; const FeatureToggleList = ({ - fetcher, features, - settings, revive, - currentProjectId, - updateSetting, - featureMetrics, - toggleFeature, archive, loading, flags, + filter, + setFilter, + sort, + setSort, }) => { const { hasAccess } = useContext(AccessContext); const styles = useStyles(); const smallScreen = useMediaQuery('(max-width:800px)'); const mobileView = useMediaQuery('(max-width:600px)'); - useLayoutEffect(() => { - fetcher(); - }, [fetcher]); - - useEffect(() => { - updateSetting('filter', ''); - /* eslint-disable-next-line */ - }, []); - - const toggleMetrics = () => { - updateSetting('showLastHour', !settings.showLastHour); + const setFilterQuery = v => { + const query = v && typeof v === 'string' ? v.trim() : ''; + setFilter(prev => ({ ...prev, query })); }; - const setSort = v => { - updateSetting('sort', typeof v === 'string' ? v.trim() : ''); - }; - - const createURL = getCreateTogglePath(currentProjectId, flags.E); + const resolvedProjectId = resolveFilteredProjectId(filter); + const createURL = getCreateTogglePath(resolvedProjectId, flags.E); const renderFeatures = () => { features.forEach(e => { @@ -70,11 +58,7 @@ const FeatureToggleList = ({ return loadingFeatures.map(feature => ( (
} @@ -175,7 +153,7 @@ const FeatureToggleList = ({ disabled={ !hasAccess( CREATE_FEATURE, - currentProjectId + resolvedProjectId ) } > @@ -195,7 +173,7 @@ const FeatureToggleList = ({ disabled={ !hasAccess( CREATE_FEATURE, - currentProjectId + resolvedProjectId ) } className={classnames({ @@ -221,16 +199,14 @@ const FeatureToggleList = ({ FeatureToggleList.propTypes = { features: PropTypes.array.isRequired, - featureMetrics: PropTypes.object.isRequired, - fetcher: PropTypes.func, revive: PropTypes.func, - updateSetting: PropTypes.func.isRequired, - toggleFeature: PropTypes.func, - settings: PropTypes.object, - history: PropTypes.object.isRequired, loading: PropTypes.bool, - currentProjectId: PropTypes.string.isRequired, + archive: PropTypes.bool, flags: PropTypes.object, + filter: PropTypes.object.isRequired, + setFilter: PropTypes.func.isRequired, + sort: PropTypes.object.isRequired, + setSort: PropTypes.func.isRequired, }; export default FeatureToggleList; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListActions/FeatureToggleListActions.jsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListActions/FeatureToggleListActions.jsx index d242ebdd2f..fd27c1a1fd 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListActions/FeatureToggleListActions.jsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListActions/FeatureToggleListActions.jsx @@ -2,31 +2,21 @@ import React from 'react'; import PropTypes from 'prop-types'; import { MenuItem, Typography } from '@material-ui/core'; -// import { HourglassEmpty, HourglassFull } from '@material-ui/icons'; -// import { MenuItemWithIcon } from '../../../common'; import DropdownMenu from '../../../common/DropdownMenu/DropdownMenu'; import ProjectSelect from '../../../common/ProjectSelect'; import { useStyles } from './styles'; import useLoading from '../../../../hooks/useLoading'; import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig'; import ConditionallyRender from '../../../common/ConditionallyRender'; +import { createFeaturesFilterSortOptions } from '../../../../hooks/useFeaturesSort'; -const sortingOptions = [ - { type: 'name', displayName: 'Name' }, - { type: 'type', displayName: 'Type' }, - { type: 'enabled', displayName: 'Enabled' }, - { type: 'stale', displayName: 'Stale' }, - { type: 'created', displayName: 'Created' }, - { type: 'Last seen', displayName: 'Last seen' }, - { type: 'project', displayName: 'Project' }, - { type: 'metrics', displayName: 'Metrics' }, -]; +const sortOptions = createFeaturesFilterSortOptions(); const FeatureToggleListActions = ({ - settings, + filter, + setFilter, + sort, setSort, - toggleMetrics, - updateSetting, loading, }) => { const styles = useStyles(); @@ -34,65 +24,33 @@ const FeatureToggleListActions = ({ const ref = useLoading(loading); const handleSort = e => { - const target = e.target.getAttribute('data-target'); - setSort(target); + const type = e.target.getAttribute('data-target')?.trim(); + type && setSort(prev => ({ ...prev, type })); }; - const isDisabled = type => settings.sort === type; + const isDisabled = s => s === sort.type; + const selectedOption = sortOptions.find(o => o.type === sort.type) || sortOptions[0]; const renderSortingOptions = () => - sortingOptions.map(option => ( + sortOptions.map(option => ( - {option.displayName} + {option.name} )); - /* - const renderMetricsOptions = () => [ - , - , - ]; - */ - return (
Sorted by: - {/* } - - {*/} setFilter(prev => ({ ...prev, project }))} style={{ textTransform: 'lowercase', fontWeight: 'normal', @@ -119,10 +77,11 @@ const FeatureToggleListActions = ({ }; FeatureToggleListActions.propTypes = { - settings: PropTypes.object, + filter: PropTypes.object, + setFilter: PropTypes.func, + sort: PropTypes.object, setSort: PropTypes.func, toggleMetrics: PropTypes.func, - updateSetting: PropTypes.func, loading: PropTypes.bool, }; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListContainer.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListContainer.tsx new file mode 100644 index 0000000000..84a7f94a3c --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListContainer.tsx @@ -0,0 +1,24 @@ +import { useFeatures } from '../../../hooks/api/getters/useFeatures/useFeatures'; +import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig'; +import { useFeaturesFilter } from '../../../hooks/useFeaturesFilter'; +import FeatureToggleList from './FeatureToggleList'; +import { useFeaturesSort } from '../../../hooks/useFeaturesSort'; + +export const FeatureToggleListContainer = () => { + const { uiConfig } = useUiConfig(); + const { features, loading } = useFeatures(); + const { filtered, filter, setFilter } = useFeaturesFilter(features); + const { sorted, sort, setSort } = useFeaturesSort(filtered); + + return ( + + ); +}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItem.jsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItem.jsx index e157d7b9fe..e85a05da02 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItem.jsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItem.jsx @@ -22,10 +22,6 @@ import PermissionIconButton from '../../../common/PermissionIconButton/Permissio const FeatureToggleListItem = ({ feature, - toggleFeature, - settings, - metricsLastHour = { yes: 0, no: 0, isFallback: true }, - metricsLastMinute = { yes: 0, no: 0, isFallback: true }, revive, hasAccess, flags = {}, @@ -164,10 +160,6 @@ const FeatureToggleListItem = ({ FeatureToggleListItem.propTypes = { feature: PropTypes.object, - toggleFeature: PropTypes.func, - settings: PropTypes.object, - metricsLastHour: PropTypes.object, - metricsLastMinute: PropTypes.object, revive: PropTypes.func, hasAccess: PropTypes.func.isRequired, flags: PropTypes.object, diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItemChip/FeatureToggleListItemChip.jsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItemChip/FeatureToggleListItemChip.jsx deleted file mode 100644 index 819f62627c..0000000000 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItemChip/FeatureToggleListItemChip.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import React, { memo } from 'react'; -import { Chip } from '@material-ui/core'; -import PropTypes from 'prop-types'; - -import { useStyles } from './styles'; - -const FeatureToggleListItemChip = ({ type, types, onClick }) => { - const styles = useStyles(); - - const typeObject = types.find(o => o.id === type) || { - id: type, - name: type, - }; - - return ( - - ); -}; - -FeatureToggleListItemChip.propTypes = { - type: PropTypes.string.isRequired, - types: PropTypes.array, - onClick: PropTypes.func, -}; - -export default memo(FeatureToggleListItemChip); diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItemChip/index.jsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItemChip/index.jsx deleted file mode 100644 index a148ec6e07..0000000000 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItemChip/index.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import { connect } from 'react-redux'; -import Component from './FeatureToggleListItemChip'; - -const mapStateToProps = state => ({ - types: state.featureTypes.toJS(), -}); - -const FeatureType = connect(mapStateToProps)(Component); - -export default FeatureType; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItemChip/styles.js b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItemChip/styles.js deleted file mode 100644 index 1ca8703e8e..0000000000 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItemChip/styles.js +++ /dev/null @@ -1,10 +0,0 @@ -import { makeStyles } from '@material-ui/styles'; - -export const useStyles = makeStyles(theme => ({ - typeChip: { - margin: '0 8px', - background: 'transparent', - border: `1px solid ${theme.palette.primary.main}`, - color: theme.palette.primary.main, - }, -})); diff --git a/frontend/src/component/feature/FeatureToggleList/__tests__/__snapshots__/list-component-test.jsx.snap b/frontend/src/component/feature/FeatureToggleList/__tests__/__snapshots__/list-component-test.jsx.snap index 9a504742d3..36bd2043b0 100644 --- a/frontend/src/component/feature/FeatureToggleList/__tests__/__snapshots__/list-component-test.jsx.snap +++ b/frontend/src/component/feature/FeatureToggleList/__tests__/__snapshots__/list-component-test.jsx.snap @@ -117,7 +117,7 @@ exports[`renders correctly with one feature 1`] = ` - By name + By Name @@ -185,12 +185,6 @@ exports[`renders correctly with one feature 1`] = ` } flags={Object {}} hasAccess={[Function]} - settings={ - Object { - "sort": "name", - } - } - toggleFeature={[MockFunction]} />
@@ -315,7 +309,7 @@ exports[`renders correctly with one feature without permissions 1`] = ` - By name + By Name @@ -386,12 +380,6 @@ exports[`renders correctly with one feature without permissions 1`] = ` } flags={Object {}} hasAccess={[Function]} - settings={ - Object { - "sort": "name", - } - } - toggleFeature={[MockFunction]} />
diff --git a/frontend/src/component/feature/FeatureToggleList/__tests__/feature-list-item-component-test.jsx b/frontend/src/component/feature/FeatureToggleList/__tests__/feature-list-item-component-test.jsx index a9325a9d36..e3af2247ca 100644 --- a/frontend/src/component/feature/FeatureToggleList/__tests__/feature-list-item-component-test.jsx +++ b/frontend/src/component/feature/FeatureToggleList/__tests__/feature-list-item-component-test.jsx @@ -7,8 +7,6 @@ import renderer from 'react-test-renderer'; import theme from '../../../../themes/main-theme'; -jest.mock('../FeatureToggleListItem/FeatureToggleListItemChip'); - test('renders correctly with one feature', () => { const feature = { name: 'Another', @@ -26,18 +24,12 @@ test('renders correctly with one feature', () => { ], createdAt: '2018-02-04T20:27:52.127Z', }; - const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} }; - const settings = { sort: 'name' }; const tree = renderer.create( true} /> @@ -63,18 +55,12 @@ test('renders correctly with one feature without permission', () => { ], createdAt: '2018-02-04T20:27:52.127Z', }; - const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} }; - const settings = { sort: 'name' }; const tree = renderer.create( true} /> diff --git a/frontend/src/component/feature/FeatureToggleList/__tests__/list-component-test.jsx b/frontend/src/component/feature/FeatureToggleList/__tests__/list-component-test.jsx index b603139017..516f20da64 100644 --- a/frontend/src/component/feature/FeatureToggleList/__tests__/list-component-test.jsx +++ b/frontend/src/component/feature/FeatureToggleList/__tests__/list-component-test.jsx @@ -25,8 +25,7 @@ test('renders correctly with one feature', () => { name: 'Another', }, ]; - const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} }; - const settings = { sort: 'name' }; + const tree = renderer.create( @@ -35,13 +34,12 @@ test('renders correctly with one feature', () => { > @@ -58,8 +56,6 @@ test('renders correctly with one feature without permissions', () => { name: 'Another', }, ]; - const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} }; - const settings = { sort: 'name' }; const tree = renderer.create( @@ -67,14 +63,12 @@ test('renders correctly with one feature without permissions', () => { store={createFakeStore([{ permission: CREATE_FEATURE }])} > diff --git a/frontend/src/component/feature/FeatureToggleList/index.jsx b/frontend/src/component/feature/FeatureToggleList/index.jsx deleted file mode 100644 index 3f6c13836b..0000000000 --- a/frontend/src/component/feature/FeatureToggleList/index.jsx +++ /dev/null @@ -1,151 +0,0 @@ -import { connect } from 'react-redux'; -import { - toggleFeature, - fetchFeatureToggles, -} from '../../../store/feature-toggle/actions'; -import { updateSettingForGroup } from '../../../store/settings/actions'; -import FeatureToggleList from './FeatureToggleList'; - -function checkConstraints(strategy, regex) { - if (!strategy.constraints) { - return; - } - return strategy.constraints.some(c => c.values.some(v => regex.test(v))); -} - -function resolveCurrentProjectId(settings) { - if (!settings.currentProjectId || settings.currentProjectId === '*') { - return 'default'; - } - return settings.currentProjectId; -} - -export const mapStateToPropsConfigurable = isFeature => state => { - const featureMetrics = state.featureMetrics.toJS(); - const flags = state.uiConfig.toJS().flags; - const settings = state.settings.toJS().feature || {}; - let features = isFeature - ? state.features.toJS() - : state.archive.get('list').toArray(); - - if (settings.currentProjectId && settings.currentProjectId !== '*') { - features = features.filter( - f => f.project === settings.currentProjectId - ); - } - if (settings.filter) { - try { - const regex = new RegExp(settings.filter, 'i'); - features = features.filter(feature => { - if (!isFeature) { - return ( - regex.test(feature.name) || - regex.test(feature.description) || - (settings.filter.length > 1 && - regex.test(JSON.stringify(feature))) - ); - } - return ( - feature.strategies.some(s => checkConstraints(s, regex)) || - regex.test(feature.name) || - regex.test(feature.description) || - feature.strategies.some( - s => s && s.name && regex.test(s.name) - ) || - (settings.filter.length > 1 && - regex.test(JSON.stringify(feature))) - ); - }); - } catch (e) { - // Invalid filter regex - } - } - - if (!settings.sort) { - settings.sort = 'name'; - } - - if (settings.sort === 'enabled') { - features = features.sort((a, b) => - // eslint-disable-next-line - a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1 - ); - } else if (settings.sort === 'stale') { - features = features.sort((a, b) => - // eslint-disable-next-line - a.stale === b.stale ? 0 : a.stale ? -1 : 1 - ); - } else if (settings.sort === 'created') { - features = features.sort((a, b) => - new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1 - ); - } else if (settings.sort === 'Last seen') { - features = features.sort((a, b) => - new Date(a.lastSeenAt) > new Date(b.lastSeenAt) ? -1 : 1 - ); - } else if (settings.sort === 'name') { - features = features.sort((a, b) => { - if (a.name < b.name) { - return -1; - } - if (a.name > b.name) { - return 1; - } - return 0; - }); - } else if (settings.sort === 'project') { - features = features.sort((a, b) => - a.project.length > b.project.length ? -1 : 1 - ); - } else if (settings.sort === 'type') { - features = features.sort((a, b) => { - if (a.type < b.type) { - return -1; - } - if (a.type > b.type) { - return 1; - } - return 0; - }); - } else if (settings.sort === 'metrics') { - const target = settings.showLastHour - ? featureMetrics.lastHour - : featureMetrics.lastMinute; - - features = features.sort((a, b) => { - if (!target[a.name]) { - return 1; - } - if (!target[b.name]) { - return -1; - } - if (target[a.name].yes > target[b.name].yes) { - return -1; - } - return 1; - }); - } - - return { - features, - currentProjectId: resolveCurrentProjectId(settings), - featureMetrics, - archive: !isFeature, - settings, - flags, - loading: state.apiCalls.fetchTogglesState.loading, - }; -}; -const mapStateToProps = mapStateToPropsConfigurable(true); -const mapDispatchToProps = { - toggleFeature, - fetcher: () => fetchFeatureToggles(), - updateSetting: updateSettingForGroup('feature'), -}; - -const FeatureToggleListContainer = connect( - mapStateToProps, - mapDispatchToProps -)(FeatureToggleList); - -export default FeatureToggleListContainer; diff --git a/frontend/src/component/feature/RedirectFeatureView/RedirectFeatureView.tsx b/frontend/src/component/feature/RedirectFeatureView/RedirectFeatureView.tsx index c642cf0800..a557d57a24 100644 --- a/frontend/src/component/feature/RedirectFeatureView/RedirectFeatureView.tsx +++ b/frontend/src/component/feature/RedirectFeatureView/RedirectFeatureView.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Redirect, useParams } from 'react-router-dom'; -import useFeatures from '../../../hooks/api/getters/useFeatures/useFeatures'; +import { useFeatures } from '../../../hooks/api/getters/useFeatures/useFeatures'; import { IFeatureToggle } from '../../../interfaces/featureToggle'; import { getTogglePath } from '../../../utils/route-path-helpers'; diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index 26f375f20e..a61593860f 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -1,11 +1,11 @@ import CopyFeatureToggle from '../../page/features/copy'; -import Features from '../../page/features'; +import { FeatureToggleListContainer } from '../feature/FeatureToggleList/FeatureToggleListContainer'; import CreateStrategies from '../../page/strategies/create'; import StrategyView from '../../page/strategies/show'; import Strategies from '../../page/strategies'; import HistoryPage from '../../page/history'; import HistoryTogglePage from '../../page/history/toggle'; -import Archive from '../../page/archive'; +import { ArchiveListContainer } from '../archive/ArchiveListContainer'; import Applications from '../../page/applications'; import ApplicationView from '../../page/applications/view'; import ListTagTypes from '../../page/tag-types'; @@ -24,7 +24,7 @@ import ResetPassword from '../user/ResetPassword/ResetPassword'; import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword'; import ProjectListNew from '../project/ProjectList/ProjectList'; import Project from '../project/Project/Project'; -import RedirectArchive from '../feature/RedirectArchive/RedirectArchive'; +import RedirectArchive from '../archive/RedirectArchive'; import EnvironmentList from '../environments/EnvironmentList/EnvironmentList'; import FeatureView from '../feature/FeatureView/FeatureView'; import ProjectRoles from '../admin/project-roles/ProjectRoles/ProjectRoles'; @@ -182,7 +182,7 @@ export const routes = [ { path: '/features', title: 'Feature Toggles', - component: Features, + component: FeatureToggleListContainer, type: 'protected', layout: 'main', menu: { mobile: true }, @@ -372,7 +372,7 @@ export const routes = [ { path: '/archive', title: 'Archived Toggles', - component: Archive, + component: ArchiveListContainer, type: 'protected', layout: 'main', menu: {}, diff --git a/frontend/src/component/user/UserProfile/UserProfile.tsx b/frontend/src/component/user/UserProfile/UserProfile.tsx index 7f7ac5101a..bc97874437 100644 --- a/frontend/src/component/user/UserProfile/UserProfile.tsx +++ b/frontend/src/component/user/UserProfile/UserProfile.tsx @@ -8,11 +8,11 @@ import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown'; import { useStyles } from './UserProfile.styles'; import { useCommonStyles } from '../../../common.styles'; import UserProfileContent from './UserProfileContent/UserProfileContent'; -import { IUser } from "../../../interfaces/user"; +import { IUser } from '../../../interfaces/user'; interface IUserProfileProps { - profile: IUser - updateSettingLocation: (field: 'locale', value: string) => void + profile: IUser; + updateSettingLocation: (field: 'locale', value: string) => void; } const UserProfile = ({ diff --git a/frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.tsx b/frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.tsx index 1d8b63b020..d2470111dd 100644 --- a/frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.tsx +++ b/frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.tsx @@ -1,13 +1,13 @@ import React, { useState } from 'react'; import ConditionallyRender from '../../../common/ConditionallyRender'; import { - Paper, Avatar, - Typography, Button, FormControl, - Select, InputLabel, + Paper, + Select, + Typography, } from '@material-ui/core'; import classnames from 'classnames'; import { useStyles } from './UserProfileContent.styles'; @@ -17,16 +17,16 @@ import EditProfile from '../EditProfile/EditProfile'; import legacyStyles from '../../user.module.scss'; import { getBasePath } from '../../../../utils/format-path'; import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig'; -import { IUser } from "../../../../interfaces/user"; +import { IUser } from '../../../../interfaces/user'; interface IUserProfileContentProps { - showProfile: boolean - profile: IUser - possibleLocales: string[] - updateSettingLocation: (field: 'locale', value: string) => void - imageUrl: string - currentLocale?: string - setCurrentLocale: (value: string) => void + showProfile: boolean; + profile: IUser; + possibleLocales: string[]; + updateSettingLocation: (field: 'locale', value: string) => void; + imageUrl: string; + currentLocale?: string; + setCurrentLocale: (value: string) => void; } const UserProfileContent = ({ @@ -99,14 +99,19 @@ const UserProfileContent = ({ condition={!editingProfile} show={ <> - setEditingProfile(true)} - > - Update password - - } /> + + setEditingProfile(true) + } + > + Update password + + } + />
{ + const { makeRequest, createRequest, errors, loading } = useAPI({ + propagateErrors: true, + }); + + const reviveFeature = async (feature: string) => { + const path = `api/admin/archive/revive/${feature}`; + const req = createRequest(path, { method: 'POST' }); + return makeRequest(req.caller, req.id); + }; + + return { reviveFeature, errors, loading }; +}; diff --git a/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts b/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts index ac7e290741..532a31e84d 100644 --- a/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts +++ b/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts @@ -1,40 +1,39 @@ import useSWR, { mutate, SWRConfiguration } from 'swr'; -import { useState, useEffect } from 'react'; +import { useCallback } from 'react'; import { formatApiPath } from '../../../../utils/format-path'; import handleErrorResponses from '../httpErrorResponseHandler'; +import { IFeatureToggle } from '../../../../interfaces/featureToggle'; -const useFeatures = (options: SWRConfiguration = {}) => { - const fetcher = async () => { - const path = formatApiPath('api/admin/features/'); - return fetch(path, { - method: 'GET', - }) - .then(handleErrorResponses('Features')) - .then(res => res.json()); - }; +const PATH = formatApiPath('api/admin/features'); - const FEATURES_CACHE_KEY = 'api/admin/features/'; +export interface IUseFeaturesOutput { + features: IFeatureToggle[]; + refetchFeatures: () => void; + loading: boolean; + error?: Error; +} - const { data, error } = useSWR(FEATURES_CACHE_KEY, fetcher, { - ...options, - }); +export const useFeatures = (options?: SWRConfiguration): IUseFeaturesOutput => { + const { data, error } = useSWR<{ features: IFeatureToggle[] }>( + PATH, + fetchFeatures, + options + ); - const [loading, setLoading] = useState(!error && !data); - - const refetchFeatures = () => { - mutate(FEATURES_CACHE_KEY); - }; - - useEffect(() => { - setLoading(!error && !data); - }, [data, error]); + const refetchFeatures = useCallback(() => { + mutate(PATH).catch(console.warn); + }, []); return { features: data?.features || [], - error, - loading, + loading: !error && !data, refetchFeatures, + error, }; }; -export default useFeatures; +const fetchFeatures = () => { + return fetch(PATH, { method: 'GET' }) + .then(handleErrorResponses('Features')) + .then(res => res.json()); +}; diff --git a/frontend/src/hooks/api/getters/useFeaturesArchive/useFeaturesArchive.ts b/frontend/src/hooks/api/getters/useFeaturesArchive/useFeaturesArchive.ts new file mode 100644 index 0000000000..7645813380 --- /dev/null +++ b/frontend/src/hooks/api/getters/useFeaturesArchive/useFeaturesArchive.ts @@ -0,0 +1,41 @@ +import useSWR, { mutate, SWRConfiguration } from 'swr'; +import { useCallback } from 'react'; +import { formatApiPath } from '../../../../utils/format-path'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { IFeatureToggle } from '../../../../interfaces/featureToggle'; + +const PATH = formatApiPath('api/admin/archive/features'); + +export interface UseFeaturesArchiveOutput { + archivedFeatures: IFeatureToggle[]; + refetchArchived: () => void; + loading: boolean; + error?: Error; +} + +export const useFeaturesArchive = ( + options?: SWRConfiguration +): UseFeaturesArchiveOutput => { + const { data, error } = useSWR<{ features: IFeatureToggle[] }>( + PATH, + fetchArchivedFeatures, + options + ); + + const refetchArchived = useCallback(() => { + mutate(PATH).catch(console.warn); + }, []); + + return { + archivedFeatures: data?.features || [], + refetchArchived, + loading: !error && !data, + error, + }; +}; + +const fetchArchivedFeatures = () => { + return fetch(PATH, { method: 'GET' }) + .then(handleErrorResponses('Archive')) + .then(res => res.json()); +}; diff --git a/frontend/src/hooks/useFeaturesFilter.ts b/frontend/src/hooks/useFeaturesFilter.ts new file mode 100644 index 0000000000..0429f7c1a7 --- /dev/null +++ b/frontend/src/hooks/useFeaturesFilter.ts @@ -0,0 +1,117 @@ +import { IFeatureToggle } from '../interfaces/featureToggle'; +import React, { useMemo } from 'react'; +import { getBasePath } from '../utils/format-path'; +import { createPersistentGlobalState } from './usePersistentGlobalState'; + +export interface IFeaturesFilter { + query?: string; + project: string; +} + +export interface IFeaturesSortOutput { + filtered: IFeatureToggle[]; + filter: IFeaturesFilter; + setFilter: React.Dispatch> +} + +// Store the features filter state globally, and in localStorage. +// When changing the format of IFeaturesFilter, change the version as well. +const useFeaturesFilterState = createPersistentGlobalState( + `${getBasePath()}:useFeaturesFilter:v1`, + { project: '*' } +); + +export const useFeaturesFilter = ( + features: IFeatureToggle[] +): IFeaturesSortOutput => { + const [filter, setFilter] = useFeaturesFilterState(); + + const filtered = useMemo(() => { + return filterFeatures(features, filter); + }, [features, filter]); + + return { + setFilter, + filter, + filtered, + }; +}; + +// Return the current project ID a project has been selected, +// or the 'default' project if showing all projects. +export const resolveFilteredProjectId = (filter: IFeaturesFilter): string => { + if (!filter.project || filter.project === '*') { + return 'default'; + } + + return filter.project; +}; + +const filterFeatures = ( + features: IFeatureToggle[], + filter: IFeaturesFilter +): IFeatureToggle[] => { + return filterFeaturesByQuery( + filterFeaturesByProject(features, filter), + filter + ); +}; + +const filterFeaturesByProject = ( + features: IFeatureToggle[], + filter: IFeaturesFilter +): IFeatureToggle[] => { + return filter.project === '*' + ? features + : features.filter(f => f.project === filter.project); +}; + +const filterFeaturesByQuery = ( + features: IFeatureToggle[], + filter: IFeaturesFilter +): IFeatureToggle[] => { + if (!filter.query) { + return features; + } + + // Try to parse the search query as a RegExp. + // Return all features if it can't be parsed. + try { + const regExp = new RegExp(filter.query, 'i'); + return features.filter(f => filterFeatureByRegExp(f, filter, regExp)); + } catch (err) { + if (err instanceof SyntaxError) { + return features; + } else { + throw err; + } + } +}; + +const filterFeatureByRegExp = ( + feature: IFeatureToggle, + filter: IFeaturesFilter, + regExp: RegExp +): boolean => { + if (regExp.test(feature.name) || regExp.test(feature.description)) { + return true; + } + + if ( + filter.query && + filter.query.length > 1 && + regExp.test(JSON.stringify(feature)) + ) { + return true; + } + + if (!feature.strategies) { + return false; + } + + return feature.strategies.some( + s => + regExp.test(s.name) || + s.constraints.some(c => c.values.some(v => regExp.test(v))) + ); +}; diff --git a/frontend/src/hooks/useFeaturesSort.ts b/frontend/src/hooks/useFeaturesSort.ts new file mode 100644 index 0000000000..cccd4d59a7 --- /dev/null +++ b/frontend/src/hooks/useFeaturesSort.ts @@ -0,0 +1,137 @@ +import { IFeatureToggle } from '../interfaces/featureToggle'; +import React, { useMemo } from 'react'; +import { getBasePath } from '../utils/format-path'; +import { createPersistentGlobalState } from './usePersistentGlobalState'; + +type FeaturesSortType = + | 'name' + | 'type' + | 'enabled' + | 'stale' + | 'created' + | 'last-seen' + | 'project'; + +interface IFeaturesSort { + type: FeaturesSortType; +} + +export interface IFeaturesSortOutput { + sort: IFeaturesSort; + sorted: IFeatureToggle[]; + setSort: React.Dispatch> +} + +export interface IFeaturesFilterSortOption { + type: FeaturesSortType; + name: string; +} + +// Store the features sort state globally, and in localStorage. +// When changing the format of IFeaturesSort, change the version as well. +const useFeaturesSortState = createPersistentGlobalState( + `${getBasePath()}:useFeaturesSort:v1`, + { type: 'name' } +); + +export const useFeaturesSort = ( + features: IFeatureToggle[] +): IFeaturesSortOutput => { + const [sort, setSort] = useFeaturesSortState(); + + const sorted = useMemo(() => { + return sortFeatures(features, sort); + }, [features, sort]); + + return { + setSort, + sort, + sorted, + }; +}; + +export const createFeaturesFilterSortOptions = + (): IFeaturesFilterSortOption[] => { + return [ + { type: 'name', name: 'Name' }, + { type: 'type', name: 'Type' }, + { type: 'enabled', name: 'Enabled' }, + { type: 'stale', name: 'Stale' }, + { type: 'created', name: 'Created' }, + { type: 'last-seen', name: 'Last seen' }, + { type: 'project', name: 'Project' }, + ]; + }; + +const sortFeatures = ( + features: IFeatureToggle[], + sort: IFeaturesSort +): IFeatureToggle[] => { + switch (sort.type) { + case 'enabled': + return sortByEnabled(features); + case 'stale': + return sortByStale(features); + case 'created': + return sortByCreated(features); + case 'last-seen': + return sortByLastSeen(features); + case 'name': + return sortByName(features); + case 'project': + return sortByProject(features); + case 'type': + return sortByType(features); + default: + console.error(`Unknown feature sort type: ${sort.type}`); + return features; + } +}; + +const sortByEnabled = ( + features: Readonly +): IFeatureToggle[] => { + return [...features].sort((a, b) => + a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1 + ); +}; + +const sortByStale = ( + features: Readonly +): IFeatureToggle[] => { + return [...features].sort((a, b) => + a.stale === b.stale ? 0 : a.stale ? -1 : 1 + ); +}; + +const sortByLastSeen = ( + features: Readonly +): IFeatureToggle[] => { + return [...features].sort((a, b) => + a.lastSeenAt && b.lastSeenAt + ? a.lastSeenAt.localeCompare(b.lastSeenAt) + : 0 + ); +}; + +const sortByCreated = ( + features: Readonly +): IFeatureToggle[] => { + return [...features].sort((a, b) => + new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1 + ); +}; + +const sortByName = (features: Readonly): IFeatureToggle[] => { + return [...features].sort((a, b) => a.name.localeCompare(b.name)); +}; + +const sortByProject = ( + features: Readonly +): IFeatureToggle[] => { + return [...features].sort((a, b) => a.project.localeCompare(b.project)); +}; + +const sortByType = (features: Readonly): IFeatureToggle[] => { + return [...features].sort((a, b) => a.type.localeCompare(b.type)); +}; diff --git a/frontend/src/hooks/usePersistentGlobalState.ts b/frontend/src/hooks/usePersistentGlobalState.ts new file mode 100644 index 0000000000..1a06da3ffe --- /dev/null +++ b/frontend/src/hooks/usePersistentGlobalState.ts @@ -0,0 +1,29 @@ +import React from 'react'; +import { createGlobalState } from 'react-hooks-global-state'; +import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage'; + +type UsePersistentGlobalState = () => [ + value: T, + setValue: React.Dispatch> +]; + +// 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 = ( + key: string, + initialValue: T +): UsePersistentGlobalState => { + const container = createGlobalState<{ [key: string]: T }>({ + [key]: getLocalStorageItem(key) ?? initialValue, + }); + + const setGlobalState = (value: React.SetStateAction) => { + const prev = container.getGlobalState(key); + const next = typeof value === 'function' ? value(prev) : value; + container.setGlobalState(key, next); + setLocalStorageItem(key, next); + }; + + return () => [container.useGlobalState(key)[0], setGlobalState]; +}; diff --git a/frontend/src/interfaces/featureToggle.ts b/frontend/src/interfaces/featureToggle.ts index b8d62137d9..bd05fc78ea 100644 --- a/frontend/src/interfaces/featureToggle.ts +++ b/frontend/src/interfaces/featureToggle.ts @@ -32,8 +32,9 @@ export interface IFeatureTogglePayload { export interface IFeatureToggle { stale: boolean; archived: boolean; - createdAt: Date; - lastSeenAt?: Date; + enabled?: boolean; + createdAt: string; + lastSeenAt?: string; description: string; environments: IFeatureEnvironment[]; name: string; @@ -41,6 +42,7 @@ export interface IFeatureToggle { type: string; variants: IFeatureVariant[]; impressionData: boolean; + strategies?: IFeatureStrategy[]; } export interface IFeatureEnvironment { diff --git a/frontend/src/page/archive/index.js b/frontend/src/page/archive/index.js deleted file mode 100644 index bac016f71a..0000000000 --- a/frontend/src/page/archive/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import Archive from '../../component/archive/archive-list-container'; -import PropTypes from 'prop-types'; - -const render = ({ match: { params }, history }) => ; -render.propTypes = { - match: PropTypes.object, - history: PropTypes.object, -}; - -export default render; diff --git a/frontend/src/page/features/index.js b/frontend/src/page/features/index.js deleted file mode 100644 index 555dee298f..0000000000 --- a/frontend/src/page/features/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import FeatureListContainer from '../../component/feature/FeatureToggleList'; -import PropTypes from 'prop-types'; - -const render = ({ history }) => ; - -render.propTypes = { - history: PropTypes.object.isRequired, -}; - -export default render; diff --git a/frontend/src/store/archive/actions.js b/frontend/src/store/archive/actions.js deleted file mode 100644 index ac76f667db..0000000000 --- a/frontend/src/store/archive/actions.js +++ /dev/null @@ -1,32 +0,0 @@ -import api from './api'; -import { dispatchError } from '../util'; - -export const REVIVE_TOGGLE = 'REVIVE_TOGGLE'; -export const RECEIVE_ARCHIVE = 'RECEIVE_ARCHIVE'; -export const ERROR_RECEIVE_ARCHIVE = 'ERROR_RECEIVE_ARCHIVE'; - -const receiveArchive = json => ({ - type: RECEIVE_ARCHIVE, - value: json.features, -}); - -const reviveToggle = archiveFeatureToggle => ({ - type: REVIVE_TOGGLE, - value: archiveFeatureToggle, -}); - -export function revive(featureToggle) { - return dispatch => - api - .revive(featureToggle) - .then(() => dispatch(reviveToggle(featureToggle))) - .catch(dispatchError(dispatch, ERROR_RECEIVE_ARCHIVE)); -} - -export function fetchArchive() { - return dispatch => - api - .fetchAll() - .then(json => dispatch(receiveArchive(json))) - .catch(dispatchError(dispatch, ERROR_RECEIVE_ARCHIVE)); -} diff --git a/frontend/src/store/archive/api.js b/frontend/src/store/archive/api.js deleted file mode 100644 index ea4661747f..0000000000 --- a/frontend/src/store/archive/api.js +++ /dev/null @@ -1,23 +0,0 @@ -import { formatApiPath } from '../../utils/format-path'; -import { throwIfNotSuccess, headers } from '../api-helper'; - -const URI = formatApiPath('api/admin/archive'); - -function fetchAll() { - return fetch(`${URI}/features`, { credentials: 'include' }) - .then(throwIfNotSuccess) - .then(response => response.json()); -} - -function revive(featureName) { - return fetch(`${URI}/revive/${featureName}`, { - method: 'POST', - headers, - credentials: 'include', - }).then(throwIfNotSuccess); -} - -export default { - fetchAll, - revive, -}; diff --git a/frontend/src/store/archive/index.js b/frontend/src/store/archive/index.js deleted file mode 100644 index ba7f51a4eb..0000000000 --- a/frontend/src/store/archive/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import { List, Map as $Map } from 'immutable'; -import { RECEIVE_ARCHIVE, REVIVE_TOGGLE } from './actions'; -import { USER_LOGOUT, USER_LOGIN } from '../user/actions'; - -function getInitState() { - return new $Map({ list: new List() }); -} - -const archiveStore = (state = getInitState(), action) => { - switch (action.type) { - case REVIVE_TOGGLE: - return state.update('list', list => list.filter(item => item.name !== action.value)); - case RECEIVE_ARCHIVE: - return state.set('list', new List(action.value)); - case USER_LOGOUT: - case USER_LOGIN: - return getInitState(); - default: - return state; - } -}; - -export default archiveStore; diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 2949742dbe..0e27049b32 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -6,7 +6,6 @@ import featureTags from './feature-tags'; import tagTypes from './tag-type'; import tags from './tag'; import strategies from './strategy'; -import archive from './archive'; import error from './error'; import settings from './settings'; import user from './user'; @@ -27,7 +26,6 @@ const unleashStore = combineReducers({ tagTypes, tags, featureTags, - archive, error, settings, user, diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts new file mode 100644 index 0000000000..f07ad7ddc4 --- /dev/null +++ b/frontend/src/utils/storage.ts @@ -0,0 +1,29 @@ +// Get an item from localStorage. +// Returns undefined if the browser denies access. +export function getLocalStorageItem(key: string): T | undefined { + try { + return parseStoredItem(window.localStorage.getItem(key)); + } catch (err: unknown) { + console.warn(err); + } +} + +// Store an item in localStorage. +// Does nothing if the browser denies access. +export function setLocalStorageItem(key: string, value: unknown) { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (err: unknown) { + console.warn(err); + } +} + +// Parse an item from localStorage. +// Returns undefined if the item could not be parsed. +function parseStoredItem(data: string | null): T | undefined { + try { + return data ? JSON.parse(data) : undefined; + } catch (err: unknown) { + console.warn(err); + } +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index b8f8f65918..83a892a0de 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -10310,6 +10310,11 @@ react-error-overlay@^6.0.9: resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz" integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== +react-hooks-global-state@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/react-hooks-global-state/-/react-hooks-global-state-1.0.2.tgz#37bbc3203a0be9f3ac0658abfd28dd7ce7ee166c" + integrity sha512-UcWz+VjcUUCQ7bXGmOhanGII3j22zyPSjwJnQWeycxFYj/etBxIbz9xziEm4sv5+OqGuS7bzvpx24XkCxgJ7Bg== + react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"