mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01: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;
|
||||
z-index: 9999;
|
||||
box-shadow: none;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.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',
|
||||
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 ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
|
||||
|
||||
import { isFeatureExpired } from '../utils';
|
||||
|
||||
import styles from './ReportCard.module.scss';
|
||||
|
||||
const ReportCard = ({ features }) => {
|
||||
const getActiveToggles = () => {
|
||||
const result = features.filter(feature => !feature.stale);
|
||||
|
||||
if (result === 0) return 0;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
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 ReportCard = ({
|
||||
health,
|
||||
activeCount,
|
||||
staleCount,
|
||||
potentiallyStaleCount,
|
||||
}) => {
|
||||
const healthLessThan50 = health < 50;
|
||||
const healthLessThan75 = health < 75;
|
||||
|
||||
const healthClasses = classnames(styles.reportCardHealthRating, {
|
||||
[styles.healthWarning]: healthLessThan75,
|
||||
@ -71,23 +26,21 @@ const ReportCard = ({ features }) => {
|
||||
const renderActiveToggles = () => (
|
||||
<>
|
||||
<CheckIcon className={styles.check} />
|
||||
<span>{activeTogglesCount} active toggles</span>
|
||||
<span>{activeCount} active toggles</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderStaleToggles = () => (
|
||||
<>
|
||||
<ReportProblemOutlinedIcon className={styles.danger} />
|
||||
<span>{staleTogglesCount} stale toggles</span>
|
||||
<span>{staleCount} stale toggles</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderPotentiallyStaleToggles = () => (
|
||||
<>
|
||||
<ReportProblemOutlinedIcon className={styles.danger} />
|
||||
<span>
|
||||
{potentiallyStaleTogglesCount} potentially stale toggles
|
||||
</span>
|
||||
<span>{potentiallyStaleCount} potentially stale toggles</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -98,10 +51,8 @@ const ReportCard = ({ features }) => {
|
||||
<h2 className={styles.header}>Health rating</h2>
|
||||
<div className={styles.reportCardHealthInnerContainer}>
|
||||
<ConditionallyRender
|
||||
condition={healthRating > -1}
|
||||
show={
|
||||
<p className={healthClasses}>{healthRating}%</p>
|
||||
}
|
||||
condition={health > -1}
|
||||
show={<p className={healthClasses}>{health}%</p>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -110,19 +61,19 @@ const ReportCard = ({ features }) => {
|
||||
<ul className={styles.reportCardList}>
|
||||
<li>
|
||||
<ConditionallyRender
|
||||
condition={activeTogglesCount}
|
||||
condition={activeCount}
|
||||
show={renderActiveToggles}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<ConditionallyRender
|
||||
condition={staleTogglesCount}
|
||||
condition={staleCount}
|
||||
show={renderStaleToggles}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<ConditionallyRender
|
||||
condition={potentiallyStaleTogglesCount}
|
||||
condition={potentiallyStaleCount}
|
||||
show={renderPotentiallyStaleToggles}
|
||||
/>
|
||||
</li>
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
applyCheckedToFeatures,
|
||||
} from '../utils';
|
||||
|
||||
import useSort from '../useSort';
|
||||
import useSort from '../../../hooks/useSort';
|
||||
|
||||
import styles from './ReportToggleList.module.scss';
|
||||
|
||||
|
@ -1,20 +1,12 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { filterByProject } from '../utils';
|
||||
|
||||
import ReportToggleList from './ReportToggleList';
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const features = state.features.toJS();
|
||||
const mapStateToProps = (state, ownProps) => {};
|
||||
|
||||
const sameProject = filterByProject(ownProps.selectedProject);
|
||||
const featuresByProject = features.filter(sameProject);
|
||||
|
||||
return {
|
||||
features: featuresByProject,
|
||||
};
|
||||
};
|
||||
|
||||
const ReportToggleListContainer = connect(mapStateToProps, null)(ReportToggleList);
|
||||
const ReportToggleListContainer = connect(
|
||||
mapStateToProps,
|
||||
null
|
||||
)(ReportToggleList);
|
||||
|
||||
export default ReportToggleListContainer;
|
||||
|
@ -15,7 +15,10 @@ import {
|
||||
toggleExpiryByTypeMap,
|
||||
getDiffInDays,
|
||||
} from '../../utils';
|
||||
import { KILLSWITCH, PERMISSION } from '../../constants';
|
||||
import {
|
||||
KILLSWITCH,
|
||||
PERMISSION,
|
||||
} from '../../../../constants/featureToggleTypes';
|
||||
|
||||
import styles from '../ReportToggleList.module.scss';
|
||||
|
||||
|
@ -12,15 +12,17 @@ import { formatProjectOptions } from './utils';
|
||||
import { REPORTING_SELECT_ID } from '../../testIds';
|
||||
|
||||
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([
|
||||
{ key: 'default', label: 'Default' },
|
||||
]);
|
||||
const [selectedProject, setSelectedProject] = useState('default');
|
||||
const { project, error, refetch } = useHealthReport(selectedProject);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeatureToggles();
|
||||
setSelectedProject(projects[0].id);
|
||||
/* eslint-disable-next-line */
|
||||
}, []);
|
||||
@ -62,8 +64,28 @@ const Reporting = ({ fetchFeatureToggles, projects }) => {
|
||||
show={renderSelect}
|
||||
/>
|
||||
|
||||
<ReportCardContainer selectedProject={selectedProject} />
|
||||
<ReportToggleListContainer selectedProject={selectedProject} />
|
||||
<ConditionallyRender
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -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 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 */
|
||||
export const FOURTYDAYS = 40;
|
||||
export const SEVENDAYS = 7;
|
||||
|
@ -1,7 +1,13 @@
|
||||
import parseISO from 'date-fns/parseISO';
|
||||
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 = {
|
||||
[EXPERIMENT]: FOURTYDAYS,
|
||||
@ -21,9 +27,11 @@ export const getCheckedState = (name, features) => {
|
||||
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) => {
|
||||
if (diff >= toggleExpiryByTypeMap[type]) return true;
|
||||
@ -56,7 +64,8 @@ export const sortFeaturesByNameAscending = features => {
|
||||
return sorted;
|
||||
};
|
||||
|
||||
export const sortFeaturesByNameDescending = features => sortFeaturesByNameAscending([...features]).reverse();
|
||||
export const sortFeaturesByNameDescending = features =>
|
||||
sortFeaturesByNameAscending([...features]).reverse();
|
||||
|
||||
export const sortFeaturesByLastSeenAscending = features => {
|
||||
const sorted = [...features];
|
||||
@ -72,7 +81,8 @@ export const sortFeaturesByLastSeenAscending = features => {
|
||||
return sorted;
|
||||
};
|
||||
|
||||
export const sortFeaturesByLastSeenDescending = features => sortFeaturesByLastSeenAscending([...features]).reverse();
|
||||
export const sortFeaturesByLastSeenDescending = features =>
|
||||
sortFeaturesByLastSeenAscending([...features]).reverse();
|
||||
|
||||
export const sortFeaturesByCreatedAtAscending = features => {
|
||||
const sorted = [...features];
|
||||
@ -85,7 +95,8 @@ export const sortFeaturesByCreatedAtAscending = features => {
|
||||
return sorted;
|
||||
};
|
||||
|
||||
export const sortFeaturesByCreatedAtDescending = features => sortFeaturesByCreatedAtAscending([...features]).reverse();
|
||||
export const sortFeaturesByCreatedAtDescending = features =>
|
||||
sortFeaturesByCreatedAtAscending([...features]).reverse();
|
||||
|
||||
export const sortFeaturesByExpiredAtAscending = features => {
|
||||
const sorted = [...features];
|
||||
@ -149,7 +160,8 @@ export const sortFeaturesByStatusAscending = features => {
|
||||
return sorted;
|
||||
};
|
||||
|
||||
export const sortFeaturesByStatusDescending = features => sortFeaturesByStatusAscending([...features]).reverse();
|
||||
export const sortFeaturesByStatusDescending = features =>
|
||||
sortFeaturesByStatusAscending([...features]).reverse();
|
||||
|
||||
export const pluralize = (items, word) => {
|
||||
if (items === 1) return `${items} ${word}`;
|
||||
@ -163,7 +175,8 @@ export const getDates = dateString => {
|
||||
return [date, now];
|
||||
};
|
||||
|
||||
export const filterByProject = selectedProject => feature => feature.project === selectedProject;
|
||||
export const filterByProject = selectedProject => feature =>
|
||||
feature.project === selectedProject;
|
||||
|
||||
export const isFeatureExpired = feature => {
|
||||
const [date, now] = getDates(feature.createdAt);
|
||||
|
@ -33,6 +33,7 @@ exports[`renders correctly with permissions 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<h2
|
||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||
@ -551,6 +552,7 @@ exports[`renders correctly without permission 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<h2
|
||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||
|
@ -7,6 +7,7 @@ interface IAnimateOnMountProps {
|
||||
start: string;
|
||||
leave: string;
|
||||
container?: string;
|
||||
style?: Object;
|
||||
}
|
||||
|
||||
const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
||||
@ -16,6 +17,7 @@ const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
||||
leave,
|
||||
container,
|
||||
children,
|
||||
style,
|
||||
}) => {
|
||||
const [show, setShow] = useState(mounted);
|
||||
const [styles, setStyles] = useState('');
|
||||
@ -49,6 +51,7 @@ const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
||||
container ? container : ''
|
||||
}`}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
style={{ ...style }}
|
||||
>
|
||||
{children}
|
||||
</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: {
|
||||
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: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
@ -82,9 +82,9 @@ const Feedback = ({
|
||||
return (
|
||||
<AnimateOnMount
|
||||
mounted={show}
|
||||
enter={styles.feedbackEnter}
|
||||
start={styles.feedbackStart}
|
||||
leave={styles.feedbackLeave}
|
||||
start={commonStyles.fadeInBottomStart}
|
||||
enter={commonStyles.fadeInBottomEnter}
|
||||
leave={commonStyles.fadeInBottomLeave}
|
||||
container={styles.animateContainer}
|
||||
>
|
||||
<div className={styles.feedback}>
|
||||
|
@ -7,20 +7,33 @@ import ConditionallyRender from '../ConditionallyRender/ConditionallyRender';
|
||||
|
||||
import { useStyles } from './styles';
|
||||
|
||||
const HeaderTitle = ({ title, actions, subtitle, variant, loading }) => {
|
||||
const HeaderTitle = ({
|
||||
title,
|
||||
actions,
|
||||
subtitle,
|
||||
variant,
|
||||
loading,
|
||||
className,
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
const headerClasses = classnames({ skeleton: loading });
|
||||
|
||||
return (
|
||||
<div className={styles.headerTitleContainer}>
|
||||
<div className={headerClasses}>
|
||||
<Typography variant={variant || 'h2'} className={styles.headerTitle}>
|
||||
<div className={headerClasses} data-loading>
|
||||
<Typography
|
||||
variant={variant || 'h2'}
|
||||
className={classnames(styles.headerTitle, className)}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && <small>{subtitle}</small>}
|
||||
</div>
|
||||
|
||||
<ConditionallyRender condition={actions} show={<div className={styles.headerActions}>{actions}</div>} />
|
||||
<ConditionallyRender
|
||||
condition={actions}
|
||||
show={<div className={styles.headerActions}>{actions}</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 RBAC = 'RBAC';
|
||||
export const OIDC = 'OIDC';
|
||||
|
||||
export const PROJECTCARDACTIONS = false;
|
||||
export const PROJECTFILTERING = false;
|
||||
|
@ -50,6 +50,7 @@ exports[`renders correctly with one feature 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<h2
|
||||
className="MuiTypography-root makeStyles-headerTitle-13 MuiTypography-h2"
|
||||
@ -286,6 +287,7 @@ exports[`renders correctly with one feature without permissions 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<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>
|
||||
<fieldset
|
||||
aria-hidden={true}
|
||||
className="PrivateNotchedOutline-root-18 MuiOutlinedInput-notchedOutline"
|
||||
className="PrivateNotchedOutline-root-21 MuiOutlinedInput-notchedOutline"
|
||||
>
|
||||
<legend
|
||||
className="PrivateNotchedOutline-legendLabelled-20 PrivateNotchedOutline-legendNotched-21"
|
||||
className="PrivateNotchedOutline-legendLabelled-23 PrivateNotchedOutline-legendNotched-24"
|
||||
>
|
||||
<span>
|
||||
Stickiness
|
||||
|
@ -140,10 +140,10 @@ exports[`renders correctly with one feature 1`] = `
|
||||
</svg>
|
||||
<fieldset
|
||||
aria-hidden={true}
|
||||
className="PrivateNotchedOutline-root-16 MuiOutlinedInput-notchedOutline"
|
||||
className="PrivateNotchedOutline-root-19 MuiOutlinedInput-notchedOutline"
|
||||
>
|
||||
<legend
|
||||
className="PrivateNotchedOutline-legendLabelled-18"
|
||||
className="PrivateNotchedOutline-legendLabelled-21"
|
||||
>
|
||||
<span>
|
||||
Project
|
||||
@ -175,7 +175,7 @@ exports[`renders correctly with one feature 1`] = `
|
||||
>
|
||||
<span
|
||||
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]}
|
||||
onDragLeave={[Function]}
|
||||
onFocus={[Function]}
|
||||
@ -194,7 +194,7 @@ exports[`renders correctly with one feature 1`] = `
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
className="PrivateSwitchBase-input-23 MuiSwitch-input"
|
||||
className="PrivateSwitchBase-input-26 MuiSwitch-input"
|
||||
disabled={false}
|
||||
onChange={[Function]}
|
||||
type="checkbox"
|
||||
@ -327,7 +327,7 @@ exports[`renders correctly with one feature 1`] = `
|
||||
</div>
|
||||
<hr />
|
||||
<div
|
||||
className="MuiPaper-root makeStyles-tabNav-24 MuiPaper-elevation1 MuiPaper-rounded"
|
||||
className="MuiPaper-root makeStyles-tabNav-27 MuiPaper-elevation1 MuiPaper-rounded"
|
||||
>
|
||||
<div
|
||||
className="MuiTabs-root"
|
||||
@ -375,7 +375,7 @@ exports[`renders correctly with one feature 1`] = `
|
||||
Activation
|
||||
</span>
|
||||
<span
|
||||
className="PrivateTabIndicator-root-25 PrivateTabIndicator-colorPrimary-26 MuiTabs-indicator"
|
||||
className="PrivateTabIndicator-root-28 PrivateTabIndicator-colorPrimary-29 MuiTabs-indicator"
|
||||
style={Object {}}
|
||||
/>
|
||||
</button>
|
||||
|
@ -39,6 +39,8 @@ import { P, C } from '../common/flags';
|
||||
import NewUser from '../user/NewUser';
|
||||
import ResetPassword from '../user/ResetPassword/ResetPassword';
|
||||
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
|
||||
import ProjectListNew from '../project/ProjectListNew/ProjectListNew';
|
||||
import Project from '../project/Project/Project';
|
||||
|
||||
import {
|
||||
List,
|
||||
@ -231,7 +233,15 @@ export const routes = [
|
||||
type: 'protected',
|
||||
layout: 'main',
|
||||
},
|
||||
|
||||
{
|
||||
path: '/projects/:id',
|
||||
parent: '/projects',
|
||||
title: ':id',
|
||||
component: Project,
|
||||
flag: P,
|
||||
type: 'protected',
|
||||
layout: 'main',
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
title: 'Projects',
|
||||
@ -241,6 +251,16 @@ export const routes = [
|
||||
type: 'protected',
|
||||
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',
|
||||
|
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,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Button,
|
||||
useMediaQuery,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
Add,
|
||||
@ -29,10 +27,10 @@ import ConfirmDialogue from '../../common/Dialogue';
|
||||
import PageContent from '../../common/PageContent/PageContent';
|
||||
import { useStyles } from './styles';
|
||||
import AccessContext from '../../../contexts/AccessContext';
|
||||
import ResponsiveButton from '../../common/ResponsiveButton/ResponsiveButton';
|
||||
|
||||
const ProjectList = ({ projects, fetchProjects, removeProject, history }) => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const smallScreen = useMediaQuery('(max-width:700px)');
|
||||
const [showDelDialogue, setShowDelDialogue] = useState(false);
|
||||
const [project, setProject] = useState(undefined);
|
||||
const styles = useStyles();
|
||||
@ -44,26 +42,11 @@ const ProjectList = ({ projects, fetchProjects, removeProject, history }) => {
|
||||
<ConditionallyRender
|
||||
condition={hasAccess(CREATE_PROJECT)}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={smallScreen}
|
||||
show={
|
||||
<Tooltip title="Add new project">
|
||||
<IconButton
|
||||
onClick={() => history.push('/projects/create')}
|
||||
>
|
||||
<Add />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
elseShow={
|
||||
<Button
|
||||
onClick={() => history.push('/projects/create')}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
>
|
||||
Add new project
|
||||
</Button>
|
||||
}
|
||||
<ResponsiveButton
|
||||
Icon={Add}
|
||||
onClick={() => history.push('/projects/create')}
|
||||
maxWidth="700px"
|
||||
tooltip="Add new project"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
@ -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 fetchAccess = async () => {
|
||||
const access = await projectApi.fetchAccess(projectId);
|
||||
setRoles(access.roles);
|
||||
setUsers(
|
||||
access.users.map(u => ({ ...u, name: u.name || '(No name)' }))
|
||||
);
|
||||
try {
|
||||
const access = await projectApi.fetchAccess(projectId);
|
||||
setRoles(access.roles);
|
||||
setUsers(
|
||||
access.users.map(u => ({ ...u, name: u.name || '(No name)' }))
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -12,6 +12,7 @@ exports[`renders correctly with one strategy 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<h2
|
||||
className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2"
|
||||
@ -140,6 +141,7 @@ exports[`renders correctly with one strategy without permissions 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<h2
|
||||
className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2"
|
||||
|
@ -12,6 +12,7 @@ exports[`renders correctly with one strategy 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<h2
|
||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||
|
@ -12,6 +12,7 @@ exports[`it supports editMode 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<h2
|
||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||
@ -108,6 +109,7 @@ exports[`renders correctly for creating 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<h2
|
||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||
@ -204,6 +206,7 @@ exports[`renders correctly for creating without permissions 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<h2
|
||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||
|
@ -12,6 +12,7 @@ exports[`renders a list with elements correctly 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<h2
|
||||
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
|
||||
@ -154,6 +155,7 @@ exports[`renders an empty list correctly 1`] = `
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
data-loading={true}
|
||||
>
|
||||
<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 { useStyles } from './NewUser.styles';
|
||||
import useResetPassword from '../../../hooks/useResetPassword';
|
||||
import useResetPassword from '../../../hooks/api/getters/useResetPassword/useResetPassword';
|
||||
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
|
||||
import ConditionallyRender from '../../common/ConditionallyRender';
|
||||
import InvalidToken from '../common/InvalidToken/InvalidToken';
|
||||
|
@ -6,7 +6,7 @@ import { useStyles } from './ResetPassword.styles';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import ConditionallyRender from '../../common/ConditionallyRender';
|
||||
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';
|
||||
|
||||
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 useQueryParams from './useQueryParams';
|
||||
import useQueryParams from '../../../useQueryParams';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { formatApiPath } from '../utils/format-path';
|
||||
import { formatApiPath } from '../../../../utils/format-path';
|
||||
|
||||
const getFetcher = (token: string) => () => {
|
||||
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 { useState, useEffect } from 'react';
|
||||
import { formatApiPath } from '../utils/format-path';
|
||||
import { formatApiPath } from '../../../../utils/format-path';
|
||||
|
||||
const useUsers = () => {
|
||||
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,
|
||||
sortFeaturesByStatusAscending,
|
||||
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 [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 Dialogue from '../../../../component/common/Dialogue';
|
||||
|
||||
import { IUserApiErrors } from '../../../../hooks/useAdminUsersApi';
|
||||
import { IUserApiErrors } from '../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
|
||||
import IRole from '../../../../interfaces/role';
|
||||
import AddUserForm from './AddUserForm/AddUserForm';
|
||||
|
||||
|
@ -18,7 +18,7 @@ import useLoading from '../../../../../hooks/useLoading';
|
||||
import {
|
||||
ADD_USER_ERROR,
|
||||
UPDATE_USER_ERROR,
|
||||
} from '../../../../../hooks/useAdminUsersApi';
|
||||
} from '../../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
|
||||
function AddUserForm({
|
||||
|
@ -16,8 +16,8 @@ import ConditionallyRender from '../../../../component/common/ConditionallyRende
|
||||
import AccessContext from '../../../../contexts/AccessContext';
|
||||
import { ADMIN } from '../../../../component/AccessProvider/permissions';
|
||||
import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded';
|
||||
import useUsers from '../../../../hooks/useUsers';
|
||||
import useAdminUsersApi from '../../../../hooks/useAdminUsersApi';
|
||||
import useUsers from '../../../../hooks/api/getters/useUsers/useUsers';
|
||||
import useAdminUsersApi from '../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
|
||||
import UserListItem from './UserListItem/UserListItem';
|
||||
import loadingData from './loadingData';
|
||||
import useLoading from '../../../../hooks/useLoading';
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import Dialogue from '../../../component/common/Dialogue/Dialogue';
|
||||
import ConditionallyRender from '../../../component/common/ConditionallyRender/ConditionallyRender';
|
||||
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 useLoading from '../../../hooks/useLoading';
|
||||
import { Avatar, Typography } from '@material-ui/core';
|
||||
|
@ -67,7 +67,9 @@ function validate(id) {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
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