mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-15 17:50:48 +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": "14.0.5",
|
||||||
"react-dnd-html5-backend": "14.1.0",
|
"react-dnd-html5-backend": "14.1.0",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
|
"react-hooks-global-state": "^1.0.2",
|
||||||
"react-outside-click-handler": "1.3.0",
|
"react-outside-click-handler": "1.3.0",
|
||||||
"react-redux": "7.2.6",
|
"react-redux": "7.2.6",
|
||||||
"react-router-dom": "5.3.0",
|
"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 { MenuItem } from '@material-ui/core';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import DropdownMenu from '../DropdownMenu/DropdownMenu';
|
import DropdownMenu from '../DropdownMenu/DropdownMenu';
|
||||||
@ -9,20 +9,8 @@ const ALL_PROJECTS = { id: '*', name: '> All projects' };
|
|||||||
const ProjectSelect = ({ currentProjectId, updateCurrentProject, ...rest }) => {
|
const ProjectSelect = ({ currentProjectId, updateCurrentProject, ...rest }) => {
|
||||||
const { projects } = useProjects();
|
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 setProject = v => {
|
||||||
const id = typeof v === 'string' ? v.trim() : '';
|
const id = v && typeof v === 'string' ? v.trim() : '*';
|
||||||
updateCurrentProject(id);
|
updateCurrentProject(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,9 +3,8 @@ import ProjectSelect from './ProjectSelect';
|
|||||||
import { fetchProjects } from '../../../store/project/actions';
|
import { fetchProjects } from '../../../store/project/actions';
|
||||||
|
|
||||||
const mapStateToProps = (state, ownProps) => ({
|
const mapStateToProps = (state, ownProps) => ({
|
||||||
|
...ownProps,
|
||||||
projects: state.projects.toJS(),
|
projects: state.projects.toJS(),
|
||||||
currentProjectId: ownProps.settings.currentProjectId || '*',
|
|
||||||
updateCurrentProject: id => ownProps.updateSetting('currentProjectId', id),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, { fetchProjects })(ProjectSelect);
|
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 PropTypes from 'prop-types';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
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 useMediaQuery from '@material-ui/core/useMediaQuery';
|
||||||
import { Add } from '@material-ui/icons';
|
import { Add } from '@material-ui/icons';
|
||||||
|
|
||||||
@ -23,43 +23,31 @@ import { useStyles } from './styles';
|
|||||||
import ListPlaceholder from '../../common/ListPlaceholder/ListPlaceholder';
|
import ListPlaceholder from '../../common/ListPlaceholder/ListPlaceholder';
|
||||||
import { getCreateTogglePath } from '../../../utils/route-path-helpers';
|
import { getCreateTogglePath } from '../../../utils/route-path-helpers';
|
||||||
import { NAVIGATE_TO_CREATE_FEATURE } from '../../../testIds';
|
import { NAVIGATE_TO_CREATE_FEATURE } from '../../../testIds';
|
||||||
|
import { resolveFilteredProjectId } from '../../../hooks/useFeaturesFilter';
|
||||||
|
|
||||||
const FeatureToggleList = ({
|
const FeatureToggleList = ({
|
||||||
fetcher,
|
|
||||||
features,
|
features,
|
||||||
settings,
|
|
||||||
revive,
|
revive,
|
||||||
currentProjectId,
|
|
||||||
updateSetting,
|
|
||||||
featureMetrics,
|
|
||||||
toggleFeature,
|
|
||||||
archive,
|
archive,
|
||||||
loading,
|
loading,
|
||||||
flags,
|
flags,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
sort,
|
||||||
|
setSort,
|
||||||
}) => {
|
}) => {
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const smallScreen = useMediaQuery('(max-width:800px)');
|
const smallScreen = useMediaQuery('(max-width:800px)');
|
||||||
const mobileView = useMediaQuery('(max-width:600px)');
|
const mobileView = useMediaQuery('(max-width:600px)');
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
const setFilterQuery = v => {
|
||||||
fetcher();
|
const query = v && typeof v === 'string' ? v.trim() : '';
|
||||||
}, [fetcher]);
|
setFilter(prev => ({ ...prev, query }));
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateSetting('filter', '');
|
|
||||||
/* eslint-disable-next-line */
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleMetrics = () => {
|
|
||||||
updateSetting('showLastHour', !settings.showLastHour);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSort = v => {
|
const resolvedProjectId = resolveFilteredProjectId(filter);
|
||||||
updateSetting('sort', typeof v === 'string' ? v.trim() : '');
|
const createURL = getCreateTogglePath(resolvedProjectId, flags.E);
|
||||||
};
|
|
||||||
|
|
||||||
const createURL = getCreateTogglePath(currentProjectId, flags.E);
|
|
||||||
|
|
||||||
const renderFeatures = () => {
|
const renderFeatures = () => {
|
||||||
features.forEach(e => {
|
features.forEach(e => {
|
||||||
@ -70,11 +58,7 @@ const FeatureToggleList = ({
|
|||||||
return loadingFeatures.map(feature => (
|
return loadingFeatures.map(feature => (
|
||||||
<FeatureToggleListItem
|
<FeatureToggleListItem
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
settings={settings}
|
|
||||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
|
||||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
toggleFeature={toggleFeature}
|
|
||||||
revive={revive}
|
revive={revive}
|
||||||
hasAccess={hasAccess}
|
hasAccess={hasAccess}
|
||||||
className={'skeleton'}
|
className={'skeleton'}
|
||||||
@ -89,13 +73,7 @@ const FeatureToggleList = ({
|
|||||||
show={features.map(feature => (
|
show={features.map(feature => (
|
||||||
<FeatureToggleListItem
|
<FeatureToggleListItem
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
settings={settings}
|
|
||||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
|
||||||
metricsLastMinute={
|
|
||||||
featureMetrics.lastMinute[feature.name]
|
|
||||||
}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
toggleFeature={toggleFeature}
|
|
||||||
revive={revive}
|
revive={revive}
|
||||||
hasAccess={hasAccess}
|
hasAccess={hasAccess}
|
||||||
flags={flags}
|
flags={flags}
|
||||||
@ -129,7 +107,7 @@ const FeatureToggleList = ({
|
|||||||
<div className={styles.featureContainer}>
|
<div className={styles.featureContainer}>
|
||||||
<div className={styles.searchBarContainer}>
|
<div className={styles.searchBarContainer}>
|
||||||
<SearchField
|
<SearchField
|
||||||
updateValue={updateSetting.bind(this, 'filter')}
|
updateValue={setFilterQuery}
|
||||||
className={classnames(styles.searchBar, {
|
className={classnames(styles.searchBar, {
|
||||||
skeleton: loading,
|
skeleton: loading,
|
||||||
})}
|
})}
|
||||||
@ -151,10 +129,10 @@ const FeatureToggleList = ({
|
|||||||
condition={!smallScreen}
|
condition={!smallScreen}
|
||||||
show={
|
show={
|
||||||
<FeatureToggleListActions
|
<FeatureToggleListActions
|
||||||
settings={settings}
|
filter={filter}
|
||||||
toggleMetrics={toggleMetrics}
|
setFilter={setFilter}
|
||||||
|
sort={sort}
|
||||||
setSort={setSort}
|
setSort={setSort}
|
||||||
updateSetting={updateSetting}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@ -175,7 +153,7 @@ const FeatureToggleList = ({
|
|||||||
disabled={
|
disabled={
|
||||||
!hasAccess(
|
!hasAccess(
|
||||||
CREATE_FEATURE,
|
CREATE_FEATURE,
|
||||||
currentProjectId
|
resolvedProjectId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -195,7 +173,7 @@ const FeatureToggleList = ({
|
|||||||
disabled={
|
disabled={
|
||||||
!hasAccess(
|
!hasAccess(
|
||||||
CREATE_FEATURE,
|
CREATE_FEATURE,
|
||||||
currentProjectId
|
resolvedProjectId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className={classnames({
|
className={classnames({
|
||||||
@ -221,16 +199,14 @@ const FeatureToggleList = ({
|
|||||||
|
|
||||||
FeatureToggleList.propTypes = {
|
FeatureToggleList.propTypes = {
|
||||||
features: PropTypes.array.isRequired,
|
features: PropTypes.array.isRequired,
|
||||||
featureMetrics: PropTypes.object.isRequired,
|
|
||||||
fetcher: PropTypes.func,
|
|
||||||
revive: PropTypes.func,
|
revive: PropTypes.func,
|
||||||
updateSetting: PropTypes.func.isRequired,
|
|
||||||
toggleFeature: PropTypes.func,
|
|
||||||
settings: PropTypes.object,
|
|
||||||
history: PropTypes.object.isRequired,
|
|
||||||
loading: PropTypes.bool,
|
loading: PropTypes.bool,
|
||||||
currentProjectId: PropTypes.string.isRequired,
|
archive: PropTypes.bool,
|
||||||
flags: PropTypes.object,
|
flags: PropTypes.object,
|
||||||
|
filter: PropTypes.object.isRequired,
|
||||||
|
setFilter: PropTypes.func.isRequired,
|
||||||
|
sort: PropTypes.object.isRequired,
|
||||||
|
setSort: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FeatureToggleList;
|
export default FeatureToggleList;
|
||||||
|
@ -2,31 +2,21 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { MenuItem, Typography } from '@material-ui/core';
|
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 DropdownMenu from '../../../common/DropdownMenu/DropdownMenu';
|
||||||
import ProjectSelect from '../../../common/ProjectSelect';
|
import ProjectSelect from '../../../common/ProjectSelect';
|
||||||
import { useStyles } from './styles';
|
import { useStyles } from './styles';
|
||||||
import useLoading from '../../../../hooks/useLoading';
|
import useLoading from '../../../../hooks/useLoading';
|
||||||
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import ConditionallyRender from '../../../common/ConditionallyRender';
|
import ConditionallyRender from '../../../common/ConditionallyRender';
|
||||||
|
import { createFeaturesFilterSortOptions } from '../../../../hooks/useFeaturesSort';
|
||||||
|
|
||||||
const sortingOptions = [
|
const sortOptions = createFeaturesFilterSortOptions();
|
||||||
{ 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 FeatureToggleListActions = ({
|
const FeatureToggleListActions = ({
|
||||||
settings,
|
filter,
|
||||||
|
setFilter,
|
||||||
|
sort,
|
||||||
setSort,
|
setSort,
|
||||||
toggleMetrics,
|
|
||||||
updateSetting,
|
|
||||||
loading,
|
loading,
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
@ -34,65 +24,33 @@ const FeatureToggleListActions = ({
|
|||||||
const ref = useLoading(loading);
|
const ref = useLoading(loading);
|
||||||
|
|
||||||
const handleSort = e => {
|
const handleSort = e => {
|
||||||
const target = e.target.getAttribute('data-target');
|
const type = e.target.getAttribute('data-target')?.trim();
|
||||||
setSort(target);
|
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 = () =>
|
const renderSortingOptions = () =>
|
||||||
sortingOptions.map(option => (
|
sortOptions.map(option => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
style={{ fontSize: '14px' }}
|
style={{ fontSize: '14px' }}
|
||||||
key={option.type}
|
key={option.type}
|
||||||
disabled={isDisabled(option.type)}
|
disabled={isDisabled(option.type)}
|
||||||
data-target={option.type}
|
data-target={option.type}
|
||||||
>
|
>
|
||||||
{option.displayName}
|
{option.name}
|
||||||
</MenuItem>
|
</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 (
|
return (
|
||||||
<div className={styles.actions} ref={ref}>
|
<div className={styles.actions} ref={ref}>
|
||||||
<Typography variant="body2" data-loading>
|
<Typography variant="body2" data-loading>
|
||||||
Sorted by:
|
Sorted by:
|
||||||
</Typography>
|
</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
|
<DropdownMenu
|
||||||
id={'sorting'}
|
id={'sorting'}
|
||||||
label={`By ${settings.sort}`}
|
label={`By ${selectedOption.name}`}
|
||||||
callback={handleSort}
|
callback={handleSort}
|
||||||
renderOptions={renderSortingOptions}
|
renderOptions={renderSortingOptions}
|
||||||
title="Sort by"
|
title="Sort by"
|
||||||
@ -104,8 +62,8 @@ const FeatureToggleListActions = ({
|
|||||||
condition={uiConfig.flags.P}
|
condition={uiConfig.flags.P}
|
||||||
show={
|
show={
|
||||||
<ProjectSelect
|
<ProjectSelect
|
||||||
settings={settings}
|
currentProjectId={filter.project}
|
||||||
updateSetting={updateSetting}
|
updateCurrentProject={project => setFilter(prev => ({ ...prev, project }))}
|
||||||
style={{
|
style={{
|
||||||
textTransform: 'lowercase',
|
textTransform: 'lowercase',
|
||||||
fontWeight: 'normal',
|
fontWeight: 'normal',
|
||||||
@ -119,10 +77,11 @@ const FeatureToggleListActions = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
FeatureToggleListActions.propTypes = {
|
FeatureToggleListActions.propTypes = {
|
||||||
settings: PropTypes.object,
|
filter: PropTypes.object,
|
||||||
|
setFilter: PropTypes.func,
|
||||||
|
sort: PropTypes.object,
|
||||||
setSort: PropTypes.func,
|
setSort: PropTypes.func,
|
||||||
toggleMetrics: PropTypes.func,
|
toggleMetrics: PropTypes.func,
|
||||||
updateSetting: PropTypes.func,
|
|
||||||
loading: PropTypes.bool,
|
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 = ({
|
const FeatureToggleListItem = ({
|
||||||
feature,
|
feature,
|
||||||
toggleFeature,
|
|
||||||
settings,
|
|
||||||
metricsLastHour = { yes: 0, no: 0, isFallback: true },
|
|
||||||
metricsLastMinute = { yes: 0, no: 0, isFallback: true },
|
|
||||||
revive,
|
revive,
|
||||||
hasAccess,
|
hasAccess,
|
||||||
flags = {},
|
flags = {},
|
||||||
@ -164,10 +160,6 @@ const FeatureToggleListItem = ({
|
|||||||
|
|
||||||
FeatureToggleListItem.propTypes = {
|
FeatureToggleListItem.propTypes = {
|
||||||
feature: PropTypes.object,
|
feature: PropTypes.object,
|
||||||
toggleFeature: PropTypes.func,
|
|
||||||
settings: PropTypes.object,
|
|
||||||
metricsLastHour: PropTypes.object,
|
|
||||||
metricsLastMinute: PropTypes.object,
|
|
||||||
revive: PropTypes.func,
|
revive: PropTypes.func,
|
||||||
hasAccess: PropTypes.func.isRequired,
|
hasAccess: PropTypes.func.isRequired,
|
||||||
flags: PropTypes.object,
|
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
|
<span
|
||||||
className="MuiButton-label"
|
className="MuiButton-label"
|
||||||
>
|
>
|
||||||
By name
|
By Name
|
||||||
<span
|
<span
|
||||||
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
||||||
>
|
>
|
||||||
@ -185,12 +185,6 @@ exports[`renders correctly with one feature 1`] = `
|
|||||||
}
|
}
|
||||||
flags={Object {}}
|
flags={Object {}}
|
||||||
hasAccess={[Function]}
|
hasAccess={[Function]}
|
||||||
settings={
|
|
||||||
Object {
|
|
||||||
"sort": "name",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toggleFeature={[MockFunction]}
|
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -315,7 +309,7 @@ exports[`renders correctly with one feature without permissions 1`] = `
|
|||||||
<span
|
<span
|
||||||
className="MuiButton-label"
|
className="MuiButton-label"
|
||||||
>
|
>
|
||||||
By name
|
By Name
|
||||||
<span
|
<span
|
||||||
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
||||||
>
|
>
|
||||||
@ -386,12 +380,6 @@ exports[`renders correctly with one feature without permissions 1`] = `
|
|||||||
}
|
}
|
||||||
flags={Object {}}
|
flags={Object {}}
|
||||||
hasAccess={[Function]}
|
hasAccess={[Function]}
|
||||||
settings={
|
|
||||||
Object {
|
|
||||||
"sort": "name",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toggleFeature={[MockFunction]}
|
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,8 +7,6 @@ import renderer from 'react-test-renderer';
|
|||||||
|
|
||||||
import theme from '../../../../themes/main-theme';
|
import theme from '../../../../themes/main-theme';
|
||||||
|
|
||||||
jest.mock('../FeatureToggleListItem/FeatureToggleListItemChip');
|
|
||||||
|
|
||||||
test('renders correctly with one feature', () => {
|
test('renders correctly with one feature', () => {
|
||||||
const feature = {
|
const feature = {
|
||||||
name: 'Another',
|
name: 'Another',
|
||||||
@ -26,18 +24,12 @@ test('renders correctly with one feature', () => {
|
|||||||
],
|
],
|
||||||
createdAt: '2018-02-04T20:27:52.127Z',
|
createdAt: '2018-02-04T20:27:52.127Z',
|
||||||
};
|
};
|
||||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
|
||||||
const settings = { sort: 'name' };
|
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<FeatureToggleListItem
|
<FeatureToggleListItem
|
||||||
key={0}
|
key={0}
|
||||||
settings={settings}
|
|
||||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
|
||||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
toggleFeature={jest.fn()}
|
|
||||||
hasAccess={() => true}
|
hasAccess={() => true}
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
@ -63,18 +55,12 @@ test('renders correctly with one feature without permission', () => {
|
|||||||
],
|
],
|
||||||
createdAt: '2018-02-04T20:27:52.127Z',
|
createdAt: '2018-02-04T20:27:52.127Z',
|
||||||
};
|
};
|
||||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
|
||||||
const settings = { sort: 'name' };
|
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<FeatureToggleListItem
|
<FeatureToggleListItem
|
||||||
key={0}
|
key={0}
|
||||||
settings={settings}
|
|
||||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
|
||||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
toggleFeature={jest.fn()}
|
|
||||||
hasAccess={() => true}
|
hasAccess={() => true}
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
@ -25,8 +25,7 @@ test('renders correctly with one feature', () => {
|
|||||||
name: 'Another',
|
name: 'Another',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
|
||||||
const settings = { sort: 'name' };
|
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
@ -35,13 +34,12 @@ test('renders correctly with one feature', () => {
|
|||||||
>
|
>
|
||||||
<FeatureToggleList
|
<FeatureToggleList
|
||||||
updateSetting={jest.fn()}
|
updateSetting={jest.fn()}
|
||||||
settings={settings}
|
filter={{}}
|
||||||
history={{}}
|
setFilter={jest.fn()}
|
||||||
featureMetrics={featureMetrics}
|
sort={{}}
|
||||||
|
setSort={jest.fn()}
|
||||||
features={features}
|
features={features}
|
||||||
toggleFeature={jest.fn()}
|
|
||||||
fetcher={jest.fn()}
|
fetcher={jest.fn()}
|
||||||
currentProjectId="default"
|
|
||||||
flags={{}}
|
flags={{}}
|
||||||
/>
|
/>
|
||||||
</AccessProvider>
|
</AccessProvider>
|
||||||
@ -58,8 +56,6 @@ test('renders correctly with one feature without permissions', () => {
|
|||||||
name: 'Another',
|
name: 'Another',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
|
||||||
const settings = { sort: 'name' };
|
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
@ -67,14 +63,12 @@ test('renders correctly with one feature without permissions', () => {
|
|||||||
store={createFakeStore([{ permission: CREATE_FEATURE }])}
|
store={createFakeStore([{ permission: CREATE_FEATURE }])}
|
||||||
>
|
>
|
||||||
<FeatureToggleList
|
<FeatureToggleList
|
||||||
updateSetting={jest.fn()}
|
filter={{}}
|
||||||
settings={settings}
|
setFilter={jest.fn()}
|
||||||
history={{}}
|
sort={{}}
|
||||||
featureMetrics={featureMetrics}
|
setSort={jest.fn()}
|
||||||
features={features}
|
features={features}
|
||||||
toggleFeature={jest.fn()}
|
|
||||||
fetcher={jest.fn()}
|
fetcher={jest.fn()}
|
||||||
currentProjectId="default"
|
|
||||||
flags={{}}
|
flags={{}}
|
||||||
/>
|
/>
|
||||||
</AccessProvider>
|
</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 { useEffect, useState } from 'react';
|
||||||
import { Redirect, useParams } from 'react-router-dom';
|
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 { IFeatureToggle } from '../../../interfaces/featureToggle';
|
||||||
import { getTogglePath } from '../../../utils/route-path-helpers';
|
import { getTogglePath } from '../../../utils/route-path-helpers';
|
||||||
|
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import CopyFeatureToggle from '../../page/features/copy';
|
import CopyFeatureToggle from '../../page/features/copy';
|
||||||
import Features from '../../page/features';
|
import { FeatureToggleListContainer } from '../feature/FeatureToggleList/FeatureToggleListContainer';
|
||||||
import CreateStrategies from '../../page/strategies/create';
|
import CreateStrategies from '../../page/strategies/create';
|
||||||
import StrategyView from '../../page/strategies/show';
|
import StrategyView from '../../page/strategies/show';
|
||||||
import Strategies from '../../page/strategies';
|
import Strategies from '../../page/strategies';
|
||||||
import HistoryPage from '../../page/history';
|
import HistoryPage from '../../page/history';
|
||||||
import HistoryTogglePage from '../../page/history/toggle';
|
import HistoryTogglePage from '../../page/history/toggle';
|
||||||
import Archive from '../../page/archive';
|
import { ArchiveListContainer } from '../archive/ArchiveListContainer';
|
||||||
import Applications from '../../page/applications';
|
import Applications from '../../page/applications';
|
||||||
import ApplicationView from '../../page/applications/view';
|
import ApplicationView from '../../page/applications/view';
|
||||||
import ListTagTypes from '../../page/tag-types';
|
import ListTagTypes from '../../page/tag-types';
|
||||||
@ -24,7 +24,7 @@ import ResetPassword from '../user/ResetPassword/ResetPassword';
|
|||||||
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
|
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
|
||||||
import ProjectListNew from '../project/ProjectList/ProjectList';
|
import ProjectListNew from '../project/ProjectList/ProjectList';
|
||||||
import Project from '../project/Project/Project';
|
import Project from '../project/Project/Project';
|
||||||
import RedirectArchive from '../feature/RedirectArchive/RedirectArchive';
|
import RedirectArchive from '../archive/RedirectArchive';
|
||||||
import EnvironmentList from '../environments/EnvironmentList/EnvironmentList';
|
import EnvironmentList from '../environments/EnvironmentList/EnvironmentList';
|
||||||
import FeatureView from '../feature/FeatureView/FeatureView';
|
import FeatureView from '../feature/FeatureView/FeatureView';
|
||||||
import ProjectRoles from '../admin/project-roles/ProjectRoles/ProjectRoles';
|
import ProjectRoles from '../admin/project-roles/ProjectRoles/ProjectRoles';
|
||||||
@ -182,7 +182,7 @@ export const routes = [
|
|||||||
{
|
{
|
||||||
path: '/features',
|
path: '/features',
|
||||||
title: 'Feature Toggles',
|
title: 'Feature Toggles',
|
||||||
component: Features,
|
component: FeatureToggleListContainer,
|
||||||
type: 'protected',
|
type: 'protected',
|
||||||
layout: 'main',
|
layout: 'main',
|
||||||
menu: { mobile: true },
|
menu: { mobile: true },
|
||||||
@ -372,7 +372,7 @@ export const routes = [
|
|||||||
{
|
{
|
||||||
path: '/archive',
|
path: '/archive',
|
||||||
title: 'Archived Toggles',
|
title: 'Archived Toggles',
|
||||||
component: Archive,
|
component: ArchiveListContainer,
|
||||||
type: 'protected',
|
type: 'protected',
|
||||||
layout: 'main',
|
layout: 'main',
|
||||||
menu: {},
|
menu: {},
|
||||||
|
@ -8,11 +8,11 @@ import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
|
|||||||
import { useStyles } from './UserProfile.styles';
|
import { useStyles } from './UserProfile.styles';
|
||||||
import { useCommonStyles } from '../../../common.styles';
|
import { useCommonStyles } from '../../../common.styles';
|
||||||
import UserProfileContent from './UserProfileContent/UserProfileContent';
|
import UserProfileContent from './UserProfileContent/UserProfileContent';
|
||||||
import { IUser } from "../../../interfaces/user";
|
import { IUser } from '../../../interfaces/user';
|
||||||
|
|
||||||
interface IUserProfileProps {
|
interface IUserProfileProps {
|
||||||
profile: IUser
|
profile: IUser;
|
||||||
updateSettingLocation: (field: 'locale', value: string) => void
|
updateSettingLocation: (field: 'locale', value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserProfile = ({
|
const UserProfile = ({
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import ConditionallyRender from '../../../common/ConditionallyRender';
|
import ConditionallyRender from '../../../common/ConditionallyRender';
|
||||||
import {
|
import {
|
||||||
Paper,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
Typography,
|
|
||||||
Button,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
Select,
|
|
||||||
InputLabel,
|
InputLabel,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Typography,
|
||||||
} from '@material-ui/core';
|
} from '@material-ui/core';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { useStyles } from './UserProfileContent.styles';
|
import { useStyles } from './UserProfileContent.styles';
|
||||||
@ -17,16 +17,16 @@ import EditProfile from '../EditProfile/EditProfile';
|
|||||||
import legacyStyles from '../../user.module.scss';
|
import legacyStyles from '../../user.module.scss';
|
||||||
import { getBasePath } from '../../../../utils/format-path';
|
import { getBasePath } from '../../../../utils/format-path';
|
||||||
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { IUser } from "../../../../interfaces/user";
|
import { IUser } from '../../../../interfaces/user';
|
||||||
|
|
||||||
interface IUserProfileContentProps {
|
interface IUserProfileContentProps {
|
||||||
showProfile: boolean
|
showProfile: boolean;
|
||||||
profile: IUser
|
profile: IUser;
|
||||||
possibleLocales: string[]
|
possibleLocales: string[];
|
||||||
updateSettingLocation: (field: 'locale', value: string) => void
|
updateSettingLocation: (field: 'locale', value: string) => void;
|
||||||
imageUrl: string
|
imageUrl: string;
|
||||||
currentLocale?: string
|
currentLocale?: string;
|
||||||
setCurrentLocale: (value: string) => void
|
setCurrentLocale: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserProfileContent = ({
|
const UserProfileContent = ({
|
||||||
@ -99,14 +99,19 @@ const UserProfileContent = ({
|
|||||||
condition={!editingProfile}
|
condition={!editingProfile}
|
||||||
show={
|
show={
|
||||||
<>
|
<>
|
||||||
<ConditionallyRender condition={!uiConfig.disablePasswordAuth} show={
|
<ConditionallyRender
|
||||||
|
condition={!uiConfig.disablePasswordAuth}
|
||||||
|
show={
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => setEditingProfile(true)}
|
onClick={() =>
|
||||||
|
setEditingProfile(true)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Update password
|
Update password
|
||||||
</Button>
|
</Button>
|
||||||
} />
|
}
|
||||||
|
/>
|
||||||
<div className={commonStyles.divider} />
|
<div className={commonStyles.divider} />
|
||||||
<div className={legacyStyles.showUserSettings}>
|
<div className={legacyStyles.showUserSettings}>
|
||||||
<FormControl
|
<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 useSWR, { mutate, SWRConfiguration } from 'swr';
|
||||||
import { useState, useEffect } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { formatApiPath } from '../../../../utils/format-path';
|
import { formatApiPath } from '../../../../utils/format-path';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
import { IFeatureToggle } from '../../../../interfaces/featureToggle';
|
||||||
|
|
||||||
const useFeatures = (options: SWRConfiguration = {}) => {
|
const PATH = formatApiPath('api/admin/features');
|
||||||
const fetcher = async () => {
|
|
||||||
const path = formatApiPath('api/admin/features/');
|
|
||||||
return fetch(path, {
|
|
||||||
method: 'GET',
|
|
||||||
})
|
|
||||||
.then(handleErrorResponses('Features'))
|
|
||||||
.then(res => res.json());
|
|
||||||
};
|
|
||||||
|
|
||||||
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, {
|
export const useFeatures = (options?: SWRConfiguration): IUseFeaturesOutput => {
|
||||||
...options,
|
const { data, error } = useSWR<{ features: IFeatureToggle[] }>(
|
||||||
});
|
PATH,
|
||||||
|
fetchFeatures,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(!error && !data);
|
const refetchFeatures = useCallback(() => {
|
||||||
|
mutate(PATH).catch(console.warn);
|
||||||
const refetchFeatures = () => {
|
}, []);
|
||||||
mutate(FEATURES_CACHE_KEY);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(!error && !data);
|
|
||||||
}, [data, error]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
features: data?.features || [],
|
features: data?.features || [],
|
||||||
error,
|
loading: !error && !data,
|
||||||
loading,
|
|
||||||
refetchFeatures,
|
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 {
|
export interface IFeatureToggle {
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
createdAt: Date;
|
enabled?: boolean;
|
||||||
lastSeenAt?: Date;
|
createdAt: string;
|
||||||
|
lastSeenAt?: string;
|
||||||
description: string;
|
description: string;
|
||||||
environments: IFeatureEnvironment[];
|
environments: IFeatureEnvironment[];
|
||||||
name: string;
|
name: string;
|
||||||
@ -41,6 +42,7 @@ export interface IFeatureToggle {
|
|||||||
type: string;
|
type: string;
|
||||||
variants: IFeatureVariant[];
|
variants: IFeatureVariant[];
|
||||||
impressionData: boolean;
|
impressionData: boolean;
|
||||||
|
strategies?: IFeatureStrategy[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFeatureEnvironment {
|
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 tagTypes from './tag-type';
|
||||||
import tags from './tag';
|
import tags from './tag';
|
||||||
import strategies from './strategy';
|
import strategies from './strategy';
|
||||||
import archive from './archive';
|
|
||||||
import error from './error';
|
import error from './error';
|
||||||
import settings from './settings';
|
import settings from './settings';
|
||||||
import user from './user';
|
import user from './user';
|
||||||
@ -27,7 +26,6 @@ const unleashStore = combineReducers({
|
|||||||
tagTypes,
|
tagTypes,
|
||||||
tags,
|
tags,
|
||||||
featureTags,
|
featureTags,
|
||||||
archive,
|
|
||||||
error,
|
error,
|
||||||
settings,
|
settings,
|
||||||
user,
|
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"
|
resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz"
|
||||||
integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==
|
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:
|
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"
|
version "16.13.1"
|
||||||
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
||||||
|
Loading…
Reference in New Issue
Block a user