mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-23 01:16:27 +02:00
Feat/group by projects (#308)
This PR adds support for projects as a first class citizen, and toggling features on in different environments.
This commit is contained in:
parent
151fccc262
commit
85a7c55fdf
@ -46,6 +46,7 @@ body {
|
|||||||
background-color: #e2e8f0;
|
background-color: #e2e8f0;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
fill: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton::before {
|
.skeleton::before {
|
||||||
|
9
frontend/src/assets/icons/projectIcon.svg
Normal file
9
frontend/src/assets/icons/projectIcon.svg
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<svg width="88" height="40" viewBox="0 0 88 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="9" height="39" fill="#635DC5"/>
|
||||||
|
<rect x="13" width="10" height="39" fill="#635DC5"/>
|
||||||
|
<rect x="27" y="6" width="9" height="33" fill="#635DC5"/>
|
||||||
|
<rect x="40" y="12" width="9" height="27" fill="#635DC5"/>
|
||||||
|
<rect x="52" y="15" width="9" height="24" fill="#635DC5"/>
|
||||||
|
<rect x="65.0752" y="15.2578" width="9.2957" height="23.9101" fill="#635DC5"/>
|
||||||
|
<rect x="78.355" y="19.0352" width="9.2957" height="20.1348" fill="#635DC5"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 534 B |
@ -62,4 +62,21 @@ export const useCommonStyles = makeStyles(theme => ({
|
|||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
marginBottom: '0.5rem',
|
marginBottom: '0.5rem',
|
||||||
},
|
},
|
||||||
|
fadeInBottomStart: {
|
||||||
|
opacity: '0',
|
||||||
|
position: 'fixed',
|
||||||
|
right: '40px',
|
||||||
|
bottom: '40px',
|
||||||
|
transform: 'translateY(400px)',
|
||||||
|
},
|
||||||
|
fadeInBottomEnter: {
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
opacity: '1',
|
||||||
|
transition: 'transform 0.6s ease, opacity 1s ease',
|
||||||
|
},
|
||||||
|
fadeInBottomLeave: {
|
||||||
|
transform: 'translateY(400px)',
|
||||||
|
opacity: '0',
|
||||||
|
transition: 'transform 1.25s ease, opacity 1s ease',
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -7,61 +7,16 @@ import CheckIcon from '@material-ui/icons/Check';
|
|||||||
import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined';
|
import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined';
|
||||||
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
|
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
import { isFeatureExpired } from '../utils';
|
|
||||||
|
|
||||||
import styles from './ReportCard.module.scss';
|
import styles from './ReportCard.module.scss';
|
||||||
|
|
||||||
const ReportCard = ({ features }) => {
|
const ReportCard = ({
|
||||||
const getActiveToggles = () => {
|
health,
|
||||||
const result = features.filter(feature => !feature.stale);
|
activeCount,
|
||||||
|
staleCount,
|
||||||
if (result === 0) return 0;
|
potentiallyStaleCount,
|
||||||
|
}) => {
|
||||||
return result;
|
const healthLessThan50 = health < 50;
|
||||||
};
|
const healthLessThan75 = health < 75;
|
||||||
|
|
||||||
const getPotentiallyStaleToggles = activeToggles => {
|
|
||||||
const result = activeToggles.filter(
|
|
||||||
feature => isFeatureExpired(feature) && !feature.stale
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHealthRating = (
|
|
||||||
total,
|
|
||||||
staleTogglesCount,
|
|
||||||
potentiallyStaleTogglesCount
|
|
||||||
) => {
|
|
||||||
const startPercentage = 100;
|
|
||||||
|
|
||||||
const stalePercentage = (staleTogglesCount / total) * 100;
|
|
||||||
|
|
||||||
const potentiallyStalePercentage =
|
|
||||||
(potentiallyStaleTogglesCount / total) * 100;
|
|
||||||
|
|
||||||
return Math.round(
|
|
||||||
startPercentage - stalePercentage - potentiallyStalePercentage
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const total = features.length;
|
|
||||||
const activeTogglesArray = getActiveToggles();
|
|
||||||
const potentiallyStaleToggles =
|
|
||||||
getPotentiallyStaleToggles(activeTogglesArray);
|
|
||||||
|
|
||||||
const activeTogglesCount = activeTogglesArray.length;
|
|
||||||
const staleTogglesCount = features.length - activeTogglesCount;
|
|
||||||
const potentiallyStaleTogglesCount = potentiallyStaleToggles.length;
|
|
||||||
|
|
||||||
const healthRating = getHealthRating(
|
|
||||||
total,
|
|
||||||
staleTogglesCount,
|
|
||||||
potentiallyStaleTogglesCount
|
|
||||||
);
|
|
||||||
|
|
||||||
const healthLessThan50 = healthRating < 50;
|
|
||||||
const healthLessThan75 = healthRating < 75;
|
|
||||||
|
|
||||||
const healthClasses = classnames(styles.reportCardHealthRating, {
|
const healthClasses = classnames(styles.reportCardHealthRating, {
|
||||||
[styles.healthWarning]: healthLessThan75,
|
[styles.healthWarning]: healthLessThan75,
|
||||||
@ -71,23 +26,21 @@ const ReportCard = ({ features }) => {
|
|||||||
const renderActiveToggles = () => (
|
const renderActiveToggles = () => (
|
||||||
<>
|
<>
|
||||||
<CheckIcon className={styles.check} />
|
<CheckIcon className={styles.check} />
|
||||||
<span>{activeTogglesCount} active toggles</span>
|
<span>{activeCount} active toggles</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStaleToggles = () => (
|
const renderStaleToggles = () => (
|
||||||
<>
|
<>
|
||||||
<ReportProblemOutlinedIcon className={styles.danger} />
|
<ReportProblemOutlinedIcon className={styles.danger} />
|
||||||
<span>{staleTogglesCount} stale toggles</span>
|
<span>{staleCount} stale toggles</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPotentiallyStaleToggles = () => (
|
const renderPotentiallyStaleToggles = () => (
|
||||||
<>
|
<>
|
||||||
<ReportProblemOutlinedIcon className={styles.danger} />
|
<ReportProblemOutlinedIcon className={styles.danger} />
|
||||||
<span>
|
<span>{potentiallyStaleCount} potentially stale toggles</span>
|
||||||
{potentiallyStaleTogglesCount} potentially stale toggles
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -98,10 +51,8 @@ const ReportCard = ({ features }) => {
|
|||||||
<h2 className={styles.header}>Health rating</h2>
|
<h2 className={styles.header}>Health rating</h2>
|
||||||
<div className={styles.reportCardHealthInnerContainer}>
|
<div className={styles.reportCardHealthInnerContainer}>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={healthRating > -1}
|
condition={health > -1}
|
||||||
show={
|
show={<p className={healthClasses}>{health}%</p>}
|
||||||
<p className={healthClasses}>{healthRating}%</p>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -110,19 +61,19 @@ const ReportCard = ({ features }) => {
|
|||||||
<ul className={styles.reportCardList}>
|
<ul className={styles.reportCardList}>
|
||||||
<li>
|
<li>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={activeTogglesCount}
|
condition={activeCount}
|
||||||
show={renderActiveToggles}
|
show={renderActiveToggles}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={staleTogglesCount}
|
condition={staleCount}
|
||||||
show={renderStaleToggles}
|
show={renderStaleToggles}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={potentiallyStaleTogglesCount}
|
condition={potentiallyStaleCount}
|
||||||
show={renderPotentiallyStaleToggles}
|
show={renderPotentiallyStaleToggles}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
@ -13,7 +13,7 @@ import {
|
|||||||
applyCheckedToFeatures,
|
applyCheckedToFeatures,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
import useSort from '../useSort';
|
import useSort from '../../../hooks/useSort';
|
||||||
|
|
||||||
import styles from './ReportToggleList.module.scss';
|
import styles from './ReportToggleList.module.scss';
|
||||||
|
|
||||||
|
@ -1,20 +1,12 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { filterByProject } from '../utils';
|
|
||||||
|
|
||||||
import ReportToggleList from './ReportToggleList';
|
import ReportToggleList from './ReportToggleList';
|
||||||
|
|
||||||
const mapStateToProps = (state, ownProps) => {
|
const mapStateToProps = (state, ownProps) => {};
|
||||||
const features = state.features.toJS();
|
|
||||||
|
|
||||||
const sameProject = filterByProject(ownProps.selectedProject);
|
const ReportToggleListContainer = connect(
|
||||||
const featuresByProject = features.filter(sameProject);
|
mapStateToProps,
|
||||||
|
null
|
||||||
return {
|
)(ReportToggleList);
|
||||||
features: featuresByProject,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const ReportToggleListContainer = connect(mapStateToProps, null)(ReportToggleList);
|
|
||||||
|
|
||||||
export default ReportToggleListContainer;
|
export default ReportToggleListContainer;
|
||||||
|
@ -15,7 +15,10 @@ import {
|
|||||||
toggleExpiryByTypeMap,
|
toggleExpiryByTypeMap,
|
||||||
getDiffInDays,
|
getDiffInDays,
|
||||||
} from '../../utils';
|
} from '../../utils';
|
||||||
import { KILLSWITCH, PERMISSION } from '../../constants';
|
import {
|
||||||
|
KILLSWITCH,
|
||||||
|
PERMISSION,
|
||||||
|
} from '../../../../constants/featureToggleTypes';
|
||||||
|
|
||||||
import styles from '../ReportToggleList.module.scss';
|
import styles from '../ReportToggleList.module.scss';
|
||||||
|
|
||||||
|
@ -12,15 +12,17 @@ import { formatProjectOptions } from './utils';
|
|||||||
import { REPORTING_SELECT_ID } from '../../testIds';
|
import { REPORTING_SELECT_ID } from '../../testIds';
|
||||||
|
|
||||||
import styles from './Reporting.module.scss';
|
import styles from './Reporting.module.scss';
|
||||||
|
import useHealthReport from '../../hooks/api/getters/useHealthReport/useHealthReport';
|
||||||
|
import ApiError from '../common/ApiError/ApiError';
|
||||||
|
|
||||||
const Reporting = ({ fetchFeatureToggles, projects }) => {
|
const Reporting = ({ projects }) => {
|
||||||
const [projectOptions, setProjectOptions] = useState([
|
const [projectOptions, setProjectOptions] = useState([
|
||||||
{ key: 'default', label: 'Default' },
|
{ key: 'default', label: 'Default' },
|
||||||
]);
|
]);
|
||||||
const [selectedProject, setSelectedProject] = useState('default');
|
const [selectedProject, setSelectedProject] = useState('default');
|
||||||
|
const { project, error, refetch } = useHealthReport(selectedProject);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFeatureToggles();
|
|
||||||
setSelectedProject(projects[0].id);
|
setSelectedProject(projects[0].id);
|
||||||
/* eslint-disable-next-line */
|
/* eslint-disable-next-line */
|
||||||
}, []);
|
}, []);
|
||||||
@ -62,8 +64,28 @@ const Reporting = ({ fetchFeatureToggles, projects }) => {
|
|||||||
show={renderSelect}
|
show={renderSelect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReportCardContainer selectedProject={selectedProject} />
|
<ConditionallyRender
|
||||||
<ReportToggleListContainer selectedProject={selectedProject} />
|
condition={error}
|
||||||
|
show={
|
||||||
|
<ApiError
|
||||||
|
data-loading
|
||||||
|
style={{ maxWidth: '500px', marginTop: '1rem' }}
|
||||||
|
onClick={refetch}
|
||||||
|
text={`Could not fetch health rating for ${selectedProject}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ReportCardContainer
|
||||||
|
health={project?.health}
|
||||||
|
staleCount={project?.staleCount}
|
||||||
|
activeCount={project?.activeCount}
|
||||||
|
potentiallyStaleCount={project?.potentiallyStaleCount}
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
/>
|
||||||
|
<ReportToggleListContainer
|
||||||
|
features={project.features}
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { HashRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { createStore } from 'redux';
|
|
||||||
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
|
||||||
|
|
||||||
import Reporting from '../Reporting';
|
|
||||||
import { REPORTING_SELECT_ID } from '../../../testIds';
|
|
||||||
|
|
||||||
import { testProjects, testFeatures } from '../testData';
|
|
||||||
|
|
||||||
const mockStore = {
|
|
||||||
projects: testProjects,
|
|
||||||
features: { toJS: () => testFeatures },
|
|
||||||
};
|
|
||||||
const mockReducer = state => state;
|
|
||||||
|
|
||||||
test('changing projects renders only toggles from that project', async () => {
|
|
||||||
render(
|
|
||||||
<HashRouter>
|
|
||||||
<Provider store={createStore(mockReducer, mockStore)}>
|
|
||||||
<Reporting projects={testProjects} features={testFeatures} fetchFeatureToggles={() => {}} />
|
|
||||||
</Provider>
|
|
||||||
</HashRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = await screen.findByRole("table");
|
|
||||||
/* Length of projects belonging to project (3) + header row (1) */
|
|
||||||
expect(table.rows).toHaveLength(4);
|
|
||||||
fireEvent.change(await screen.findByTestId(REPORTING_SELECT_ID), { target: { value: 'myProject'}});
|
|
||||||
expect(table.rows).toHaveLength(3);
|
|
||||||
});
|
|
@ -6,13 +6,6 @@ export const EXPIRED = 'expired';
|
|||||||
export const STATUS = 'status';
|
export const STATUS = 'status';
|
||||||
export const REPORT = 'report';
|
export const REPORT = 'report';
|
||||||
|
|
||||||
/* FEATURE TOGGLE TYPES */
|
|
||||||
export const EXPERIMENT = 'experiment';
|
|
||||||
export const RELEASE = 'release';
|
|
||||||
export const OPERATIONAL = 'operational';
|
|
||||||
export const KILLSWITCH = 'kill-switch';
|
|
||||||
export const PERMISSION = 'permission';
|
|
||||||
|
|
||||||
/* DAYS */
|
/* DAYS */
|
||||||
export const FOURTYDAYS = 40;
|
export const FOURTYDAYS = 40;
|
||||||
export const SEVENDAYS = 7;
|
export const SEVENDAYS = 7;
|
||||||
|
@ -1,7 +1,13 @@
|
|||||||
import parseISO from 'date-fns/parseISO';
|
import parseISO from 'date-fns/parseISO';
|
||||||
import differenceInDays from 'date-fns/differenceInDays';
|
import differenceInDays from 'date-fns/differenceInDays';
|
||||||
|
|
||||||
import { EXPERIMENT, OPERATIONAL, RELEASE, FOURTYDAYS, SEVENDAYS } from './constants';
|
import {
|
||||||
|
EXPERIMENT,
|
||||||
|
OPERATIONAL,
|
||||||
|
RELEASE,
|
||||||
|
} from '../../constants/featureToggleTypes';
|
||||||
|
|
||||||
|
import { FOURTYDAYS, SEVENDAYS } from './constants';
|
||||||
|
|
||||||
export const toggleExpiryByTypeMap = {
|
export const toggleExpiryByTypeMap = {
|
||||||
[EXPERIMENT]: FOURTYDAYS,
|
[EXPERIMENT]: FOURTYDAYS,
|
||||||
@ -21,9 +27,11 @@ export const getCheckedState = (name, features) => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDiffInDays = (date, now) => Math.abs(differenceInDays(date, now));
|
export const getDiffInDays = (date, now) =>
|
||||||
|
Math.abs(differenceInDays(date, now));
|
||||||
|
|
||||||
export const formatProjectOptions = projects => projects.map(project => ({ key: project.id, label: project.name }));
|
export const formatProjectOptions = projects =>
|
||||||
|
projects.map(project => ({ key: project.id, label: project.name }));
|
||||||
|
|
||||||
export const expired = (diff, type) => {
|
export const expired = (diff, type) => {
|
||||||
if (diff >= toggleExpiryByTypeMap[type]) return true;
|
if (diff >= toggleExpiryByTypeMap[type]) return true;
|
||||||
@ -56,7 +64,8 @@ export const sortFeaturesByNameAscending = features => {
|
|||||||
return sorted;
|
return sorted;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sortFeaturesByNameDescending = features => sortFeaturesByNameAscending([...features]).reverse();
|
export const sortFeaturesByNameDescending = features =>
|
||||||
|
sortFeaturesByNameAscending([...features]).reverse();
|
||||||
|
|
||||||
export const sortFeaturesByLastSeenAscending = features => {
|
export const sortFeaturesByLastSeenAscending = features => {
|
||||||
const sorted = [...features];
|
const sorted = [...features];
|
||||||
@ -72,7 +81,8 @@ export const sortFeaturesByLastSeenAscending = features => {
|
|||||||
return sorted;
|
return sorted;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sortFeaturesByLastSeenDescending = features => sortFeaturesByLastSeenAscending([...features]).reverse();
|
export const sortFeaturesByLastSeenDescending = features =>
|
||||||
|
sortFeaturesByLastSeenAscending([...features]).reverse();
|
||||||
|
|
||||||
export const sortFeaturesByCreatedAtAscending = features => {
|
export const sortFeaturesByCreatedAtAscending = features => {
|
||||||
const sorted = [...features];
|
const sorted = [...features];
|
||||||
@ -85,7 +95,8 @@ export const sortFeaturesByCreatedAtAscending = features => {
|
|||||||
return sorted;
|
return sorted;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sortFeaturesByCreatedAtDescending = features => sortFeaturesByCreatedAtAscending([...features]).reverse();
|
export const sortFeaturesByCreatedAtDescending = features =>
|
||||||
|
sortFeaturesByCreatedAtAscending([...features]).reverse();
|
||||||
|
|
||||||
export const sortFeaturesByExpiredAtAscending = features => {
|
export const sortFeaturesByExpiredAtAscending = features => {
|
||||||
const sorted = [...features];
|
const sorted = [...features];
|
||||||
@ -149,7 +160,8 @@ export const sortFeaturesByStatusAscending = features => {
|
|||||||
return sorted;
|
return sorted;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sortFeaturesByStatusDescending = features => sortFeaturesByStatusAscending([...features]).reverse();
|
export const sortFeaturesByStatusDescending = features =>
|
||||||
|
sortFeaturesByStatusAscending([...features]).reverse();
|
||||||
|
|
||||||
export const pluralize = (items, word) => {
|
export const pluralize = (items, word) => {
|
||||||
if (items === 1) return `${items} ${word}`;
|
if (items === 1) return `${items} ${word}`;
|
||||||
@ -163,7 +175,8 @@ export const getDates = dateString => {
|
|||||||
return [date, now];
|
return [date, now];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filterByProject = selectedProject => feature => feature.project === selectedProject;
|
export const filterByProject = selectedProject => feature =>
|
||||||
|
feature.project === selectedProject;
|
||||||
|
|
||||||
export const isFeatureExpired = feature => {
|
export const isFeatureExpired = feature => {
|
||||||
const [date, now] = getDates(feature.createdAt);
|
const [date, now] = getDates(feature.createdAt);
|
||||||
|
@ -33,6 +33,7 @@ exports[`renders correctly with permissions 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||||
@ -551,6 +552,7 @@ exports[`renders correctly without permission 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||||
|
@ -7,6 +7,7 @@ interface IAnimateOnMountProps {
|
|||||||
start: string;
|
start: string;
|
||||||
leave: string;
|
leave: string;
|
||||||
container?: string;
|
container?: string;
|
||||||
|
style?: Object;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
||||||
@ -16,6 +17,7 @@ const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
|||||||
leave,
|
leave,
|
||||||
container,
|
container,
|
||||||
children,
|
children,
|
||||||
|
style,
|
||||||
}) => {
|
}) => {
|
||||||
const [show, setShow] = useState(mounted);
|
const [show, setShow] = useState(mounted);
|
||||||
const [styles, setStyles] = useState('');
|
const [styles, setStyles] = useState('');
|
||||||
@ -49,6 +51,7 @@ const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
|||||||
container ? container : ''
|
container ? container : ''
|
||||||
}`}
|
}`}
|
||||||
onTransitionEnd={onTransitionEnd}
|
onTransitionEnd={onTransitionEnd}
|
||||||
|
style={{ ...style }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
32
frontend/src/component/common/ApiError/ApiError.tsx
Normal file
32
frontend/src/component/common/ApiError/ApiError.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Button } from '@material-ui/core';
|
||||||
|
import { Alert } from '@material-ui/lab';
|
||||||
|
|
||||||
|
interface IApiErrorProps {
|
||||||
|
className?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiError: React.FC<IApiErrorProps> = ({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
text,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
className={className ? className : ''}
|
||||||
|
action={
|
||||||
|
<Button color="inherit" size="small" onClick={onClick}>
|
||||||
|
TRY AGAIN
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
severity="error"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiError;
|
@ -14,23 +14,6 @@ export const useStyles = makeStyles(theme => ({
|
|||||||
animateContainer: {
|
animateContainer: {
|
||||||
zIndex: '9999',
|
zIndex: '9999',
|
||||||
},
|
},
|
||||||
feedbackStart: {
|
|
||||||
opacity: '0',
|
|
||||||
position: 'fixed',
|
|
||||||
right: '40px',
|
|
||||||
bottom: '40px',
|
|
||||||
transform: 'translateY(400px)',
|
|
||||||
},
|
|
||||||
feedbackEnter: {
|
|
||||||
transform: 'translateY(0)',
|
|
||||||
opacity: '1',
|
|
||||||
transition: 'transform 0.6s ease, opacity 1s ease',
|
|
||||||
},
|
|
||||||
feedbackLeave: {
|
|
||||||
transform: 'translateY(400px)',
|
|
||||||
opacity: '0',
|
|
||||||
transition: 'transform 1.25s ease, opacity 1s ease',
|
|
||||||
},
|
|
||||||
container: {
|
container: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
@ -82,9 +82,9 @@ const Feedback = ({
|
|||||||
return (
|
return (
|
||||||
<AnimateOnMount
|
<AnimateOnMount
|
||||||
mounted={show}
|
mounted={show}
|
||||||
enter={styles.feedbackEnter}
|
start={commonStyles.fadeInBottomStart}
|
||||||
start={styles.feedbackStart}
|
enter={commonStyles.fadeInBottomEnter}
|
||||||
leave={styles.feedbackLeave}
|
leave={commonStyles.fadeInBottomLeave}
|
||||||
container={styles.animateContainer}
|
container={styles.animateContainer}
|
||||||
>
|
>
|
||||||
<div className={styles.feedback}>
|
<div className={styles.feedback}>
|
||||||
|
@ -7,20 +7,33 @@ import ConditionallyRender from '../ConditionallyRender/ConditionallyRender';
|
|||||||
|
|
||||||
import { useStyles } from './styles';
|
import { useStyles } from './styles';
|
||||||
|
|
||||||
const HeaderTitle = ({ title, actions, subtitle, variant, loading }) => {
|
const HeaderTitle = ({
|
||||||
|
title,
|
||||||
|
actions,
|
||||||
|
subtitle,
|
||||||
|
variant,
|
||||||
|
loading,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const headerClasses = classnames({ skeleton: loading });
|
const headerClasses = classnames({ skeleton: loading });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.headerTitleContainer}>
|
<div className={styles.headerTitleContainer}>
|
||||||
<div className={headerClasses}>
|
<div className={headerClasses} data-loading>
|
||||||
<Typography variant={variant || 'h2'} className={styles.headerTitle}>
|
<Typography
|
||||||
|
variant={variant || 'h2'}
|
||||||
|
className={classnames(styles.headerTitle, className)}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{subtitle && <small>{subtitle}</small>}
|
{subtitle && <small>{subtitle}</small>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConditionallyRender condition={actions} show={<div className={styles.headerActions}>{actions}</div>} />
|
<ConditionallyRender
|
||||||
|
condition={actions}
|
||||||
|
show={<div className={styles.headerActions}>{actions}</div>}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
88
frontend/src/component/common/PaginateUI/PaginateUI.tsx
Normal file
88
frontend/src/component/common/PaginateUI/PaginateUI.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import ConditionallyRender from '../ConditionallyRender';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
import { useStyles } from './PaginationUI.styles';
|
||||||
|
|
||||||
|
import ArrowBackIosIcon from '@material-ui/icons/ArrowBackIos';
|
||||||
|
import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos';
|
||||||
|
|
||||||
|
interface IPaginateUIProps {
|
||||||
|
pages: any[];
|
||||||
|
pageIndex: number;
|
||||||
|
prevPage: () => void;
|
||||||
|
setPageIndex: (idx: number) => void;
|
||||||
|
nextPage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginateUI = ({
|
||||||
|
pages,
|
||||||
|
pageIndex,
|
||||||
|
prevPage,
|
||||||
|
setPageIndex,
|
||||||
|
nextPage,
|
||||||
|
}: IPaginateUIProps) => {
|
||||||
|
const styles = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={pages.length > 1}
|
||||||
|
show={
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<div className={styles.paginationInnerContainer}>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={pageIndex > 0}
|
||||||
|
show={
|
||||||
|
<button
|
||||||
|
className={classnames(
|
||||||
|
styles.idxBtn,
|
||||||
|
styles.idxBtnLeft
|
||||||
|
)}
|
||||||
|
onClick={() => prevPage()}
|
||||||
|
>
|
||||||
|
<ArrowBackIosIcon
|
||||||
|
className={styles.idxBtnIcon}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{pages.map((page, idx) => {
|
||||||
|
const active = pageIndex === idx;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
className={classnames(
|
||||||
|
styles.paginationButton,
|
||||||
|
{
|
||||||
|
[styles.paginationButtonActive]:
|
||||||
|
active,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={() => setPageIndex(idx)}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={pageIndex < pages.length - 1}
|
||||||
|
show={
|
||||||
|
<button
|
||||||
|
onClick={() => nextPage()}
|
||||||
|
className={classnames(
|
||||||
|
styles.idxBtn,
|
||||||
|
styles.idxBtnRight
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowForwardIosIcon
|
||||||
|
className={styles.idxBtnIcon}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaginateUI;
|
@ -0,0 +1,47 @@
|
|||||||
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
|
|
||||||
|
export const useStyles = makeStyles(theme => ({
|
||||||
|
pagination: {
|
||||||
|
margin: '1rem 0 0 0',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: ' center',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '25px',
|
||||||
|
right: '0',
|
||||||
|
left: '0',
|
||||||
|
},
|
||||||
|
paginationInnerContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
paginationButton: {
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'efefef',
|
||||||
|
margin: '0 0.2rem',
|
||||||
|
borderRadius: '3px',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
},
|
||||||
|
paginationButtonActive: {
|
||||||
|
backgroundColor: '#635DC5',
|
||||||
|
color: '#fff',
|
||||||
|
transition: 'backgroundColor 0.3s ease',
|
||||||
|
},
|
||||||
|
idxBtn: {
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '3px',
|
||||||
|
background: 'transparent',
|
||||||
|
position: 'absolute',
|
||||||
|
height: '23px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
idxBtnIcon: {
|
||||||
|
height: '15px',
|
||||||
|
width: '15px',
|
||||||
|
},
|
||||||
|
idxBtnLeft: {
|
||||||
|
left: '-30px',
|
||||||
|
},
|
||||||
|
idxBtnRight: {
|
||||||
|
right: '-30px',
|
||||||
|
},
|
||||||
|
}));
|
@ -0,0 +1,38 @@
|
|||||||
|
import { IconButton, Tooltip, Button, useMediaQuery } from '@material-ui/core';
|
||||||
|
import ConditionallyRender from '../ConditionallyRender';
|
||||||
|
|
||||||
|
interface IResponsiveButtonProps {
|
||||||
|
Icon: React.ElementType;
|
||||||
|
onClick: () => void;
|
||||||
|
tooltip?: string;
|
||||||
|
maxWidth: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResponsiveButton = ({
|
||||||
|
Icon,
|
||||||
|
onClick,
|
||||||
|
maxWidth,
|
||||||
|
tooltip,
|
||||||
|
}: IResponsiveButtonProps) => {
|
||||||
|
const smallScreen = useMediaQuery(`(max-width:${maxWidth})`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={smallScreen}
|
||||||
|
show={
|
||||||
|
<Tooltip title={tooltip ? tooltip : ''}>
|
||||||
|
<IconButton onClick={onClick}>
|
||||||
|
<Icon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<Button onClick={onClick} color="primary" variant="contained">
|
||||||
|
Add new project
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResponsiveButton;
|
46
frontend/src/component/common/Toast/Toast.tsx
Normal file
46
frontend/src/component/common/Toast/Toast.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Portal, Snackbar } from '@material-ui/core';
|
||||||
|
import { Alert } from '@material-ui/lab';
|
||||||
|
import { useCommonStyles } from '../../../common.styles';
|
||||||
|
import AnimateOnMount from '../AnimateOnMount/AnimateOnMount';
|
||||||
|
|
||||||
|
interface IToastProps {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
autoHideDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Toast = ({
|
||||||
|
show,
|
||||||
|
onClose,
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
autoHideDuration = 6000,
|
||||||
|
}: IToastProps) => {
|
||||||
|
const styles = useCommonStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<AnimateOnMount
|
||||||
|
mounted={show}
|
||||||
|
start={styles.fadeInBottomStart}
|
||||||
|
enter={styles.fadeInBottomEnter}
|
||||||
|
leave={styles.fadeInBottomLeave}
|
||||||
|
container={styles.fullWidth}
|
||||||
|
>
|
||||||
|
<Snackbar
|
||||||
|
open={show}
|
||||||
|
onClose={onClose}
|
||||||
|
autoHideDuration={autoHideDuration}
|
||||||
|
>
|
||||||
|
<Alert variant="filled" severity={type} onClose={onClose}>
|
||||||
|
{text}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</AnimateOnMount>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toast;
|
@ -2,3 +2,6 @@ export const P = 'P';
|
|||||||
export const C = 'C';
|
export const C = 'C';
|
||||||
export const RBAC = 'RBAC';
|
export const RBAC = 'RBAC';
|
||||||
export const OIDC = 'OIDC';
|
export const OIDC = 'OIDC';
|
||||||
|
|
||||||
|
export const PROJECTCARDACTIONS = false;
|
||||||
|
export const PROJECTFILTERING = false;
|
||||||
|
@ -50,6 +50,7 @@ exports[`renders correctly with one feature 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-13 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-13 MuiTypography-h2"
|
||||||
@ -286,6 +287,7 @@ exports[`renders correctly with one feature without permissions 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-13 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-13 MuiTypography-h2"
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
|
|
||||||
|
export const useStyles = makeStyles(theme => ({
|
||||||
|
tableRow: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
tableCell: {
|
||||||
|
border: 'none',
|
||||||
|
padding: '0.25rem 0',
|
||||||
|
},
|
||||||
|
tableCellHeader: {
|
||||||
|
paddingBottom: '0.5rem',
|
||||||
|
},
|
||||||
|
tableCellName: {
|
||||||
|
width: '250px',
|
||||||
|
},
|
||||||
|
tableCellEnv: {
|
||||||
|
width: '20px',
|
||||||
|
},
|
||||||
|
tableCellType: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
marginRight: '0.3rem',
|
||||||
|
},
|
||||||
|
}));
|
@ -0,0 +1,132 @@
|
|||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
} from '@material-ui/core';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
import { useStyles } from './FeatureToggleListNew.styles';
|
||||||
|
import FeatureToggleListNewItem from './FeatureToggleListNewItem/FeatureToggleListNewItem';
|
||||||
|
import usePagination from '../../../hooks/usePagination';
|
||||||
|
|
||||||
|
import loadingFeatures from './FeatureToggleListNewItem/loadingFeatures';
|
||||||
|
import { IFeatureToggleListItem } from '../../../interfaces/featureToggle';
|
||||||
|
import PaginateUI from '../../common/PaginateUI/PaginateUI';
|
||||||
|
interface IFeatureToggleListNewProps {
|
||||||
|
features: IFeatureToggleListItem[];
|
||||||
|
loading: boolean;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureToggleListNew = ({
|
||||||
|
features,
|
||||||
|
loading,
|
||||||
|
projectId,
|
||||||
|
}: IFeatureToggleListNewProps) => {
|
||||||
|
const styles = useStyles();
|
||||||
|
const { page, pages, nextPage, prevPage, setPageIndex, pageIndex } =
|
||||||
|
usePagination(features, 9);
|
||||||
|
|
||||||
|
const getEnvironments = () => {
|
||||||
|
if (features.length > 0) {
|
||||||
|
const envs = features[0].environments || [];
|
||||||
|
return envs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: ':global:',
|
||||||
|
displayName: 'Across all environments',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFeatures = () => {
|
||||||
|
if (loading) {
|
||||||
|
return loadingFeatures.map((feature: IFeatureToggleListItem) => {
|
||||||
|
return (
|
||||||
|
<FeatureToggleListNewItem
|
||||||
|
key={feature.name}
|
||||||
|
name={feature.name}
|
||||||
|
type={feature.type}
|
||||||
|
environments={feature.environments}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return page.map((feature: IFeatureToggleListItem) => {
|
||||||
|
return (
|
||||||
|
<FeatureToggleListNewItem
|
||||||
|
key={feature.name}
|
||||||
|
name={feature.name}
|
||||||
|
type={feature.type}
|
||||||
|
environments={feature.environments}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
className={classnames(
|
||||||
|
styles.tableCell,
|
||||||
|
styles.tableCellName,
|
||||||
|
styles.tableCellHeader
|
||||||
|
)}
|
||||||
|
align="left"
|
||||||
|
>
|
||||||
|
<span data-loading>name</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={classnames(
|
||||||
|
styles.tableCell,
|
||||||
|
styles.tableCellHeader
|
||||||
|
)}
|
||||||
|
align="left"
|
||||||
|
>
|
||||||
|
<span data-loading>type</span>
|
||||||
|
</TableCell>
|
||||||
|
{getEnvironments().map((env: any) => {
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={env.name}
|
||||||
|
className={classnames(
|
||||||
|
styles.tableCell,
|
||||||
|
styles.tableCellEnv,
|
||||||
|
styles.tableCellHeader
|
||||||
|
)}
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<span data-loading>
|
||||||
|
{env.name === ':global:'
|
||||||
|
? 'global'
|
||||||
|
: env.name}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>{renderFeatures()}</TableBody>
|
||||||
|
</Table>
|
||||||
|
<PaginateUI
|
||||||
|
pages={pages}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
setPageIndex={setPageIndex}
|
||||||
|
nextPage={nextPage}
|
||||||
|
prevPage={prevPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeatureToggleListNew;
|
@ -0,0 +1,106 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Switch, TableCell, TableRow } from '@material-ui/core';
|
||||||
|
import { useHistory } from 'react-router';
|
||||||
|
import { getFeatureTypeIcons } from '../../../../utils/get-feature-type-icons';
|
||||||
|
import { useStyles } from '../FeatureToggleListNew.styles';
|
||||||
|
import useToggleFeatureByEnv from '../../../../hooks/api/actions/useToggleFeatureByEnv/useToggleFeatureByEnv';
|
||||||
|
import { IEnvironments } from '../../../../interfaces/featureToggle';
|
||||||
|
import Toast from '../../../common/Toast/Toast';
|
||||||
|
|
||||||
|
interface IFeatureToggleListNewItemProps {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
environments: IEnvironments[];
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureToggleListNewItem = ({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
environments,
|
||||||
|
projectId,
|
||||||
|
}: IFeatureToggleListNewItemProps) => {
|
||||||
|
const { toggleFeatureByEnvironment } = useToggleFeatureByEnv(
|
||||||
|
projectId,
|
||||||
|
name
|
||||||
|
);
|
||||||
|
const [snackbarData, setSnackbardata] = useState({
|
||||||
|
show: false,
|
||||||
|
type: 'success',
|
||||||
|
text: '',
|
||||||
|
});
|
||||||
|
const styles = useStyles();
|
||||||
|
const history = useHistory();
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
const onClick = (e: Event) => {
|
||||||
|
if (!ref.current?.contains(e.target)) {
|
||||||
|
history.push(`/features/strategies/${name}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (env: IEnvironments) => {
|
||||||
|
toggleFeatureByEnvironment(env.name, env.enabled)
|
||||||
|
.then(() => {
|
||||||
|
setSnackbardata({
|
||||||
|
show: true,
|
||||||
|
type: 'success',
|
||||||
|
text: 'Successfully updated toggle status.',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
setSnackbardata({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
text: e.toString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideSnackbar = () => {
|
||||||
|
setSnackbardata(prev => ({ ...prev, show: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const IconComponent = getFeatureTypeIcons(type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow onClick={onClick} className={styles.tableRow}>
|
||||||
|
<TableCell className={styles.tableCell} align="left">
|
||||||
|
<span data-loading>{name}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={styles.tableCell} align="left">
|
||||||
|
<div className={styles.tableCellType}>
|
||||||
|
<IconComponent data-loading className={styles.icon} />{' '}
|
||||||
|
<span data-loading>{type}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{environments.map((env: IEnvironments) => {
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
className={styles.tableCell}
|
||||||
|
align="center"
|
||||||
|
key={env.name}
|
||||||
|
>
|
||||||
|
<span data-loading style={{ display: 'block' }}>
|
||||||
|
<Switch
|
||||||
|
checked={env.enabled}
|
||||||
|
ref={ref}
|
||||||
|
onClick={() => handleToggle(env)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
<Toast
|
||||||
|
show={snackbarData.show}
|
||||||
|
onClose={hideSnackbar}
|
||||||
|
text={snackbarData.text}
|
||||||
|
type={snackbarData.type}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeatureToggleListNewItem;
|
@ -0,0 +1,103 @@
|
|||||||
|
const loadingFeatures = [
|
||||||
|
{
|
||||||
|
type: 'release',
|
||||||
|
name: 'loading1',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: ':global:',
|
||||||
|
displayName: 'Across all environments',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'release',
|
||||||
|
name: 'loadg2',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: ':global:',
|
||||||
|
displayName: 'Across all environments',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'release',
|
||||||
|
name: 'loading3',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: ':global:',
|
||||||
|
displayName: 'Across all environments',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'release',
|
||||||
|
name: 'loadi4',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: ':global:',
|
||||||
|
displayName: 'Across all environments',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'release',
|
||||||
|
name: 'loadi5',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: ':global:',
|
||||||
|
displayName: 'Across all environments',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'release',
|
||||||
|
name: 'loadg6',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: ':global:',
|
||||||
|
displayName: 'Across all environments',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'release',
|
||||||
|
name: 'loading7',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: ':global:',
|
||||||
|
displayName: 'Across all environments',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'release',
|
||||||
|
name: 'ln8',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: ':global:',
|
||||||
|
displayName: 'Across all environments',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'release',
|
||||||
|
name: 'load9',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: ':global:',
|
||||||
|
displayName: 'Across all environments',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default loadingFeatures;
|
@ -497,10 +497,10 @@ exports[`renders correctly with with variants 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
<fieldset
|
<fieldset
|
||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="PrivateNotchedOutline-root-18 MuiOutlinedInput-notchedOutline"
|
className="PrivateNotchedOutline-root-21 MuiOutlinedInput-notchedOutline"
|
||||||
>
|
>
|
||||||
<legend
|
<legend
|
||||||
className="PrivateNotchedOutline-legendLabelled-20 PrivateNotchedOutline-legendNotched-21"
|
className="PrivateNotchedOutline-legendLabelled-23 PrivateNotchedOutline-legendNotched-24"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
Stickiness
|
Stickiness
|
||||||
|
@ -140,10 +140,10 @@ exports[`renders correctly with one feature 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
<fieldset
|
<fieldset
|
||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="PrivateNotchedOutline-root-16 MuiOutlinedInput-notchedOutline"
|
className="PrivateNotchedOutline-root-19 MuiOutlinedInput-notchedOutline"
|
||||||
>
|
>
|
||||||
<legend
|
<legend
|
||||||
className="PrivateNotchedOutline-legendLabelled-18"
|
className="PrivateNotchedOutline-legendLabelled-21"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
Project
|
Project
|
||||||
@ -175,7 +175,7 @@ exports[`renders correctly with one feature 1`] = `
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-disabled={false}
|
aria-disabled={false}
|
||||||
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-20 MuiSwitch-switchBase MuiSwitch-colorSecondary"
|
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-23 MuiSwitch-switchBase MuiSwitch-colorSecondary"
|
||||||
onBlur={[Function]}
|
onBlur={[Function]}
|
||||||
onDragLeave={[Function]}
|
onDragLeave={[Function]}
|
||||||
onFocus={[Function]}
|
onFocus={[Function]}
|
||||||
@ -194,7 +194,7 @@ exports[`renders correctly with one feature 1`] = `
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
checked={false}
|
checked={false}
|
||||||
className="PrivateSwitchBase-input-23 MuiSwitch-input"
|
className="PrivateSwitchBase-input-26 MuiSwitch-input"
|
||||||
disabled={false}
|
disabled={false}
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -327,7 +327,7 @@ exports[`renders correctly with one feature 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<div
|
<div
|
||||||
className="MuiPaper-root makeStyles-tabNav-24 MuiPaper-elevation1 MuiPaper-rounded"
|
className="MuiPaper-root makeStyles-tabNav-27 MuiPaper-elevation1 MuiPaper-rounded"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="MuiTabs-root"
|
className="MuiTabs-root"
|
||||||
@ -375,7 +375,7 @@ exports[`renders correctly with one feature 1`] = `
|
|||||||
Activation
|
Activation
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="PrivateTabIndicator-root-25 PrivateTabIndicator-colorPrimary-26 MuiTabs-indicator"
|
className="PrivateTabIndicator-root-28 PrivateTabIndicator-colorPrimary-29 MuiTabs-indicator"
|
||||||
style={Object {}}
|
style={Object {}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
@ -39,6 +39,8 @@ import { P, C } from '../common/flags';
|
|||||||
import NewUser from '../user/NewUser';
|
import NewUser from '../user/NewUser';
|
||||||
import ResetPassword from '../user/ResetPassword/ResetPassword';
|
import ResetPassword from '../user/ResetPassword/ResetPassword';
|
||||||
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
|
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
|
||||||
|
import ProjectListNew from '../project/ProjectListNew/ProjectListNew';
|
||||||
|
import Project from '../project/Project/Project';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
List,
|
List,
|
||||||
@ -231,7 +233,15 @@ export const routes = [
|
|||||||
type: 'protected',
|
type: 'protected',
|
||||||
layout: 'main',
|
layout: 'main',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/projects/:id',
|
||||||
|
parent: '/projects',
|
||||||
|
title: ':id',
|
||||||
|
component: Project,
|
||||||
|
flag: P,
|
||||||
|
type: 'protected',
|
||||||
|
layout: 'main',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/projects',
|
path: '/projects',
|
||||||
title: 'Projects',
|
title: 'Projects',
|
||||||
@ -241,6 +251,16 @@ export const routes = [
|
|||||||
type: 'protected',
|
type: 'protected',
|
||||||
layout: 'main',
|
layout: 'main',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/projects-new',
|
||||||
|
title: 'Projects new',
|
||||||
|
icon: 'folder_open',
|
||||||
|
component: ProjectListNew,
|
||||||
|
flag: P,
|
||||||
|
type: 'protected',
|
||||||
|
hidden: true,
|
||||||
|
layout: 'main',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/tag-types/create',
|
path: '/tag-types/create',
|
||||||
|
48
frontend/src/component/project/Project/Project.tsx
Normal file
48
frontend/src/component/project/Project/Project.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { useCommonStyles } from '../../../common.styles';
|
||||||
|
import useProject from '../../../hooks/api/getters/useProject/useProject';
|
||||||
|
import useLoading from '../../../hooks/useLoading';
|
||||||
|
import ApiError from '../../common/ApiError/ApiError';
|
||||||
|
import ConditionallyRender from '../../common/ConditionallyRender';
|
||||||
|
import ProjectFeatureToggles from './ProjectFeatureToggles/ProjectFeatureToggles';
|
||||||
|
import ProjectInfo from './ProjectInfo/ProjectInfo';
|
||||||
|
|
||||||
|
const Project = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { project, error, loading, refetch } = useProject(id);
|
||||||
|
const ref = useLoading(loading);
|
||||||
|
const { members, features, health } = project;
|
||||||
|
const commonStyles = useCommonStyles();
|
||||||
|
|
||||||
|
const containerStyles = { marginTop: '1.5rem', display: 'flex' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<h1 data-loading className={commonStyles.title}>
|
||||||
|
{project?.name}
|
||||||
|
</h1>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={error}
|
||||||
|
show={
|
||||||
|
<ApiError
|
||||||
|
data-loading
|
||||||
|
style={{ maxWidth: '400px', marginTop: '1rem' }}
|
||||||
|
onClick={refetch}
|
||||||
|
text="Could not fetch project"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div style={containerStyles}>
|
||||||
|
<ProjectInfo
|
||||||
|
id={id}
|
||||||
|
memberCount={members}
|
||||||
|
health={health}
|
||||||
|
featureCount={features?.length}
|
||||||
|
/>
|
||||||
|
<ProjectFeatureToggles features={features} loading={loading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Project;
|
@ -0,0 +1,25 @@
|
|||||||
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
|
|
||||||
|
export const useStyles = makeStyles(theme => ({
|
||||||
|
container: {
|
||||||
|
boxShadow: 'none',
|
||||||
|
marginLeft: '2rem',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: '1rem',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
marginRight: '1rem',
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
color: '#000',
|
||||||
|
height: '30px',
|
||||||
|
width: '30px',
|
||||||
|
},
|
||||||
|
}));
|
@ -0,0 +1,70 @@
|
|||||||
|
import { Button, IconButton } from '@material-ui/core';
|
||||||
|
import FilterListIcon from '@material-ui/icons/FilterList';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { IFeatureToggleListItem } from '../../../../interfaces/featureToggle';
|
||||||
|
import ConditionallyRender from '../../../common/ConditionallyRender';
|
||||||
|
import { PROJECTFILTERING } from '../../../common/flags';
|
||||||
|
import HeaderTitle from '../../../common/HeaderTitle';
|
||||||
|
import PageContent from '../../../common/PageContent';
|
||||||
|
import FeatureToggleListNew from '../../../feature/FeatureToggleListNew/FeatureToggleListNew';
|
||||||
|
import { useStyles } from './ProjectFeatureToggles.styles';
|
||||||
|
|
||||||
|
interface IProjectFeatureToggles {
|
||||||
|
features: IFeatureToggleListItem[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectFeatureToggles = ({
|
||||||
|
features,
|
||||||
|
loading,
|
||||||
|
}: IProjectFeatureToggles) => {
|
||||||
|
const styles = useStyles();
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContent
|
||||||
|
className={styles.container}
|
||||||
|
headerContent={
|
||||||
|
<HeaderTitle
|
||||||
|
className={styles.title}
|
||||||
|
title="Feature toggles"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={PROJECTFILTERING}
|
||||||
|
show={
|
||||||
|
<IconButton
|
||||||
|
className={styles.iconButton}
|
||||||
|
data-loading
|
||||||
|
>
|
||||||
|
<FilterListIcon
|
||||||
|
className={styles.icon}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
component={Link}
|
||||||
|
to="/features/create"
|
||||||
|
data-loading
|
||||||
|
>
|
||||||
|
New feature toggle
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FeatureToggleListNew
|
||||||
|
features={features}
|
||||||
|
loading={loading}
|
||||||
|
projectId={id}
|
||||||
|
/>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectFeatureToggles;
|
@ -0,0 +1,31 @@
|
|||||||
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
|
|
||||||
|
export const useStyles = makeStyles(theme => ({
|
||||||
|
projectInfo: {
|
||||||
|
width: '275px',
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
marginBottom: '1.25rem',
|
||||||
|
},
|
||||||
|
emphazisedText: {
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
},
|
||||||
|
infoSection: {
|
||||||
|
margin: '1.8rem 0',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
arrowIcon: {
|
||||||
|
color: '#635dc5',
|
||||||
|
marginLeft: '0.5rem',
|
||||||
|
},
|
||||||
|
infoLink: {
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: '#635dc5',
|
||||||
|
},
|
||||||
|
}));
|
@ -0,0 +1,82 @@
|
|||||||
|
import { Paper } from '@material-ui/core';
|
||||||
|
import { useStyles } from './ProjectInfo.styles';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import ArrowForwardIcon from '@material-ui/icons/ArrowForward';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
|
||||||
|
import { ReactComponent as ProjectIcon } from '../../../../assets/icons/projectIcon.svg';
|
||||||
|
import { useCommonStyles } from '../../../../common.styles';
|
||||||
|
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
|
||||||
|
interface IProjectInfoProps {
|
||||||
|
id: string;
|
||||||
|
memberCount: number;
|
||||||
|
featureCount: number;
|
||||||
|
health: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectInfo = ({
|
||||||
|
id,
|
||||||
|
memberCount,
|
||||||
|
featureCount,
|
||||||
|
health,
|
||||||
|
}: IProjectInfoProps) => {
|
||||||
|
const commonStyles = useCommonStyles();
|
||||||
|
const styles = useStyles();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
|
let link = `/admin/users`;
|
||||||
|
|
||||||
|
if (uiConfig?.versionInfo?.current?.enterprise) {
|
||||||
|
link = `/projects/${id}/access`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside>
|
||||||
|
<Paper className={styles.projectInfo}>
|
||||||
|
<div className={styles.infoSection} data-loading>
|
||||||
|
<ProjectIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.infoSection} data-loading>
|
||||||
|
<p className={styles.subtitle}>Overall health rating</p>
|
||||||
|
<p className={styles.emphazisedText}>{health}%</p>
|
||||||
|
<Link
|
||||||
|
className={classnames(
|
||||||
|
commonStyles.flexRow,
|
||||||
|
commonStyles.justifyCenter,
|
||||||
|
styles.infoLink
|
||||||
|
)}
|
||||||
|
to="/reporting"
|
||||||
|
>
|
||||||
|
view more{' '}
|
||||||
|
<ArrowForwardIcon className={styles.arrowIcon} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.infoSection} data-loading>
|
||||||
|
<p className={styles.subtitle}>Project members</p>
|
||||||
|
<p className={styles.emphazisedText}>{memberCount}</p>
|
||||||
|
<Link
|
||||||
|
className={classnames(
|
||||||
|
commonStyles.flexRow,
|
||||||
|
commonStyles.justifyCenter,
|
||||||
|
styles.infoLink
|
||||||
|
)}
|
||||||
|
to={link}
|
||||||
|
>
|
||||||
|
view more{' '}
|
||||||
|
<ArrowForwardIcon className={styles.arrowIcon} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.infoSection} data-loading>
|
||||||
|
<p className={styles.subtitle}>Feature toggles</p>
|
||||||
|
<p className={styles.emphazisedText}>{featureCount}</p>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectInfo;
|
@ -0,0 +1,41 @@
|
|||||||
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
|
|
||||||
|
export const useStyles = makeStyles(theme => ({
|
||||||
|
projectCard: {
|
||||||
|
padding: '1rem',
|
||||||
|
width: '220px',
|
||||||
|
height: '204px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
margin: '0.5rem',
|
||||||
|
boxShadow: 'none',
|
||||||
|
border: '1px solid #efefef',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontSize: '1rem',
|
||||||
|
},
|
||||||
|
projectIcon: {
|
||||||
|
margin: '1rem auto',
|
||||||
|
width: '80px',
|
||||||
|
display: 'block',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
},
|
||||||
|
infoBox: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
infoStats: {
|
||||||
|
color: '#4A4599',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
}));
|
66
frontend/src/component/project/ProjectCard/ProjectCard.tsx
Normal file
66
frontend/src/component/project/ProjectCard/ProjectCard.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { Card, IconButton } from '@material-ui/core';
|
||||||
|
import { useStyles } from './ProjectCard.styles';
|
||||||
|
import MoreVertIcon from '@material-ui/icons/MoreVert';
|
||||||
|
|
||||||
|
import { ReactComponent as ProjectIcon } from '../../../assets/icons/projectIcon.svg';
|
||||||
|
import ConditionallyRender from '../../common/ConditionallyRender';
|
||||||
|
import { PROJECTCARDACTIONS } from '../../common/flags';
|
||||||
|
|
||||||
|
interface IProjectCardProps {
|
||||||
|
name: string;
|
||||||
|
featureCount: number;
|
||||||
|
health: number;
|
||||||
|
memberCount: number;
|
||||||
|
onHover: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectCard = ({
|
||||||
|
name,
|
||||||
|
featureCount,
|
||||||
|
health,
|
||||||
|
memberCount,
|
||||||
|
onHover,
|
||||||
|
}: IProjectCardProps) => {
|
||||||
|
const styles = useStyles();
|
||||||
|
return (
|
||||||
|
<Card className={styles.projectCard} onMouseEnter={onHover}>
|
||||||
|
<div className={styles.header} data-loading>
|
||||||
|
<h2 className={styles.title}>{name}</h2>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={PROJECTCARDACTIONS}
|
||||||
|
show={
|
||||||
|
<IconButton data-loading>
|
||||||
|
<MoreVertIcon />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div data-loading>
|
||||||
|
<ProjectIcon className={styles.projectIcon} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.infoBox}>
|
||||||
|
<p className={styles.infoStats} data-loading>
|
||||||
|
{featureCount}
|
||||||
|
</p>
|
||||||
|
<p data-loading>toggles</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoBox}>
|
||||||
|
<p className={styles.infoStats} data-loading>
|
||||||
|
{health}%
|
||||||
|
</p>
|
||||||
|
<p data-loading>health</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.infoBox}>
|
||||||
|
<p className={styles.infoStats} data-loading>
|
||||||
|
{memberCount}
|
||||||
|
</p>
|
||||||
|
<p data-loading>members</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectCard;
|
@ -14,8 +14,6 @@ import {
|
|||||||
ListItemAvatar,
|
ListItemAvatar,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Button,
|
|
||||||
useMediaQuery,
|
|
||||||
} from '@material-ui/core';
|
} from '@material-ui/core';
|
||||||
import {
|
import {
|
||||||
Add,
|
Add,
|
||||||
@ -29,10 +27,10 @@ import ConfirmDialogue from '../../common/Dialogue';
|
|||||||
import PageContent from '../../common/PageContent/PageContent';
|
import PageContent from '../../common/PageContent/PageContent';
|
||||||
import { useStyles } from './styles';
|
import { useStyles } from './styles';
|
||||||
import AccessContext from '../../../contexts/AccessContext';
|
import AccessContext from '../../../contexts/AccessContext';
|
||||||
|
import ResponsiveButton from '../../common/ResponsiveButton/ResponsiveButton';
|
||||||
|
|
||||||
const ProjectList = ({ projects, fetchProjects, removeProject, history }) => {
|
const ProjectList = ({ projects, fetchProjects, removeProject, history }) => {
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
const smallScreen = useMediaQuery('(max-width:700px)');
|
|
||||||
const [showDelDialogue, setShowDelDialogue] = useState(false);
|
const [showDelDialogue, setShowDelDialogue] = useState(false);
|
||||||
const [project, setProject] = useState(undefined);
|
const [project, setProject] = useState(undefined);
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
@ -44,26 +42,11 @@ const ProjectList = ({ projects, fetchProjects, removeProject, history }) => {
|
|||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={hasAccess(CREATE_PROJECT)}
|
condition={hasAccess(CREATE_PROJECT)}
|
||||||
show={
|
show={
|
||||||
<ConditionallyRender
|
<ResponsiveButton
|
||||||
condition={smallScreen}
|
Icon={Add}
|
||||||
show={
|
|
||||||
<Tooltip title="Add new project">
|
|
||||||
<IconButton
|
|
||||||
onClick={() => history.push('/projects/create')}
|
onClick={() => history.push('/projects/create')}
|
||||||
>
|
maxWidth="700px"
|
||||||
<Add />
|
tooltip="Add new project"
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
elseShow={
|
|
||||||
<Button
|
|
||||||
onClick={() => history.push('/projects/create')}
|
|
||||||
color="primary"
|
|
||||||
variant="contained"
|
|
||||||
>
|
|
||||||
Add new project
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
|
|
||||||
|
export const useStyles = makeStyles(theme => ({
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
},
|
||||||
|
apiError: {
|
||||||
|
maxWidth: '400px',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
},
|
||||||
|
cardLink: { color: 'inherit', textDecoration: 'none' },
|
||||||
|
}));
|
137
frontend/src/component/project/ProjectListNew/ProjectListNew.tsx
Normal file
137
frontend/src/component/project/ProjectListNew/ProjectListNew.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { useContext, useState } from 'react';
|
||||||
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
|
import { mutate } from 'swr';
|
||||||
|
import { getProjectFetcher } from '../../../hooks/api/getters/useProject/getProjectFetcher';
|
||||||
|
import useProjects from '../../../hooks/api/getters/useProjects/useProjects';
|
||||||
|
import ConditionallyRender from '../../common/ConditionallyRender';
|
||||||
|
import ProjectCard from '../ProjectCard/ProjectCard';
|
||||||
|
import { useStyles } from './ProjectListNew.styles';
|
||||||
|
import { IProjectCard } from '../../../interfaces/project';
|
||||||
|
|
||||||
|
import loadingData from './loadingData';
|
||||||
|
import useLoading from '../../../hooks/useLoading';
|
||||||
|
import PageContent from '../../common/PageContent';
|
||||||
|
import AccessContext from '../../../contexts/AccessContext';
|
||||||
|
import HeaderTitle from '../../common/HeaderTitle';
|
||||||
|
import ResponsiveButton from '../../common/ResponsiveButton/ResponsiveButton';
|
||||||
|
import { CREATE_PROJECT } from '../../AccessProvider/permissions';
|
||||||
|
|
||||||
|
import { Add } from '@material-ui/icons';
|
||||||
|
import ApiError from '../../common/ApiError/ApiError';
|
||||||
|
|
||||||
|
type projectMap = {
|
||||||
|
[index: string]: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectListNew = () => {
|
||||||
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const styles = useStyles();
|
||||||
|
const { projects, loading, error, refetch } = useProjects();
|
||||||
|
const [fetchedProjects, setFetchedProjects] = useState<projectMap>({});
|
||||||
|
const ref = useLoading(loading);
|
||||||
|
|
||||||
|
const handleHover = (projectId: string) => {
|
||||||
|
if (fetchedProjects[projectId]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { KEY, fetcher } = getProjectFetcher(projectId);
|
||||||
|
mutate(KEY, fetcher);
|
||||||
|
setFetchedProjects(prev => ({ ...prev, [projectId]: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderError = () => {
|
||||||
|
return (
|
||||||
|
<ApiError
|
||||||
|
onClick={refetch}
|
||||||
|
className={styles.apiError}
|
||||||
|
text="Error fetching projects"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProjects = () => {
|
||||||
|
if (loading) {
|
||||||
|
return renderLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects.map((project: IProjectCard) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
to={{
|
||||||
|
pathname: `/projects/${project.id}`,
|
||||||
|
state: {
|
||||||
|
projectName: project.name,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className={styles.cardLink}
|
||||||
|
>
|
||||||
|
<ProjectCard
|
||||||
|
onHover={() => handleHover(project?.id)}
|
||||||
|
name={project?.name}
|
||||||
|
memberCount={project?.memberCount}
|
||||||
|
health={project?.health}
|
||||||
|
featureCount={project?.featureCount}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLoading = () => {
|
||||||
|
return loadingData.map((project: IProjectCard) => {
|
||||||
|
return (
|
||||||
|
<ProjectCard
|
||||||
|
data-loading
|
||||||
|
onHover={() => {}}
|
||||||
|
key={project.id}
|
||||||
|
projectName={project.name}
|
||||||
|
members={2}
|
||||||
|
health={95}
|
||||||
|
toggles={4}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<PageContent
|
||||||
|
headerContent={
|
||||||
|
<HeaderTitle
|
||||||
|
title="Projects"
|
||||||
|
actions={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={hasAccess(CREATE_PROJECT)}
|
||||||
|
show={
|
||||||
|
<ResponsiveButton
|
||||||
|
Icon={Add}
|
||||||
|
onClick={() =>
|
||||||
|
history.push('/projects/create')
|
||||||
|
}
|
||||||
|
maxWidth="700px"
|
||||||
|
tooltip="Add new project"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ConditionallyRender condition={error} show={renderError()} />
|
||||||
|
<div className={styles.container}>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={projects.length < 1 && !loading}
|
||||||
|
show={<div>No projects available.</div>}
|
||||||
|
elseShow={renderProjects()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectListNew;
|
32
frontend/src/component/project/ProjectListNew/loadingData.ts
Normal file
32
frontend/src/component/project/ProjectListNew/loadingData.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const loadingData = [
|
||||||
|
{
|
||||||
|
id: 'loading1',
|
||||||
|
name: 'loading1',
|
||||||
|
members: 1,
|
||||||
|
health: 95,
|
||||||
|
toggles: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'loading2',
|
||||||
|
name: 'loading2',
|
||||||
|
members: 1,
|
||||||
|
health: 95,
|
||||||
|
toggles: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'loading3',
|
||||||
|
name: 'loading3',
|
||||||
|
members: 1,
|
||||||
|
health: 95,
|
||||||
|
toggles: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'loading4',
|
||||||
|
name: 'loading4',
|
||||||
|
members: 1,
|
||||||
|
health: 95,
|
||||||
|
toggles: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default loadingData;
|
@ -35,11 +35,15 @@ function AccessComponent({ projectId, project }) {
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const fetchAccess = async () => {
|
const fetchAccess = async () => {
|
||||||
|
try {
|
||||||
const access = await projectApi.fetchAccess(projectId);
|
const access = await projectApi.fetchAccess(projectId);
|
||||||
setRoles(access.roles);
|
setRoles(access.roles);
|
||||||
setUsers(
|
setUsers(
|
||||||
access.users.map(u => ({ ...u, name: u.name || '(No name)' }))
|
access.users.map(u => ({ ...u, name: u.name || '(No name)' }))
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -12,6 +12,7 @@ exports[`renders correctly with one strategy 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2"
|
||||||
@ -140,6 +141,7 @@ exports[`renders correctly with one strategy without permissions 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2"
|
||||||
|
@ -12,6 +12,7 @@ exports[`renders correctly with one strategy 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||||
|
@ -12,6 +12,7 @@ exports[`it supports editMode 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||||
@ -108,6 +109,7 @@ exports[`renders correctly for creating 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||||
@ -204,6 +206,7 @@ exports[`renders correctly for creating without permissions 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||||
|
@ -12,6 +12,7 @@ exports[`renders a list with elements correctly 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||||
@ -154,6 +155,7 @@ exports[`renders an empty list correctly 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=""
|
className=""
|
||||||
|
data-loading={true}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||||
|
@ -5,7 +5,7 @@ import StandaloneBanner from '../StandaloneBanner/StandaloneBanner';
|
|||||||
import ResetPasswordDetails from '../common/ResetPasswordDetails/ResetPasswordDetails';
|
import ResetPasswordDetails from '../common/ResetPasswordDetails/ResetPasswordDetails';
|
||||||
|
|
||||||
import { useStyles } from './NewUser.styles';
|
import { useStyles } from './NewUser.styles';
|
||||||
import useResetPassword from '../../../hooks/useResetPassword';
|
import useResetPassword from '../../../hooks/api/getters/useResetPassword/useResetPassword';
|
||||||
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
|
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
|
||||||
import ConditionallyRender from '../../common/ConditionallyRender';
|
import ConditionallyRender from '../../common/ConditionallyRender';
|
||||||
import InvalidToken from '../common/InvalidToken/InvalidToken';
|
import InvalidToken from '../common/InvalidToken/InvalidToken';
|
||||||
|
@ -6,7 +6,7 @@ import { useStyles } from './ResetPassword.styles';
|
|||||||
import { Typography } from '@material-ui/core';
|
import { Typography } from '@material-ui/core';
|
||||||
import ConditionallyRender from '../../common/ConditionallyRender';
|
import ConditionallyRender from '../../common/ConditionallyRender';
|
||||||
import InvalidToken from '../common/InvalidToken/InvalidToken';
|
import InvalidToken from '../common/InvalidToken/InvalidToken';
|
||||||
import useResetPassword from '../../../hooks/useResetPassword';
|
import useResetPassword from '../../../hooks/api/getters/useResetPassword/useResetPassword';
|
||||||
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
|
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
|
||||||
|
|
||||||
const ResetPassword = () => {
|
const ResetPassword = () => {
|
||||||
|
5
frontend/src/constants/featureToggleTypes.ts
Normal file
5
frontend/src/constants/featureToggleTypes.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const EXPERIMENT = 'experiment';
|
||||||
|
export const RELEASE = 'release';
|
||||||
|
export const OPERATIONAL = 'operational';
|
||||||
|
export const KILLSWITCH = 'kill-switch';
|
||||||
|
export const PERMISSION = 'permission';
|
@ -0,0 +1,76 @@
|
|||||||
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AuthenticationError,
|
||||||
|
ForbiddenError,
|
||||||
|
NotFoundError,
|
||||||
|
} from '../../../../store/api-helper';
|
||||||
|
|
||||||
|
export const handleBadRequest = async (
|
||||||
|
setErrors?: Dispatch<SetStateAction<{}>>,
|
||||||
|
res?: Response,
|
||||||
|
requestId?: string
|
||||||
|
) => {
|
||||||
|
if (!setErrors || !requestId) return;
|
||||||
|
if (res) {
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[requestId]: data[0].msg,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleNotFound = (
|
||||||
|
setErrors?: Dispatch<SetStateAction<{}>>,
|
||||||
|
res?: Response,
|
||||||
|
requestId?: string
|
||||||
|
) => {
|
||||||
|
if (!setErrors || !requestId) return;
|
||||||
|
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[requestId]: 'Could not find the requested resource.',
|
||||||
|
}));
|
||||||
|
|
||||||
|
throw new NotFoundError(res?.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleUnauthorized = async (
|
||||||
|
setErrors?: Dispatch<SetStateAction<{}>>,
|
||||||
|
res?: Response,
|
||||||
|
requestId?: string
|
||||||
|
) => {
|
||||||
|
if (!setErrors || !requestId) return;
|
||||||
|
if (res) {
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[requestId]: data[0].msg,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AuthenticationError(res?.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleForbidden = async (
|
||||||
|
setErrors?: Dispatch<SetStateAction<{}>>,
|
||||||
|
res?: Response,
|
||||||
|
requestId?: string
|
||||||
|
) => {
|
||||||
|
if (!setErrors || !requestId) return;
|
||||||
|
if (res) {
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[requestId]: data[0].msg,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenError(res?.status);
|
||||||
|
};
|
@ -0,0 +1,113 @@
|
|||||||
|
import { IUserPayload } from '../../../../interfaces/user';
|
||||||
|
|
||||||
|
import useAPI from '../useApi/useApi';
|
||||||
|
import {
|
||||||
|
handleBadRequest,
|
||||||
|
handleForbidden,
|
||||||
|
handleNotFound,
|
||||||
|
handleUnauthorized,
|
||||||
|
} from './errorHandlers';
|
||||||
|
|
||||||
|
export interface IUserApiErrors {
|
||||||
|
addUser?: string;
|
||||||
|
removeUser?: string;
|
||||||
|
updateUser?: string;
|
||||||
|
changePassword?: string;
|
||||||
|
validatePassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADD_USER_ERROR = 'addUser';
|
||||||
|
export const UPDATE_USER_ERROR = 'updateUser';
|
||||||
|
export const REMOVE_USER_ERROR = 'removeUser';
|
||||||
|
export const CHANGE_PASSWORD_ERROR = 'changePassword';
|
||||||
|
export const VALIDATE_PASSWORD_ERROR = 'validatePassword';
|
||||||
|
|
||||||
|
const useAdminUsersApi = () => {
|
||||||
|
const { loading, makeRequest, createRequest, errors } = useAPI({
|
||||||
|
handleBadRequest,
|
||||||
|
handleNotFound,
|
||||||
|
handleUnauthorized,
|
||||||
|
handleForbidden,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addUser = async (user: IUserPayload) => {
|
||||||
|
const requestId = 'addUser';
|
||||||
|
const req = createRequest(
|
||||||
|
'api/admin/user-admin',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(user),
|
||||||
|
},
|
||||||
|
requestId
|
||||||
|
);
|
||||||
|
|
||||||
|
return makeRequest(req.caller, req.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUser = async (user: IUserPayload) => {
|
||||||
|
const requestId = 'removeUser';
|
||||||
|
const req = createRequest(
|
||||||
|
`api/admin/user-admin/${user.id}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
},
|
||||||
|
requestId
|
||||||
|
);
|
||||||
|
|
||||||
|
return makeRequest(req.caller, req.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUser = async (user: IUserPayload) => {
|
||||||
|
const requestId = 'updateUser';
|
||||||
|
const req = createRequest(
|
||||||
|
`api/admin/user-admin/${user.id}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(user),
|
||||||
|
},
|
||||||
|
requestId
|
||||||
|
);
|
||||||
|
|
||||||
|
return makeRequest(req.caller, req.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePassword = async (user: IUserPayload, password: string) => {
|
||||||
|
const requestId = 'changePassword';
|
||||||
|
const req = createRequest(
|
||||||
|
`api/admin/user-admin/${user.id}/change-password`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
},
|
||||||
|
requestId
|
||||||
|
);
|
||||||
|
|
||||||
|
return makeRequest(req.caller, req.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePassword = async (password: string) => {
|
||||||
|
const requestId = 'validatePassword';
|
||||||
|
const req = createRequest(
|
||||||
|
`api/admin/user-admin/validate-password`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
},
|
||||||
|
requestId
|
||||||
|
);
|
||||||
|
|
||||||
|
return makeRequest(req.caller, req.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
addUser,
|
||||||
|
updateUser,
|
||||||
|
removeUser,
|
||||||
|
changePassword,
|
||||||
|
validatePassword,
|
||||||
|
userApiErrors: errors,
|
||||||
|
userLoading: loading,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAdminUsersApi;
|
166
frontend/src/hooks/api/actions/useApi/useApi.ts
Normal file
166
frontend/src/hooks/api/actions/useApi/useApi.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { useState, Dispatch, SetStateAction } from 'react';
|
||||||
|
import {
|
||||||
|
BAD_REQUEST,
|
||||||
|
FORBIDDEN,
|
||||||
|
NOT_FOUND,
|
||||||
|
OK,
|
||||||
|
UNAUTHORIZED,
|
||||||
|
} from '../../../../constants/statusCodes';
|
||||||
|
import {
|
||||||
|
AuthenticationError,
|
||||||
|
ForbiddenError,
|
||||||
|
headers,
|
||||||
|
NotFoundError,
|
||||||
|
} from '../../../../store/api-helper';
|
||||||
|
import { formatApiPath } from '../../../../utils/format-path';
|
||||||
|
|
||||||
|
interface IUseAPI {
|
||||||
|
handleBadRequest?: (
|
||||||
|
setErrors?: Dispatch<SetStateAction<{}>>,
|
||||||
|
res?: Response,
|
||||||
|
requestId?: string
|
||||||
|
) => void;
|
||||||
|
handleNotFound?: (
|
||||||
|
setErrors?: Dispatch<SetStateAction<{}>>,
|
||||||
|
res?: Response,
|
||||||
|
requestId?: string
|
||||||
|
) => void;
|
||||||
|
handleUnauthorized?: (
|
||||||
|
setErrors?: Dispatch<SetStateAction<{}>>,
|
||||||
|
res?: Response,
|
||||||
|
requestId?: string
|
||||||
|
) => void;
|
||||||
|
handleForbidden?: (
|
||||||
|
setErrors?: Dispatch<SetStateAction<{}>>,
|
||||||
|
res?: Response,
|
||||||
|
requestId?: string
|
||||||
|
) => void;
|
||||||
|
propagateErrors?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useAPI = ({
|
||||||
|
handleBadRequest,
|
||||||
|
handleNotFound,
|
||||||
|
handleForbidden,
|
||||||
|
handleUnauthorized,
|
||||||
|
propagateErrors = false,
|
||||||
|
}: IUseAPI) => {
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const defaultOptions: RequestInit = {
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeRequest = async (
|
||||||
|
apiCaller: any,
|
||||||
|
requestId?: string
|
||||||
|
): Promise<Response> => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiCaller();
|
||||||
|
setLoading(false);
|
||||||
|
if (res.status > 299) {
|
||||||
|
await handleResponses(res, requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === OK) {
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRequest = (
|
||||||
|
path: string,
|
||||||
|
options: any,
|
||||||
|
requestId: string = ''
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
caller: () => {
|
||||||
|
return fetch(formatApiPath(path), {
|
||||||
|
...defaultOptions,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
id: requestId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResponses = async (res: Response, requestId?: string) => {
|
||||||
|
if (res.status === BAD_REQUEST) {
|
||||||
|
if (handleBadRequest) {
|
||||||
|
return handleBadRequest(setErrors, res, requestId);
|
||||||
|
} else {
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
badRequest: 'Bad request format',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propagateErrors) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === NOT_FOUND) {
|
||||||
|
if (handleNotFound) {
|
||||||
|
return handleNotFound(setErrors, res, requestId);
|
||||||
|
} else {
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
notFound: 'Could not find the requested resource',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propagateErrors) {
|
||||||
|
throw new NotFoundError(res.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === UNAUTHORIZED) {
|
||||||
|
if (handleUnauthorized) {
|
||||||
|
return handleUnauthorized(setErrors, res, requestId);
|
||||||
|
} else {
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
unauthorized:
|
||||||
|
'You are not authorized to perform this operation',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propagateErrors) {
|
||||||
|
throw new AuthenticationError(res.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === FORBIDDEN) {
|
||||||
|
if (handleForbidden) {
|
||||||
|
return handleForbidden(setErrors);
|
||||||
|
} else {
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
forbidden: 'This operation is forbidden',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propagateErrors) {
|
||||||
|
throw new ForbiddenError(res.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
makeRequest,
|
||||||
|
createRequest,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAPI;
|
@ -0,0 +1,36 @@
|
|||||||
|
import useProject from '../../getters/useProject/useProject';
|
||||||
|
import useAPI from '../useApi/useApi';
|
||||||
|
|
||||||
|
const useToggleFeatureByEnv = (projectId: string, name: string) => {
|
||||||
|
const { refetch } = useProject(projectId);
|
||||||
|
const { makeRequest, createRequest, errors } = useAPI({
|
||||||
|
propagateErrors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleFeatureByEnvironment = async (
|
||||||
|
env: string,
|
||||||
|
enabled: boolean
|
||||||
|
) => {
|
||||||
|
const path = getToggleAPIPath(env, enabled);
|
||||||
|
const req = createRequest(path, { method: 'POST' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await makeRequest(req.caller, req.id);
|
||||||
|
refetch();
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getToggleAPIPath = (env: string, enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
return `api/admin/projects/${projectId}/features/${name}/environments/${env}/off`;
|
||||||
|
}
|
||||||
|
return `api/admin/projects/${projectId}/features/${name}/environments/${env}/on`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { toggleFeatureByEnvironment, errors };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useToggleFeatureByEnv;
|
@ -0,0 +1,48 @@
|
|||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { IProjectHealthReport } from '../../../../interfaces/project';
|
||||||
|
import { fallbackProject } from '../useProject/fallbackProject';
|
||||||
|
import useSort from '../../../useSort';
|
||||||
|
import { formatApiPath } from '../../../../utils/format-path';
|
||||||
|
|
||||||
|
const useHealthReport = (id: string) => {
|
||||||
|
const KEY = `api/admin/projects/${id}/health-report`;
|
||||||
|
|
||||||
|
const fetcher = () => {
|
||||||
|
const path = formatApiPath(`api/admin/projects/${id}/health-report`);
|
||||||
|
return fetch(path, {
|
||||||
|
method: 'GET',
|
||||||
|
}).then(res => res.json());
|
||||||
|
};
|
||||||
|
|
||||||
|
const [sort] = useSort();
|
||||||
|
|
||||||
|
const { data, error } = useSWR<IProjectHealthReport>(KEY, fetcher);
|
||||||
|
const [loading, setLoading] = useState(!error && !data);
|
||||||
|
|
||||||
|
const refetch = () => {
|
||||||
|
mutate(KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(!error && !data);
|
||||||
|
}, [data, error]);
|
||||||
|
|
||||||
|
const sortedData = (
|
||||||
|
data: IProjectHealthReport | undefined
|
||||||
|
): IProjectHealthReport => {
|
||||||
|
if (data) {
|
||||||
|
return { ...data, features: sort(data.features || []) };
|
||||||
|
}
|
||||||
|
return fallbackProject;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: sortedData(data),
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useHealthReport;
|
@ -0,0 +1,8 @@
|
|||||||
|
export const fallbackProject = {
|
||||||
|
features: [],
|
||||||
|
name: '',
|
||||||
|
health: 0,
|
||||||
|
members: 0,
|
||||||
|
version: '1',
|
||||||
|
description: 'Default',
|
||||||
|
};
|
@ -0,0 +1,17 @@
|
|||||||
|
import { formatApiPath } from '../../../../utils/format-path';
|
||||||
|
|
||||||
|
export const getProjectFetcher = (id: string) => {
|
||||||
|
const fetcher = () => {
|
||||||
|
const path = formatApiPath(`api/admin/projects/${id}`);
|
||||||
|
return fetch(path, {
|
||||||
|
method: 'GET',
|
||||||
|
}).then(res => res.json());
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY = `api/admin/projects/${id}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetcher,
|
||||||
|
KEY,
|
||||||
|
};
|
||||||
|
};
|
38
frontend/src/hooks/api/getters/useProject/useProject.ts
Normal file
38
frontend/src/hooks/api/getters/useProject/useProject.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getProjectFetcher } from './getProjectFetcher';
|
||||||
|
import { IProject } from '../../../../interfaces/project';
|
||||||
|
import { fallbackProject } from './fallbackProject';
|
||||||
|
import useSort from '../../../useSort';
|
||||||
|
|
||||||
|
const useProject = (id: string) => {
|
||||||
|
const { KEY, fetcher } = getProjectFetcher(id);
|
||||||
|
const [sort] = useSort();
|
||||||
|
|
||||||
|
const { data, error } = useSWR<IProject>(KEY, fetcher);
|
||||||
|
const [loading, setLoading] = useState(!error && !data);
|
||||||
|
|
||||||
|
const refetch = () => {
|
||||||
|
mutate(KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(!error && !data);
|
||||||
|
}, [data, error]);
|
||||||
|
|
||||||
|
const sortedData = (data: IProject | undefined): IProject => {
|
||||||
|
if (data) {
|
||||||
|
return { ...data, features: sort(data.features || []) };
|
||||||
|
}
|
||||||
|
return fallbackProject;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: sortedData(data),
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useProject;
|
36
frontend/src/hooks/api/getters/useProjects/useProjects.ts
Normal file
36
frontend/src/hooks/api/getters/useProjects/useProjects.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { formatApiPath } from '../../../../utils/format-path';
|
||||||
|
|
||||||
|
import { IProjectCard } from '../../../../interfaces/project';
|
||||||
|
|
||||||
|
const useProjects = () => {
|
||||||
|
const fetcher = () => {
|
||||||
|
const path = formatApiPath(`api/admin/projects`);
|
||||||
|
return fetch(path, {
|
||||||
|
method: 'GET',
|
||||||
|
}).then(res => res.json());
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY = `api/admin/projects`;
|
||||||
|
|
||||||
|
const { data, error } = useSWR<{ projects: IProjectCard[] }>(KEY, fetcher);
|
||||||
|
const [loading, setLoading] = useState(!error && !data);
|
||||||
|
|
||||||
|
const refetch = () => {
|
||||||
|
mutate(KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(!error && !data);
|
||||||
|
}, [data, error]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects: data?.projects || [],
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useProjects;
|
@ -1,7 +1,7 @@
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import useQueryParams from './useQueryParams';
|
import useQueryParams from '../../../useQueryParams';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { formatApiPath } from '../utils/format-path';
|
import { formatApiPath } from '../../../../utils/format-path';
|
||||||
|
|
||||||
const getFetcher = (token: string) => () => {
|
const getFetcher = (token: string) => () => {
|
||||||
const path = formatApiPath(`auth/reset/validate?token=${token}`);
|
const path = formatApiPath(`auth/reset/validate?token=${token}`);
|
23
frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts
Normal file
23
frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { LibraryBooks } from '@material-ui/icons';
|
||||||
|
|
||||||
|
export const defaultValue = {
|
||||||
|
name: 'Unleash',
|
||||||
|
version: '3.x',
|
||||||
|
environment: '',
|
||||||
|
slogan: 'The enterprise ready feature toggle service.',
|
||||||
|
flags: {},
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
value: 'Documentation',
|
||||||
|
icon: LibraryBooks,
|
||||||
|
href: 'https://docs.getunleash.io/docs?source=oss',
|
||||||
|
title: 'User documentation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'GitHub',
|
||||||
|
icon: 'c_github',
|
||||||
|
href: 'https://github.com/Unleash',
|
||||||
|
title: 'Source code on GitHub',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
37
frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts
Normal file
37
frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { formatApiPath } from '../../../../utils/format-path';
|
||||||
|
import { defaultValue } from './defaultValue';
|
||||||
|
|
||||||
|
const REQUEST_KEY = 'api/admin/ui-config';
|
||||||
|
|
||||||
|
const useUiConfig = () => {
|
||||||
|
const fetcher = () => {
|
||||||
|
const path = formatApiPath(`api/admin/ui-config`);
|
||||||
|
|
||||||
|
return fetch(path, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
}).then(res => res.json());
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, error } = useSWR(REQUEST_KEY, fetcher);
|
||||||
|
const [loading, setLoading] = useState(!error && !data);
|
||||||
|
|
||||||
|
const refetch = () => {
|
||||||
|
mutate(REQUEST_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(!error && !data);
|
||||||
|
}, [data, error]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uiConfig: data || defaultValue,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUiConfig;
|
@ -1,6 +1,6 @@
|
|||||||
import useSWR, { mutate } from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { formatApiPath } from '../utils/format-path';
|
import { formatApiPath } from '../../../../utils/format-path';
|
||||||
|
|
||||||
const useUsers = () => {
|
const useUsers = () => {
|
||||||
const fetcher = () => {
|
const fetcher = () => {
|
@ -1,178 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
BAD_REQUEST,
|
|
||||||
FORBIDDEN,
|
|
||||||
NOT_FOUND,
|
|
||||||
OK,
|
|
||||||
UNAUTHORIZED,
|
|
||||||
} from '../constants/statusCodes';
|
|
||||||
import { IUserPayload } from '../interfaces/user';
|
|
||||||
import {
|
|
||||||
AuthenticationError,
|
|
||||||
ForbiddenError,
|
|
||||||
headers,
|
|
||||||
NotFoundError,
|
|
||||||
} from '../store/api-helper';
|
|
||||||
import { formatApiPath } from '../utils/format-path';
|
|
||||||
|
|
||||||
export interface IUserApiErrors {
|
|
||||||
addUser?: string;
|
|
||||||
removeUser?: string;
|
|
||||||
updateUser?: string;
|
|
||||||
changePassword?: string;
|
|
||||||
validatePassword?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ADD_USER_ERROR = 'addUser';
|
|
||||||
export const UPDATE_USER_ERROR = 'updateUser';
|
|
||||||
export const REMOVE_USER_ERROR = 'removeUser';
|
|
||||||
export const CHANGE_PASSWORD_ERROR = 'changePassword';
|
|
||||||
export const VALIDATE_PASSWORD_ERROR = 'validatePassword';
|
|
||||||
|
|
||||||
const useAdminUsersApi = () => {
|
|
||||||
const [userApiErrors, setUserApiErrors] = useState({});
|
|
||||||
const [userLoading, setUserLoading] = useState(false);
|
|
||||||
|
|
||||||
const defaultOptions: RequestInit = {
|
|
||||||
headers,
|
|
||||||
credentials: 'include',
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeRequest = async (
|
|
||||||
apiCaller: any,
|
|
||||||
type: string
|
|
||||||
): Promise<Response> => {
|
|
||||||
setUserLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await apiCaller();
|
|
||||||
setUserLoading(false);
|
|
||||||
if (res.status > 299) {
|
|
||||||
await handleResponses(res, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === OK) {
|
|
||||||
setUserApiErrors({});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
|
||||||
} catch (e) {
|
|
||||||
setUserLoading(false);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addUser = async (user: IUserPayload) => {
|
|
||||||
return makeRequest(() => {
|
|
||||||
const path = formatApiPath('api/admin/user-admin');
|
|
||||||
return fetch(path, {
|
|
||||||
...defaultOptions,
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(user),
|
|
||||||
});
|
|
||||||
}, 'addUser');
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeUser = async (user: IUserPayload) => {
|
|
||||||
return makeRequest(() => {
|
|
||||||
const path = formatApiPath(`api/admin/user-admin/${user.id}`);
|
|
||||||
return fetch(path, {
|
|
||||||
...defaultOptions,
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
}, 'removeUser');
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateUser = async (user: IUserPayload) => {
|
|
||||||
return makeRequest(() => {
|
|
||||||
const path = formatApiPath(`api/admin/user-admin/${user.id}`);
|
|
||||||
|
|
||||||
return fetch(path, {
|
|
||||||
...defaultOptions,
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(user),
|
|
||||||
});
|
|
||||||
}, 'updateUser');
|
|
||||||
};
|
|
||||||
|
|
||||||
const changePassword = async (user: IUserPayload, password: string) => {
|
|
||||||
return makeRequest(() => {
|
|
||||||
const path = formatApiPath(
|
|
||||||
`api/admin/user-admin/${user.id}/change-password`
|
|
||||||
);
|
|
||||||
return fetch(path, {
|
|
||||||
...defaultOptions,
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ password }),
|
|
||||||
});
|
|
||||||
}, 'changePassword');
|
|
||||||
};
|
|
||||||
|
|
||||||
const validatePassword = async (password: string) => {
|
|
||||||
return makeRequest(() => {
|
|
||||||
const path = formatApiPath(
|
|
||||||
`api/admin/user-admin/validate-password`
|
|
||||||
);
|
|
||||||
return fetch(path, {
|
|
||||||
...defaultOptions,
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ password }),
|
|
||||||
});
|
|
||||||
}, 'validatePassword');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResponses = async (res: Response, type: string) => {
|
|
||||||
if (res.status === BAD_REQUEST) {
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
setUserApiErrors(prev => ({
|
|
||||||
...prev,
|
|
||||||
[type]: data[0].msg,
|
|
||||||
}));
|
|
||||||
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === NOT_FOUND) {
|
|
||||||
setUserApiErrors(prev => ({
|
|
||||||
...prev,
|
|
||||||
[type]: 'Could not find the requested resource.',
|
|
||||||
}));
|
|
||||||
|
|
||||||
throw new NotFoundError(res.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === UNAUTHORIZED) {
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
setUserApiErrors(prev => ({
|
|
||||||
...prev,
|
|
||||||
[type]: data[0].msg,
|
|
||||||
}));
|
|
||||||
|
|
||||||
throw new AuthenticationError(res.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === FORBIDDEN) {
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
setUserApiErrors(prev => ({
|
|
||||||
...prev,
|
|
||||||
[type]: data[0].msg,
|
|
||||||
}));
|
|
||||||
|
|
||||||
throw new ForbiddenError(res.status);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
addUser,
|
|
||||||
updateUser,
|
|
||||||
removeUser,
|
|
||||||
changePassword,
|
|
||||||
validatePassword,
|
|
||||||
userApiErrors,
|
|
||||||
userLoading,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useAdminUsersApi;
|
|
45
frontend/src/hooks/usePagination.ts
Normal file
45
frontend/src/hooks/usePagination.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { paginate } from '../utils/paginate';
|
||||||
|
|
||||||
|
const usePagination = (data: any[], limit: number) => {
|
||||||
|
const [paginatedData, setPaginatedData] = useState([[]]);
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const result = paginate(data, limit);
|
||||||
|
setPaginatedData(result);
|
||||||
|
}, [data, limit]);
|
||||||
|
|
||||||
|
const nextPage = () => {
|
||||||
|
if (pageIndex < paginatedData.length - 1) {
|
||||||
|
setPageIndex(prev => prev + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevPage = () => {
|
||||||
|
if (pageIndex > 0) {
|
||||||
|
setPageIndex(prev => prev - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastPage = () => {
|
||||||
|
setPageIndex(paginatedData.length - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstPage = () => {
|
||||||
|
setPageIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: paginatedData[pageIndex] || [],
|
||||||
|
pages: paginatedData,
|
||||||
|
nextPage,
|
||||||
|
prevPage,
|
||||||
|
lastPage,
|
||||||
|
firstPage,
|
||||||
|
setPageIndex,
|
||||||
|
pageIndex,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePagination;
|
@ -10,9 +10,16 @@ import {
|
|||||||
sortFeaturesByExpiredAtDescending,
|
sortFeaturesByExpiredAtDescending,
|
||||||
sortFeaturesByStatusAscending,
|
sortFeaturesByStatusAscending,
|
||||||
sortFeaturesByStatusDescending,
|
sortFeaturesByStatusDescending,
|
||||||
} from './utils';
|
} from '../component/Reporting/utils';
|
||||||
|
|
||||||
import { LAST_SEEN, NAME, CREATED, EXPIRED, STATUS, REPORT } from './constants';
|
import {
|
||||||
|
LAST_SEEN,
|
||||||
|
NAME,
|
||||||
|
CREATED,
|
||||||
|
EXPIRED,
|
||||||
|
STATUS,
|
||||||
|
REPORT,
|
||||||
|
} from '../component/Reporting/constants';
|
||||||
|
|
||||||
const useSort = () => {
|
const useSort = () => {
|
||||||
const [sortData, setSortData] = useState({
|
const [sortData, setSortData] = useState({
|
11
frontend/src/interfaces/featureToggle.ts
Normal file
11
frontend/src/interfaces/featureToggle.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export interface IFeatureToggleListItem {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
environments: IEnvironments[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEnvironments {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
26
frontend/src/interfaces/project.ts
Normal file
26
frontend/src/interfaces/project.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { IFeatureToggleListItem } from './featureToggle';
|
||||||
|
|
||||||
|
export interface IProjectCard {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
health: number;
|
||||||
|
description: string;
|
||||||
|
featureCount: number;
|
||||||
|
memberCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProject {
|
||||||
|
members: number;
|
||||||
|
version: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
health: number;
|
||||||
|
features: IFeatureToggleListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectHealthReport extends IProject {
|
||||||
|
staleCount: number;
|
||||||
|
potentiallyStaleCount: number;
|
||||||
|
activeCount: number;
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Dialogue from '../../../../component/common/Dialogue';
|
import Dialogue from '../../../../component/common/Dialogue';
|
||||||
|
|
||||||
import { IUserApiErrors } from '../../../../hooks/useAdminUsersApi';
|
import { IUserApiErrors } from '../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
|
||||||
import IRole from '../../../../interfaces/role';
|
import IRole from '../../../../interfaces/role';
|
||||||
import AddUserForm from './AddUserForm/AddUserForm';
|
import AddUserForm from './AddUserForm/AddUserForm';
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ import useLoading from '../../../../../hooks/useLoading';
|
|||||||
import {
|
import {
|
||||||
ADD_USER_ERROR,
|
ADD_USER_ERROR,
|
||||||
UPDATE_USER_ERROR,
|
UPDATE_USER_ERROR,
|
||||||
} from '../../../../../hooks/useAdminUsersApi';
|
} from '../../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
|
||||||
import { Alert } from '@material-ui/lab';
|
import { Alert } from '@material-ui/lab';
|
||||||
|
|
||||||
function AddUserForm({
|
function AddUserForm({
|
||||||
|
@ -16,8 +16,8 @@ import ConditionallyRender from '../../../../component/common/ConditionallyRende
|
|||||||
import AccessContext from '../../../../contexts/AccessContext';
|
import AccessContext from '../../../../contexts/AccessContext';
|
||||||
import { ADMIN } from '../../../../component/AccessProvider/permissions';
|
import { ADMIN } from '../../../../component/AccessProvider/permissions';
|
||||||
import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded';
|
import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded';
|
||||||
import useUsers from '../../../../hooks/useUsers';
|
import useUsers from '../../../../hooks/api/getters/useUsers/useUsers';
|
||||||
import useAdminUsersApi from '../../../../hooks/useAdminUsersApi';
|
import useAdminUsersApi from '../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
|
||||||
import UserListItem from './UserListItem/UserListItem';
|
import UserListItem from './UserListItem/UserListItem';
|
||||||
import loadingData from './loadingData';
|
import loadingData from './loadingData';
|
||||||
import useLoading from '../../../../hooks/useLoading';
|
import useLoading from '../../../../hooks/useLoading';
|
||||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import Dialogue from '../../../component/common/Dialogue/Dialogue';
|
import Dialogue from '../../../component/common/Dialogue/Dialogue';
|
||||||
import ConditionallyRender from '../../../component/common/ConditionallyRender/ConditionallyRender';
|
import ConditionallyRender from '../../../component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import propTypes from 'prop-types';
|
import propTypes from 'prop-types';
|
||||||
import { REMOVE_USER_ERROR } from '../../../hooks/useAdminUsersApi';
|
import { REMOVE_USER_ERROR } from '../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
|
||||||
import { Alert } from '@material-ui/lab';
|
import { Alert } from '@material-ui/lab';
|
||||||
import useLoading from '../../../hooks/useLoading';
|
import useLoading from '../../../hooks/useLoading';
|
||||||
import { Avatar, Typography } from '@material-ui/core';
|
import { Avatar, Typography } from '@material-ui/core';
|
||||||
|
@ -67,7 +67,9 @@ function validate(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function searchProjectUser(query) {
|
function searchProjectUser(query) {
|
||||||
return fetch(`${formatApiPath('api/admin/user-admin/search')}?q=${query}`).then(res => res.json())
|
return fetch(
|
||||||
|
`${formatApiPath('api/admin/user-admin/search')}?q=${query}`
|
||||||
|
).then(res => res.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
30
frontend/src/utils/get-feature-type-icons.ts
Normal file
30
frontend/src/utils/get-feature-type-icons.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
EXPERIMENT,
|
||||||
|
RELEASE,
|
||||||
|
KILLSWITCH,
|
||||||
|
OPERATIONAL,
|
||||||
|
PERMISSION,
|
||||||
|
} from '../constants/featureToggleTypes';
|
||||||
|
|
||||||
|
import LoopIcon from '@material-ui/icons/Loop';
|
||||||
|
import TimelineIcon from '@material-ui/icons/Timeline';
|
||||||
|
import PowerSettingsNewIcon from '@material-ui/icons/PowerSettingsNew';
|
||||||
|
import PanToolIcon from '@material-ui/icons/PanTool';
|
||||||
|
import BuildIcon from '@material-ui/icons/Build';
|
||||||
|
|
||||||
|
export const getFeatureTypeIcons = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case RELEASE:
|
||||||
|
return LoopIcon;
|
||||||
|
case EXPERIMENT:
|
||||||
|
return TimelineIcon;
|
||||||
|
case KILLSWITCH:
|
||||||
|
return PowerSettingsNewIcon;
|
||||||
|
case OPERATIONAL:
|
||||||
|
return BuildIcon;
|
||||||
|
case PERMISSION:
|
||||||
|
return PanToolIcon;
|
||||||
|
default:
|
||||||
|
return LoopIcon;
|
||||||
|
}
|
||||||
|
};
|
35
frontend/src/utils/paginate.test.ts
Normal file
35
frontend/src/utils/paginate.test.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { paginate } from './paginate';
|
||||||
|
|
||||||
|
const createInput = (count: number) => {
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
result.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('it creates the correct amount of pages when count is even', () => {
|
||||||
|
const input = createInput(20);
|
||||||
|
expect(input.length).toBe(20);
|
||||||
|
|
||||||
|
const paginationResult = paginate(input, 5);
|
||||||
|
expect(paginationResult.length).toBe(4);
|
||||||
|
expect(Array.isArray(paginationResult[0])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it creates the correct amount of pages when count is uneven', () => {
|
||||||
|
const input = createInput(33);
|
||||||
|
expect(input.length).toBe(33);
|
||||||
|
|
||||||
|
const paginationResult = paginate(input, 9);
|
||||||
|
expect(paginationResult.length).toBe(4);
|
||||||
|
|
||||||
|
const paginationCount = paginationResult.reduce((acc, cur) => {
|
||||||
|
acc += cur.length;
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
expect(paginationCount).toBe(33);
|
||||||
|
});
|
22
frontend/src/utils/paginate.ts
Normal file
22
frontend/src/utils/paginate.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export const paginate = (data: any[], limit: number) => {
|
||||||
|
let result = [];
|
||||||
|
let currentIdx = 0;
|
||||||
|
|
||||||
|
if (data.length <= currentIdx) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (currentIdx < data.length) {
|
||||||
|
if (currentIdx === 0) {
|
||||||
|
currentIdx += limit;
|
||||||
|
const page = data.slice(0, currentIdx);
|
||||||
|
result.push(page);
|
||||||
|
} else {
|
||||||
|
const page = data.slice(currentIdx, currentIdx + limit);
|
||||||
|
currentIdx += limit;
|
||||||
|
result.push(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user