1
0
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:
Fredrik Strand Oseberg 2021-07-07 11:04:36 +02:00 committed by GitHub
parent 151fccc262
commit 85a7c55fdf
75 changed files with 2139 additions and 385 deletions

View File

@ -46,6 +46,7 @@ body {
background-color: #e2e8f0;
z-index: 9999;
box-shadow: none;
fill: none;
}
.skeleton::before {

View 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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ import {
applyCheckedToFeatures,
} from '../utils';
import useSort from '../useSort';
import useSort from '../../../hooks/useSort';
import styles from './ReportToggleList.module.scss';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
export const fallbackProject = {
features: [],
name: '',
health: 0,
members: 0,
version: '1',
description: 'Default',
};

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,11 @@
export interface IFeatureToggleListItem {
type: string;
name: string;
environments: IEnvironments[];
}
export interface IEnvironments {
name: string;
displayName: string;
enabled: boolean;
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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