1
0
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:
olav 2022-02-08 12:06:25 +01:00 committed by GitHub
parent f4d5ed03aa
commit ff8d983d7e
37 changed files with 550 additions and 583 deletions

View File

@ -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",

View 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
/>
);
};

View File

@ -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;

View File

@ -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;
}

View File

@ -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);
};

View File

@ -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);

View File

@ -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;

View File

@ -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,
};

View File

@ -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}
/>
);
};

View File

@ -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,

View File

@ -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);

View File

@ -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;

View File

@ -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,
},
}));

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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';

View File

@ -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: {},

View File

@ -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 = ({

View File

@ -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

View File

@ -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 };
};

View File

@ -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());
};

View File

@ -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());
};

View 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)))
);
};

View 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));
};

View 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];
};

View File

@ -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 {

View File

@ -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;

View File

@ -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;

View File

@ -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));
}

View File

@ -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,
};

View File

@ -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;

View File

@ -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,

View 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);
}
}

View File

@ -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"