mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02:00
refactor: port FeatureToggleList to TS/SWR (#663)
* refactor: remove unused FeatureToggleListItemChip * refactor: remove unused archive.module.scss * refactor: remove unused ShowArchive route * refactor: port FeatureToggleList to TS/SWR * refactor: fix IUseFeaturesOutput interface prefix * refactor: remove unnecessary pages files * refactor: persist the features sort/filter state * refactor: format files * refactor: fix FeatureToggleListContainer file name * refactor: fix arrow function syntax * refactor: improve storage helper comments
This commit is contained in:
parent
f4d5ed03aa
commit
ff8d983d7e
@ -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",
|
||||
|
36
frontend/src/component/archive/ArchiveListContainer.tsx
Normal file
36
frontend/src/component/archive/ArchiveListContainer.tsx
Normal file
@ -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 (
|
||||
<FeatureToggleList
|
||||
features={sorted}
|
||||
loading={loading}
|
||||
revive={revive}
|
||||
flags={uiConfig.flags}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sort={sort}
|
||||
setSort={setSort}
|
||||
archive
|
||||
/>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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 => (
|
||||
<FeatureToggleListItem
|
||||
key={feature.name}
|
||||
settings={settings}
|
||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
||||
feature={feature}
|
||||
toggleFeature={toggleFeature}
|
||||
revive={revive}
|
||||
hasAccess={hasAccess}
|
||||
className={'skeleton'}
|
||||
@ -89,13 +73,7 @@ const FeatureToggleList = ({
|
||||
show={features.map(feature => (
|
||||
<FeatureToggleListItem
|
||||
key={feature.name}
|
||||
settings={settings}
|
||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
||||
metricsLastMinute={
|
||||
featureMetrics.lastMinute[feature.name]
|
||||
}
|
||||
feature={feature}
|
||||
toggleFeature={toggleFeature}
|
||||
revive={revive}
|
||||
hasAccess={hasAccess}
|
||||
flags={flags}
|
||||
@ -129,7 +107,7 @@ const FeatureToggleList = ({
|
||||
<div className={styles.featureContainer}>
|
||||
<div className={styles.searchBarContainer}>
|
||||
<SearchField
|
||||
updateValue={updateSetting.bind(this, 'filter')}
|
||||
updateValue={setFilterQuery}
|
||||
className={classnames(styles.searchBar, {
|
||||
skeleton: loading,
|
||||
})}
|
||||
@ -151,10 +129,10 @@ const FeatureToggleList = ({
|
||||
condition={!smallScreen}
|
||||
show={
|
||||
<FeatureToggleListActions
|
||||
settings={settings}
|
||||
toggleMetrics={toggleMetrics}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sort={sort}
|
||||
setSort={setSort}
|
||||
updateSetting={updateSetting}
|
||||
loading={loading}
|
||||
/>
|
||||
}
|
||||
@ -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;
|
||||
|
@ -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 => (
|
||||
<MenuItem
|
||||
style={{ fontSize: '14px' }}
|
||||
key={option.type}
|
||||
disabled={isDisabled(option.type)}
|
||||
data-target={option.type}
|
||||
>
|
||||
{option.displayName}
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
/*
|
||||
const renderMetricsOptions = () => [
|
||||
<MenuItemWithIcon
|
||||
style={{ fontSize: '14px' }}
|
||||
icon={HourglassEmpty}
|
||||
disabled={!settings.showLastHour}
|
||||
data-target="minute"
|
||||
label="Last minute"
|
||||
key={1}
|
||||
/>,
|
||||
<MenuItemWithIcon
|
||||
style={{ fontSize: '14px' }}
|
||||
icon={HourglassFull}
|
||||
disabled={settings.showLastHour}
|
||||
data-target="hour"
|
||||
label="Last hour"
|
||||
key={2}
|
||||
/>,
|
||||
];
|
||||
*/
|
||||
|
||||
return (
|
||||
<div className={styles.actions} ref={ref}>
|
||||
<Typography variant="body2" data-loading>
|
||||
Sorted by:
|
||||
</Typography>
|
||||
{/* }
|
||||
<DropdownMenu
|
||||
id={'metric'}
|
||||
label={`Last ${settings.showLastHour ? 'hour' : 'minute'}`}
|
||||
title="Metric interval"
|
||||
callback={toggleMetrics}
|
||||
renderOptions={renderMetricsOptions}
|
||||
className=""
|
||||
style={{ textTransform: 'lowercase', fontWeight: 'normal' }}
|
||||
data-loading
|
||||
/>
|
||||
{*/}
|
||||
<DropdownMenu
|
||||
id={'sorting'}
|
||||
label={`By ${settings.sort}`}
|
||||
label={`By ${selectedOption.name}`}
|
||||
callback={handleSort}
|
||||
renderOptions={renderSortingOptions}
|
||||
title="Sort by"
|
||||
@ -104,8 +62,8 @@ const FeatureToggleListActions = ({
|
||||
condition={uiConfig.flags.P}
|
||||
show={
|
||||
<ProjectSelect
|
||||
settings={settings}
|
||||
updateSetting={updateSetting}
|
||||
currentProjectId={filter.project}
|
||||
updateCurrentProject={project => 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,
|
||||
};
|
||||
|
||||
|
@ -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 (
|
||||
<FeatureToggleList
|
||||
features={sorted}
|
||||
loading={loading}
|
||||
flags={uiConfig.flags}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sort={sort}
|
||||
setSort={setSort}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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,
|
||||
|
@ -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 (
|
||||
<Chip className={styles.typeChip} title={typeObject.description} label={typeObject.name} onClick={onClick} />
|
||||
);
|
||||
};
|
||||
|
||||
FeatureToggleListItemChip.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
types: PropTypes.array,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default memo(FeatureToggleListItemChip);
|
@ -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;
|
@ -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,
|
||||
},
|
||||
}));
|
@ -117,7 +117,7 @@ exports[`renders correctly with one feature 1`] = `
|
||||
<span
|
||||
className="MuiButton-label"
|
||||
>
|
||||
By name
|
||||
By Name
|
||||
<span
|
||||
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
||||
>
|
||||
@ -185,12 +185,6 @@ exports[`renders correctly with one feature 1`] = `
|
||||
}
|
||||
flags={Object {}}
|
||||
hasAccess={[Function]}
|
||||
settings={
|
||||
Object {
|
||||
"sort": "name",
|
||||
}
|
||||
}
|
||||
toggleFeature={[MockFunction]}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
@ -315,7 +309,7 @@ exports[`renders correctly with one feature without permissions 1`] = `
|
||||
<span
|
||||
className="MuiButton-label"
|
||||
>
|
||||
By name
|
||||
By Name
|
||||
<span
|
||||
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
||||
>
|
||||
@ -386,12 +380,6 @@ exports[`renders correctly with one feature without permissions 1`] = `
|
||||
}
|
||||
flags={Object {}}
|
||||
hasAccess={[Function]}
|
||||
settings={
|
||||
Object {
|
||||
"sort": "name",
|
||||
}
|
||||
}
|
||||
toggleFeature={[MockFunction]}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -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(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
<FeatureToggleListItem
|
||||
key={0}
|
||||
settings={settings}
|
||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
||||
feature={feature}
|
||||
toggleFeature={jest.fn()}
|
||||
hasAccess={() => true}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
@ -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(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
<FeatureToggleListItem
|
||||
key={0}
|
||||
settings={settings}
|
||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
||||
feature={feature}
|
||||
toggleFeature={jest.fn()}
|
||||
hasAccess={() => true}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
|
@ -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(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
@ -35,13 +34,12 @@ test('renders correctly with one feature', () => {
|
||||
>
|
||||
<FeatureToggleList
|
||||
updateSetting={jest.fn()}
|
||||
settings={settings}
|
||||
history={{}}
|
||||
featureMetrics={featureMetrics}
|
||||
filter={{}}
|
||||
setFilter={jest.fn()}
|
||||
sort={{}}
|
||||
setSort={jest.fn()}
|
||||
features={features}
|
||||
toggleFeature={jest.fn()}
|
||||
fetcher={jest.fn()}
|
||||
currentProjectId="default"
|
||||
flags={{}}
|
||||
/>
|
||||
</AccessProvider>
|
||||
@ -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(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
@ -67,14 +63,12 @@ test('renders correctly with one feature without permissions', () => {
|
||||
store={createFakeStore([{ permission: CREATE_FEATURE }])}
|
||||
>
|
||||
<FeatureToggleList
|
||||
updateSetting={jest.fn()}
|
||||
settings={settings}
|
||||
history={{}}
|
||||
featureMetrics={featureMetrics}
|
||||
filter={{}}
|
||||
setFilter={jest.fn()}
|
||||
sort={{}}
|
||||
setSort={jest.fn()}
|
||||
features={features}
|
||||
toggleFeature={jest.fn()}
|
||||
fetcher={jest.fn()}
|
||||
currentProjectId="default"
|
||||
flags={{}}
|
||||
/>
|
||||
</AccessProvider>
|
||||
|
@ -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;
|
@ -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';
|
||||
|
||||
|
@ -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: {},
|
||||
|
@ -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 = ({
|
||||
|
@ -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={
|
||||
<>
|
||||
<ConditionallyRender condition={!uiConfig.disablePasswordAuth} show={
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setEditingProfile(true)}
|
||||
>
|
||||
Update password
|
||||
</Button>
|
||||
} />
|
||||
<ConditionallyRender
|
||||
condition={!uiConfig.disablePasswordAuth}
|
||||
show={
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() =>
|
||||
setEditingProfile(true)
|
||||
}
|
||||
>
|
||||
Update password
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className={commonStyles.divider} />
|
||||
<div className={legacyStyles.showUserSettings}>
|
||||
<FormControl
|
||||
|
@ -0,0 +1,15 @@
|
||||
import useAPI from '../useApi/useApi';
|
||||
|
||||
export const useFeatureArchiveApi = () => {
|
||||
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 };
|
||||
};
|
@ -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());
|
||||
};
|
||||
|
@ -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());
|
||||
};
|
117
frontend/src/hooks/useFeaturesFilter.ts
Normal file
117
frontend/src/hooks/useFeaturesFilter.ts
Normal file
@ -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<React.SetStateAction<IFeaturesFilter>>
|
||||
}
|
||||
|
||||
// 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`,
|
||||
{ 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)))
|
||||
);
|
||||
};
|
137
frontend/src/hooks/useFeaturesSort.ts
Normal file
137
frontend/src/hooks/useFeaturesSort.ts
Normal file
@ -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<React.SetStateAction<IFeaturesSort>>
|
||||
}
|
||||
|
||||
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<IFeaturesSort>(
|
||||
`${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[]>
|
||||
): IFeatureToggle[] => {
|
||||
return [...features].sort((a, b) =>
|
||||
a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1
|
||||
);
|
||||
};
|
||||
|
||||
const sortByStale = (
|
||||
features: Readonly<IFeatureToggle[]>
|
||||
): IFeatureToggle[] => {
|
||||
return [...features].sort((a, b) =>
|
||||
a.stale === b.stale ? 0 : a.stale ? -1 : 1
|
||||
);
|
||||
};
|
||||
|
||||
const sortByLastSeen = (
|
||||
features: Readonly<IFeatureToggle[]>
|
||||
): IFeatureToggle[] => {
|
||||
return [...features].sort((a, b) =>
|
||||
a.lastSeenAt && b.lastSeenAt
|
||||
? a.lastSeenAt.localeCompare(b.lastSeenAt)
|
||||
: 0
|
||||
);
|
||||
};
|
||||
|
||||
const sortByCreated = (
|
||||
features: Readonly<IFeatureToggle[]>
|
||||
): IFeatureToggle[] => {
|
||||
return [...features].sort((a, b) =>
|
||||
new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1
|
||||
);
|
||||
};
|
||||
|
||||
const sortByName = (features: Readonly<IFeatureToggle[]>): IFeatureToggle[] => {
|
||||
return [...features].sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
const sortByProject = (
|
||||
features: Readonly<IFeatureToggle[]>
|
||||
): IFeatureToggle[] => {
|
||||
return [...features].sort((a, b) => a.project.localeCompare(b.project));
|
||||
};
|
||||
|
||||
const sortByType = (features: Readonly<IFeatureToggle[]>): IFeatureToggle[] => {
|
||||
return [...features].sort((a, b) => a.type.localeCompare(b.type));
|
||||
};
|
29
frontend/src/hooks/usePersistentGlobalState.ts
Normal file
29
frontend/src/hooks/usePersistentGlobalState.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { createGlobalState } from 'react-hooks-global-state';
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage';
|
||||
|
||||
type UsePersistentGlobalState<T> = () => [
|
||||
value: T,
|
||||
setValue: React.Dispatch<React.SetStateAction<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>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): UsePersistentGlobalState<T> => {
|
||||
const container = createGlobalState<{ [key: string]: T }>({
|
||||
[key]: getLocalStorageItem(key) ?? initialValue,
|
||||
});
|
||||
|
||||
const setGlobalState = (value: React.SetStateAction<T>) => {
|
||||
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];
|
||||
};
|
@ -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 {
|
||||
|
@ -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 }) => <Archive name={params.name} history={history} />;
|
||||
render.propTypes = {
|
||||
match: PropTypes.object,
|
||||
history: PropTypes.object,
|
||||
};
|
||||
|
||||
export default render;
|
@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
import FeatureListContainer from '../../component/feature/FeatureToggleList';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const render = ({ history }) => <FeatureListContainer history={history} />;
|
||||
|
||||
render.propTypes = {
|
||||
history: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default render;
|
@ -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));
|
||||
}
|
@ -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,
|
||||
};
|
@ -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;
|
@ -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,
|
||||
|
29
frontend/src/utils/storage.ts
Normal file
29
frontend/src/utils/storage.ts
Normal file
@ -0,0 +1,29 @@
|
||||
// Get an item from localStorage.
|
||||
// Returns undefined if the browser denies access.
|
||||
export function getLocalStorageItem<T>(key: string): T | undefined {
|
||||
try {
|
||||
return parseStoredItem<T>(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<T>(data: string | null): T | undefined {
|
||||
try {
|
||||
return data ? JSON.parse(data) : undefined;
|
||||
} catch (err: unknown) {
|
||||
console.warn(err);
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user