1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

Feature/stale dashboard (#243)

* feat: initial structure

* feat: add reportCard

* feat: add report-toggle-list

* feat: add report-card

* feat: connect data

* feat: add material icons

* feat: add table styles

* fix: rename reportcard

* feat: add checkbox functionality

* fix: correct invalid json format

* feat: add support for changing project

* fix: linting

* fix: remove trailing slash

* fix: change rewrites to routes

* fix: update glob

* feat: add name sorting

* refactor: swap routes for rewrites in vercel.json

* feat: add rewrite rules

* feat: add all rewrite rules

* feat: initial useSort implementation

* feat: finalized useSort for consistent name sorting

* feat: date parsing

* feat: implement sorting functionality for headers

* fix: ensure consistent naming in useSort

* feat: finish reportcard

* fix: remove loader class

* feat: hide bulk actions behind feature flag

* feat: add tests

* fix: lint and proptypes

* fix: lint

* fix: update select styles

* fix: create snapshots from node 12

* fix: safari flex inconsistencies

* feat: expand conditionallyRender functionality to encompass passing functions as elseShow param

* fix: conditional project selector

* fix: add missing new-line

* fix: move dependencies

Co-authored-by: Ivar Conradi Østhus <ivarconr@gmail.com>
This commit is contained in:
Fredrik Strand Oseberg 2021-02-25 10:54:53 +01:00 committed by GitHub
parent 74b04b7a43
commit d11bee0b95
30 changed files with 1746 additions and 44 deletions

View File

@ -40,6 +40,10 @@
"main": "./index.js",
"dependencies": {},
"devDependencies": {
"@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2",
"classnames": "^2.2.6",
"date-fns": "^2.17.0",
"@babel/core": "^7.9.0",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-decorators": "^7.8.3",

26
frontend/src/app.css Normal file
View File

@ -0,0 +1,26 @@
:root {
/* FONT SIZE */
--h1-size: 1.25rem;
--p-size: 1.1rem;
/* PADDING */
--card-padding: 2rem;
--card-padding-x: 2rem;
--card-padding-y: 2rem;
/* MARGIN */
--card-margin-y: 1rem;
--card-margin-x: 1rem;
/* COLORS*/
--success: #3bd86e;
--danger: #d95e5e;
--warning: #d67c3d;
}
h1,
h2 {
padding: 0;
margin: 0;
line-height: 24px;
}

View File

@ -0,0 +1,32 @@
const ConditionallyRender = ({ condition, show, elseShow }) => {
const handleFunction = renderFunc => {
const result = renderFunc();
if (!result) {
/* eslint-disable-next-line */
console.warn(
'Nothing was returned from your render function. Verify that you are returning a valid react component'
);
return null;
}
return result;
};
const isFunc = param => typeof param === 'function';
if (condition && show) {
if (isFunc(show)) {
return handleFunction(show);
}
return show;
}
if (!condition && elseShow) {
if (isFunc(elseShow)) {
return handleFunction(elseShow);
}
return elseShow;
}
return null;
};
export default ConditionallyRender;

View File

@ -1,11 +1,15 @@
import React from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
const Select = ({ name, value, label, options, style, onChange, disabled = false, filled }) => {
const Select = ({ name, value, label, options, style, onChange, disabled = false, filled, className }) => {
const wrapper = Object.assign({ width: 'auto' }, style);
return (
<div
className="mdl-textfield mdl-js-textfield mdl-textfield--floating-label is-dirty is-upgraded"
className={classnames(
'mdl-textfield mdl-js-textfield mdl-textfield--floating-label is-dirty is-upgraded',
className
)}
style={wrapper}
>
<select
@ -14,7 +18,10 @@ const Select = ({ name, value, label, options, style, onChange, disabled = false
disabled={disabled}
onChange={onChange}
value={value}
style={{ width: 'auto', background: filled ? '#f5f5f5' : 'none' }}
style={{
width: 'auto',
background: filled ? '#f5f5f5' : 'none',
}}
>
{options.map(o => (
<option key={o.key} value={o.key} title={o.title}>

View File

@ -17,7 +17,7 @@ export default class App extends PureComponent {
return (
<Layout fixedHeader>
<Header location={this.props.location} />
<Content className="mdl-color--grey-50" style={{ display: 'flex', flexDirection: 'column' }}>
<Content className="mdl-color--grey-50">
<Grid noSpacing className={styles.content} style={{ flex: 1 }}>
<Cell col={12}>
{this.props.children}

View File

@ -119,6 +119,19 @@ exports[`should render DrawerMenu 1`] = `
Addons
</a>
<a
aria-current={null}
className="navigationLink mdl-color-text--grey-900"
href="/reporting"
onClick={[Function]}
>
<react-mdl-Icon
className="navigationIcon"
name="report"
/>
Reporting
</a>
<a
aria-current={null}
className="navigationLink mdl-color-text--grey-900"
@ -260,6 +273,19 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
Addons
</a>
<a
aria-current={null}
className="navigationLink mdl-color-text--grey-900"
href="/reporting"
onClick={[Function]}
>
<react-mdl-Icon
className="navigationIcon"
name="report"
/>
Reporting
</a>
<a
aria-current={null}
className="navigationLink mdl-color-text--grey-900"

View File

@ -71,6 +71,13 @@ exports[`should render DrawerMenu 1`] = `
>
Addons
</a>
<a
aria-current={null}
href="/reporting"
onClick={[Function]}
>
Reporting
</a>
<a
aria-current={null}
href="/logout"
@ -203,6 +210,13 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
>
Addons
</a>
<a
aria-current={null}
href="/reporting"
onClick={[Function]}
>
Reporting
</a>
<a
aria-current={null}
href="/logout"

View File

@ -59,6 +59,12 @@ Array [
"path": "/addons",
"title": "Addons",
},
Object {
"component": [Function],
"icon": "report",
"path": "/reporting",
"title": "Reporting",
},
Object {
"component": [Function],
"icon": "exit_to_app",
@ -261,6 +267,12 @@ Array [
"path": "/addons",
"title": "Addons",
},
Object {
"component": [Function],
"icon": "report",
"path": "/reporting",
"title": "Reporting",
},
Object {
"component": [Function],
"icon": "exit_to_app",

View File

@ -1,12 +1,12 @@
import { routes, baseRoutes, getRoute } from '../routes';
test('returns all defined routes', () => {
expect(routes.length).toEqual(32);
expect(routes.length).toEqual(33);
expect(routes).toMatchSnapshot();
});
test('returns all baseRoutes', () => {
expect(baseRoutes.length).toEqual(10);
expect(baseRoutes.length).toEqual(11);
expect(baseRoutes).toMatchSnapshot();
});

View File

@ -51,4 +51,6 @@ class HeaderComponent extends PureComponent {
}
}
export default connect(state => ({ uiConfig: state.uiConfig.toJS() }), { init: loadInitialData })(HeaderComponent);
export default connect(state => ({ uiConfig: state.uiConfig.toJS() }), {
init: loadInitialData,
})(HeaderComponent);

View File

@ -30,66 +30,232 @@ import Admin from '../../page/admin';
import AdminApi from '../../page/admin/api';
import AdminUsers from '../../page/admin/users';
import AdminAuth from '../../page/admin/auth';
import Reporting from '../../page/reporting';
import { P, C } from '../common/flags';
export const routes = [
// Features
{ path: '/features/create', parent: '/features', title: 'Create', component: CreateFeatureToggle },
{ path: '/features/copy/:copyToggle', parent: '/features', title: 'Copy', component: CopyFeatureToggle },
{ path: '/features/:activeTab/:name', parent: '/features', title: ':name', component: ViewFeatureToggle },
{ path: '/features', title: 'Feature Toggles', icon: 'list', component: Features },
{
path: '/features/create',
parent: '/features',
title: 'Create',
component: CreateFeatureToggle,
},
{
path: '/features/copy/:copyToggle',
parent: '/features',
title: 'Copy',
component: CopyFeatureToggle,
},
{
path: '/features/:activeTab/:name',
parent: '/features',
title: ':name',
component: ViewFeatureToggle,
},
{
path: '/features',
title: 'Feature Toggles',
icon: 'list',
component: Features,
},
// Strategies
{ path: '/strategies/create', title: 'Create', parent: '/strategies', component: CreateStrategies },
{
path: '/strategies/create',
title: 'Create',
parent: '/strategies',
component: CreateStrategies,
},
{
path: '/strategies/:activeTab/:strategyName',
title: ':strategyName',
parent: '/strategies',
component: StrategyView,
},
{ path: '/strategies', title: 'Strategies', icon: 'extension', component: Strategies },
{
path: '/strategies',
title: 'Strategies',
icon: 'extension',
component: Strategies,
},
// History
{ path: '/history/:toggleName', title: ':toggleName', parent: '/history', component: HistoryTogglePage },
{ path: '/history', title: 'Event History', icon: 'history', component: HistoryPage },
{
path: '/history/:toggleName',
title: ':toggleName',
parent: '/history',
component: HistoryTogglePage,
},
{
path: '/history',
title: 'Event History',
icon: 'history',
component: HistoryPage,
},
// Archive
{ path: '/archive/:activeTab/:name', title: ':name', parent: '/archive', component: ShowArchive },
{ path: '/archive', title: 'Archived Toggles', icon: 'archive', component: Archive },
{
path: '/archive/:activeTab/:name',
title: ':name',
parent: '/archive',
component: ShowArchive,
},
{
path: '/archive',
title: 'Archived Toggles',
icon: 'archive',
component: Archive,
},
// Applications
{ path: '/applications/:name', title: ':name', parent: '/applications', component: ApplicationView },
{ path: '/applications', title: 'Applications', icon: 'apps', component: Applications },
{
path: '/applications/:name',
title: ':name',
parent: '/applications',
component: ApplicationView,
},
{
path: '/applications',
title: 'Applications',
icon: 'apps',
component: Applications,
},
// Context
{ path: '/context/create', parent: '/context', title: 'Create', component: CreateContextField },
{ path: '/context/edit/:name', parent: '/context', title: ':name', component: EditContextField },
{ path: '/context', title: 'Context Fields', icon: 'album', component: ContextFields, flag: C },
{
path: '/context/create',
parent: '/context',
title: 'Create',
component: CreateContextField,
},
{
path: '/context/edit/:name',
parent: '/context',
title: ':name',
component: EditContextField,
},
{
path: '/context',
title: 'Context Fields',
icon: 'album',
component: ContextFields,
flag: C,
},
// Project
{ path: '/projects/create', parent: '/projects', title: 'Create', component: CreateProject },
{ path: '/projects/edit/:id', parent: '/projects', title: ':id', component: EditProject },
{ path: '/projects', title: 'Projects', icon: 'folder_open', component: ListProjects, flag: P },
{
path: '/projects/create',
parent: '/projects',
title: 'Create',
component: CreateProject,
},
{
path: '/projects/edit/:id',
parent: '/projects',
title: ':id',
component: EditProject,
},
{
path: '/projects',
title: 'Projects',
icon: 'folder_open',
component: ListProjects,
flag: P,
},
// Admin
{ path: '/admin/api', parent: '/admin', title: 'API access', component: AdminApi },
{ path: '/admin/users', parent: '/admin', title: 'Users', component: AdminUsers },
{ path: '/admin/auth', parent: '/admin', title: 'Authentication', component: AdminAuth },
{ path: '/admin', title: 'Admin', icon: 'album', component: Admin, hidden: true },
{
path: '/admin/api',
parent: '/admin',
title: 'API access',
component: AdminApi,
},
{
path: '/admin/users',
parent: '/admin',
title: 'Users',
component: AdminUsers,
},
{
path: '/admin/auth',
parent: '/admin',
title: 'Authentication',
component: AdminAuth,
},
{
path: '/admin',
title: 'Admin',
icon: 'album',
component: Admin,
hidden: true,
},
{ path: '/tag-types/create', parent: '/tag-types', title: 'Create', component: CreateTagType },
{ path: '/tag-types/edit/:name', parent: '/tag-types', title: ':name', component: EditTagType },
{ path: '/tag-types', title: 'Tag types', icon: 'label', component: ListTagTypes },
{
path: '/tag-types/create',
parent: '/tag-types',
title: 'Create',
component: CreateTagType,
},
{
path: '/tag-types/edit/:name',
parent: '/tag-types',
title: ':name',
component: EditTagType,
},
{
path: '/tag-types',
title: 'Tag types',
icon: 'label',
component: ListTagTypes,
},
{ path: '/tags/create', parent: '/tags', title: 'Create', component: CreateTag },
{ path: '/tags', title: 'Tags', icon: 'label', component: ListTags, hidden: true },
{
path: '/tags/create',
parent: '/tags',
title: 'Create',
component: CreateTag,
},
{
path: '/tags',
title: 'Tags',
icon: 'label',
component: ListTags,
hidden: true,
},
// Addons
{ path: '/addons/create/:provider', parent: '/addons', title: 'Create', component: AddonsCreate },
{ path: '/addons/edit/:id', parent: '/addons', title: 'Edit', component: AddonsEdit },
{ path: '/addons', title: 'Addons', icon: 'device_hub', component: Addons, hidden: false },
{ path: '/logout', title: 'Sign out', icon: 'exit_to_app', component: LogoutFeatures },
{
path: '/addons/create/:provider',
parent: '/addons',
title: 'Create',
component: AddonsCreate,
},
{
path: '/addons/edit/:id',
parent: '/addons',
title: 'Edit',
component: AddonsEdit,
},
{
path: '/addons',
title: 'Addons',
icon: 'device_hub',
component: Addons,
hidden: false,
},
{
path: '/reporting',
title: 'Reporting',
icon: 'report',
component: Reporting,
},
{
path: '/logout',
title: 'Sign out',
icon: 'exit_to_app',
component: LogoutFeatures,
},
];
export const getRoute = path => routes.find(route => route.path === path);

View File

@ -0,0 +1,42 @@
import React from 'react';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import { mount } from 'enzyme/build';
import Reporting from '../reporting';
import { testProjects, testFeatures } from '../testData';
const mockStore = {
projects: testProjects,
features: { toJS: () => testFeatures },
};
const mockReducer = state => state;
jest.mock('react-mdl', () => ({
Checkbox: jest.fn().mockImplementation(({ children }) => children),
Card: jest.fn().mockImplementation(({ children }) => children),
Menu: jest.fn().mockImplementation(({ children }) => children),
MenuItem: jest.fn().mockImplementation(({ children }) => children),
}));
test('changing projects renders only toggles from that project', () => {
const wrapper = mount(
<Provider store={createStore(mockReducer, mockStore)}>
<Reporting projects={testProjects} features={testFeatures} fetchFeatureToggles={() => {}} />
</Provider>
);
const select = wrapper.find('.mdl-textfield__input').first();
expect(select.contains(<option value="default">Default</option>)).toBe(true);
expect(select.contains(<option value="myProject">MyProject</option>)).toBe(true);
let list = wrapper.find('tr');
/* Length of projects belonging to project (3) + header row (1) */
expect(list.length).toBe(4);
select.simulate('change', { target: { value: 'myProject' } });
list = wrapper.find('tr');
expect(list.length).toBe(3);
});

View File

@ -0,0 +1,126 @@
import {
sortFeaturesByNameAscending,
sortFeaturesByNameDescending,
sortFeaturesByLastSeenAscending,
sortFeaturesByLastSeenDescending,
sortFeaturesByCreatedAtAscending,
sortFeaturesByCreatedAtDescending,
sortFeaturesByExpiredAtAscending,
sortFeaturesByExpiredAtDescending,
sortFeaturesByStatusAscending,
sortFeaturesByStatusDescending,
} from '../utils';
const getTestData = () => [
{
name: 'abe',
createdAt: '2021-02-14T02:42:34.515Z',
lastSeenAt: '2021-02-21T19:34:21.830Z',
type: 'release',
stale: false,
},
{
name: 'bet',
createdAt: '2021-02-13T02:42:34.515Z',
lastSeenAt: '2021-02-19T19:34:21.830Z',
type: 'release',
stale: false,
},
{
name: 'cat',
createdAt: '2021-02-12T02:42:34.515Z',
lastSeenAt: '2021-02-18T19:34:21.830Z',
type: 'experiment',
stale: true,
},
];
test('it sorts features by name ascending', () => {
const testData = getTestData();
const result = sortFeaturesByNameAscending(testData);
expect(result[0].name).toBe('abe');
expect(result[2].name).toBe('cat');
});
test('it sorts features by name descending', () => {
const testData = getTestData();
const result = sortFeaturesByNameDescending(testData);
expect(result[0].name).toBe('cat');
expect(result[2].name).toBe('abe');
});
test('it sorts features by lastSeenAt ascending', () => {
const testData = getTestData();
const result = sortFeaturesByLastSeenAscending(testData);
expect(result[0].name).toBe('cat');
expect(result[2].name).toBe('abe');
});
test('it sorts features by lastSeenAt descending', () => {
const testData = getTestData();
const result = sortFeaturesByLastSeenDescending(testData);
expect(result[0].name).toBe('abe');
expect(result[2].name).toBe('cat');
});
test('it sorts features by createdAt ascending', () => {
const testData = getTestData();
const result = sortFeaturesByCreatedAtAscending(testData);
expect(result[0].name).toBe('cat');
expect(result[2].name).toBe('abe');
});
test('it sorts features by createdAt descending', () => {
const testData = getTestData();
const result = sortFeaturesByCreatedAtDescending(testData);
expect(result[0].name).toBe('abe');
expect(result[2].name).toBe('cat');
});
test('it sorts features by expired ascending', () => {
const testData = getTestData();
const result = sortFeaturesByExpiredAtAscending(testData);
expect(result[0].name).toBe('cat');
expect(result[2].name).toBe('abe');
});
test('it sorts features by expired descending', () => {
const testData = getTestData();
const result = sortFeaturesByExpiredAtDescending(testData);
expect(result[0].name).toBe('abe');
expect(result[2].name).toBe('cat');
});
test('it sorts features by status ascending', () => {
const testData = getTestData();
const result = sortFeaturesByStatusAscending(testData);
expect(result[0].name).toBe('abe');
expect(result[2].name).toBe('cat');
});
test('it sorts features by status descending', () => {
const testData = getTestData();
const result = sortFeaturesByStatusDescending(testData);
expect(result[0].name).toBe('cat');
expect(result[2].name).toBe('abe');
});

View File

@ -0,0 +1,18 @@
/* SORT TYPES */
export const NAME = 'name';
export const LAST_SEEN = 'lastSeen';
export const CREATED = 'created';
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

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import ReportCard from './report-card';
import { filterByProject } from './utils';
const mapStateToProps = (state, ownProps) => {
const features = state.features.toJS();
const sameProject = filterByProject(ownProps.selectedProject);
const featuresByProject = features.filter(sameProject);
return {
features: featuresByProject,
};
};
const ReportCardContainer = connect(mapStateToProps, null)(ReportCard);
export default ReportCardContainer;

View File

@ -0,0 +1,124 @@
import React from 'react';
import classnames from 'classnames';
import { Card } from 'react-mdl';
import PropTypes from 'prop-types';
import CheckIcon from '@material-ui/icons/Check';
import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined';
import ConditionallyRender from '../common/conditionally-render';
import { isFeatureExpired } from './utils';
import styles from './reporting.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 healthClasses = classnames(styles.reportCardHealthRating, {
[styles.healthWarning]: healthLessThan75,
[styles.healthDanger]: healthLessThan50,
});
const renderActiveToggles = () => (
<>
<CheckIcon className={styles.check} />
<span>{activeTogglesCount} active toggles</span>
</>
);
const renderStaleToggles = () => (
<>
<ReportProblemOutlinedIcon className={styles.danger} />
<span>{staleTogglesCount} stale toggles</span>
</>
);
const renderPotentiallyStaleToggles = () => (
<>
<ReportProblemOutlinedIcon className={styles.danger} />
<span>{potentiallyStaleTogglesCount} potentially stale toggles</span>
</>
);
return (
<Card className={styles.card}>
<div className={styles.reportCardContainer}>
<div className={styles.reportCardListContainer}>
<h2 className={styles.header}>Toggle report</h2>
<ul className={styles.reportCardList}>
<li>
<ConditionallyRender condition={activeTogglesCount} show={renderActiveToggles} />
</li>
<li>
<ConditionallyRender condition={staleTogglesCount} show={renderStaleToggles} />
</li>
<li>
<ConditionallyRender
condition={potentiallyStaleTogglesCount}
show={renderPotentiallyStaleToggles}
/>
</li>
</ul>
</div>
<div className={styles.reportCardHealth}>
<h2 className={styles.header}>Health rating</h2>
<div className={styles.reportCardHealthInnerContainer}>
<ConditionallyRender
condition={healthRating}
show={<p className={healthClasses}>{healthRating}%</p>}
/>
</div>
</div>
<div className={styles.reportCardAction}>
<h2 className={styles.header}>Potential actions</h2>
<div className={styles.reportCardActionContainer}>
<p className={styles.reportCardActionText}>
Review your feature toggles and delete unused toggles.
</p>
</div>
</div>
</div>
</Card>
);
};
ReportCard.propTypes = {
features: PropTypes.array.isRequired,
};
export default ReportCard;

View File

@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import { filterByProject } from './utils';
import ReportToggleList from './report-toggle-list';
const mapStateToProps = (state, ownProps) => {
const features = state.features.toJS();
const sameProject = filterByProject(ownProps.selectedProject);
const featuresByProject = features.filter(sameProject);
return {
features: featuresByProject,
};
};
const ReportToggleListContainer = connect(mapStateToProps, null)(ReportToggleList);
export default ReportToggleListContainer;

View File

@ -0,0 +1,68 @@
import React from 'react';
import { Checkbox } from 'react-mdl';
import UnfoldMoreOutlinedIcon from '@material-ui/icons/UnfoldMoreOutlined';
import PropTypes from 'prop-types';
import ConditionallyRender from '../common/conditionally-render';
import { NAME, LAST_SEEN, CREATED, EXPIRED, STATUS, REPORT } from './constants';
import styles from './reporting.module.scss';
const ReportToggleListHeader = ({ handleCheckAll, checkAll, setSortData, bulkActionsOn }) => {
const handleSort = type => {
setSortData(prev => ({
sortKey: type,
ascending: !prev.ascending,
}));
};
return (
<thead>
<tr>
<ConditionallyRender
condition={bulkActionsOn}
show={
<th>
<Checkbox onChange={handleCheckAll} value={checkAll} checked={checkAll} />
</th>
}
/>
<th role="button" tabIndex={0} style={{ width: '150px' }} onClick={() => handleSort(NAME)}>
Name
<UnfoldMoreOutlinedIcon className={styles.sortIcon} />
</th>
<th role="button" tabIndex={0} onClick={() => handleSort(LAST_SEEN)}>
Last seen
<UnfoldMoreOutlinedIcon className={styles.sortIcon} />
</th>
<th role="button" tabIndex={0} onClick={() => handleSort(CREATED)}>
Created
<UnfoldMoreOutlinedIcon className={styles.sortIcon} />
</th>
<th role="button" tabIndex={0} onClick={() => handleSort(EXPIRED)}>
Expired
<UnfoldMoreOutlinedIcon className={styles.sortIcon} />
</th>
<th role="button" tabIndex={0} onClick={() => handleSort(STATUS)}>
Status
<UnfoldMoreOutlinedIcon className={styles.sortIcon} />
</th>
<th role="button" tabIndex={0} onClick={() => handleSort(REPORT)}>
Report
<UnfoldMoreOutlinedIcon className={styles.sortIcon} />
</th>
</tr>
</thead>
);
};
ReportToggleListHeader.propTypes = {
checkAll: PropTypes.bool.isRequired,
setSortData: PropTypes.func.isRequired,
bulkActionsOn: PropTypes.bool.isRequired,
handleCheckAll: PropTypes.func.isRequired,
};
export default ReportToggleListHeader;

View File

@ -0,0 +1,129 @@
import React from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Checkbox } from 'react-mdl';
import CheckIcon from '@material-ui/icons/Check';
import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined';
import ConditionallyRender from '../common/conditionally-render';
import { pluralize, getDates, expired, toggleExpiryByTypeMap, getDiffInDays } from './utils';
import { KILLSWITCH, PERMISSION } from './constants';
import styles from './reporting.module.scss';
const ReportToggleListItem = ({ name, stale, lastSeenAt, createdAt, type, checked, bulkActionsOn, setFeatures }) => {
const nameMatches = feature => feature.name === name;
const handleChange = () => {
setFeatures(prevState => {
const newState = [...prevState];
return newState.map(feature => {
if (nameMatches(feature)) {
return { ...feature, checked: !feature.checked };
}
return feature;
});
});
};
const formatCreatedAt = () => {
const [date, now] = getDates(createdAt);
const diff = getDiffInDays(date, now);
if (diff === 0) return '1 day';
const formatted = pluralize(diff, 'day');
return `${formatted} ago`;
};
const formatExpiredAt = () => {
if (type === KILLSWITCH || type === PERMISSION) {
return 'N/A';
}
const [date, now] = getDates(createdAt);
const diff = getDiffInDays(date, now);
if (expired(diff, type)) {
const result = diff - toggleExpiryByTypeMap[type];
if (result === 0) return '1 day';
return pluralize(result, 'day');
}
};
const formatLastSeenAt = () => {
if (!lastSeenAt) return 'Never';
const [date, now] = getDates(lastSeenAt);
const diff = getDiffInDays(date, now);
if (diff === 0) return '1 day';
if (diff) {
return pluralize(diff, 'day');
}
return '1 day';
};
const renderStatus = (icon, text) => (
<span className={styles.reportStatus}>
{icon}
{text}
</span>
);
const formatReportStatus = () => {
if (type === KILLSWITCH || type === PERMISSION) {
return renderStatus(<CheckIcon className={styles.reportIcon} />, 'Active');
}
const [date, now] = getDates(createdAt);
const diff = getDiffInDays(date, now);
if (expired(diff, type)) {
return renderStatus(<ReportProblemOutlinedIcon className={styles.reportIcon} />, 'Potentially stale');
}
return renderStatus(<CheckIcon className={styles.reportIcon} />, 'Active');
};
const statusClasses = classnames(styles.active, {
[styles.stale]: stale,
});
return (
<tr>
<ConditionallyRender
condition={bulkActionsOn}
show={
<td>
<Checkbox checked={checked} value={checked} onChange={handleChange} />
</td>
}
/>
<td>{name}</td>
<td>{formatLastSeenAt()}</td>
<td>{formatCreatedAt()}</td>
<td className={styles.expired}>{formatExpiredAt()}</td>
<td className={statusClasses}>{stale ? 'Stale' : 'Active'}</td>
<td>{formatReportStatus()}</td>
</tr>
);
};
ReportToggleListItem.propTypes = {
name: PropTypes.string.isRequired,
stale: PropTypes.bool.isRequired,
lastSeenAt: PropTypes.string,
createdAt: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
checked: PropTypes.bool.isRequired,
bulkActionsOn: PropTypes.bool.isRequired,
setFeatures: PropTypes.func.isRequired,
};
export default React.memo(ReportToggleListItem);

View File

@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react';
import classnames from 'classnames';
import { Card, Menu, MenuItem } from 'react-mdl';
import PropTypes from 'prop-types';
import ReportToggleListItem from './report-toggle-list-item';
import ReportToggleListHeader from './report-toggle-list-header';
import ConditionallyRender from '../common/conditionally-render';
import { getObjectProperties, getCheckedState, applyCheckedToFeatures } from './utils';
import useSort from './useSort';
import styles from './reporting.module.scss';
import { DropdownButton } from '../common';
/* FLAG TO TOGGLE UNFINISHED BULK ACTIONS FEATURE */
const BULK_ACTIONS_ON = false;
const ReportToggleList = ({ features, selectedProject }) => {
const [checkAll, setCheckAll] = useState(false);
const [localFeatures, setFeatures] = useState([]);
const [sort, setSortData] = useSort();
useEffect(() => {
const formattedFeatures = features.map(feature => ({
...getObjectProperties(feature, 'name', 'lastSeenAt', 'createdAt', 'stale', 'type'),
checked: getCheckedState(feature.name, features),
setFeatures,
}));
setFeatures(formattedFeatures);
}, [features, selectedProject]);
const handleCheckAll = () => {
if (!checkAll) {
setCheckAll(true);
return setFeatures(prev => applyCheckedToFeatures(prev, true));
}
setCheckAll(false);
return setFeatures(prev => applyCheckedToFeatures(prev, false));
};
const renderListRows = () =>
sort(localFeatures).map(feature => (
<ReportToggleListItem key={feature.name} {...feature} bulkActionsOn={BULK_ACTIONS_ON} />
));
const renderBulkActionsMenu = () => (
<span>
<DropdownButton
className={classnames('mdl-button', styles.bulkAction)}
id="bulk_actions"
label="Bulk actions"
/>
<Menu
target="bulk_actions"
/* eslint-disable-next-line */
onClick={() => console.log("Hi")}
style={{ width: '168px' }}
>
<MenuItem>Mark toggles as stale</MenuItem>
<MenuItem>Delete toggles</MenuItem>
</Menu>
</span>
);
return (
<Card className={styles.reportToggleList}>
<div className={styles.reportToggleListHeader}>
<h3 className={styles.reportToggleListHeading}>Overview</h3>
<ConditionallyRender condition={BULK_ACTIONS_ON} show={renderBulkActionsMenu} />
</div>
<div className={styles.reportToggleListInnerContainer}>
<table className={styles.reportingToggleTable}>
<ReportToggleListHeader
handleCheckAll={handleCheckAll}
checkAll={checkAll}
setSortData={setSortData}
bulkActionsOn={BULK_ACTIONS_ON}
/>
<tbody>{renderListRows()}</tbody>
</table>
</div>
</Card>
);
};
ReportToggleList.propTypes = {
selectedProject: PropTypes.string.isRequired,
features: PropTypes.array.isRequired,
};
export default ReportToggleList;

View File

@ -0,0 +1,16 @@
import { connect } from 'react-redux';
import { fetchFeatureToggles } from '../../store/feature-toggle/actions';
import Reporting from './reporting';
const mapStateToProps = state => ({
projects: state.projects.toJS(),
});
const mapDispatchToProps = {
fetchFeatureToggles,
};
const ReportingContainer = connect(mapStateToProps, mapDispatchToProps)(Reporting);
export default ReportingContainer;

View File

@ -0,0 +1,66 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import Select from '../common/select';
import ReportCardContainer from './report-card-container';
import ReportToggleListContainer from './report-toggle-list-container';
import ConditionallyRender from '../common/conditionally-render';
import { formatProjectOptions } from './utils';
import styles from './reporting.module.scss';
const Reporting = ({ fetchFeatureToggles, projects }) => {
const [projectOptions, setProjectOptions] = useState([{ key: 'default', label: 'Default' }]);
const [selectedProject, setSelectedProject] = useState('default');
useEffect(() => {
fetchFeatureToggles();
setSelectedProject(projects[0].id);
}, []);
useEffect(() => {
setProjectOptions(formatProjectOptions(projects));
}, [projects]);
const onChange = e => {
const { value } = e.target;
const selectedProject = projectOptions.find(option => option.key === value);
setSelectedProject(selectedProject.key);
};
const renderSelect = () => (
<div className={styles.projectSelector}>
<h1 className={styles.header}>Project</h1>
<Select
name="project"
className={styles.select}
options={projectOptions}
value={setSelectedProject.label}
onChange={onChange}
/>
</div>
);
const multipleProjects = projects.length > 1;
return (
<React.Fragment>
<ConditionallyRender condition={multipleProjects} show={renderSelect} />
<ReportCardContainer selectedProject={selectedProject} />
<ReportToggleListContainer selectedProject={selectedProject} />
</React.Fragment>
);
};
Reporting.propTypes = {
fetchFeatureToggles: PropTypes.func.isRequired,
projects: PropTypes.array.isRequired,
features: PropTypes.array,
};
export default Reporting;

View File

@ -0,0 +1,171 @@
.header {
font-size: var(--h1-size);
font-weight: 500;
margin: 0 0 0.5rem 0;
}
.card {
width: 100%;
padding: var(--card-padding);
margin: var(--card-margin-y) 0;
}
.projectSelector {
margin-bottom: 1rem;
}
.select {
background-color: #fff;
border: none;
padding: 0.5rem 1rem;
position: static;
}
.select select {
border: none;
min-width: 120px;
}
.select select:active {
border: none;
}
/** ReportCard **/
.reportCardContainer {
display: flex;
justify-content: space-between;
}
.reportCardHealthInnerContainer {
display: flex;
align-items: center;
justify-content: center;
align-items: center;
height: 80%;
}
.reportCardHealthRating {
font-size: 2rem;
font-weight: bold;
color: var(--success);
}
.reportCardList {
list-style-type: none;
margin: 0;
padding: 0;
}
.reportCardList li {
display: flex;
align-items: center;
margin: 0.5rem 0;
}
.reportCardList li span {
margin: 0;
padding: 0;
margin-left: 0.5rem;
font-size: var(--p-size);
}
.check,
.danger {
margin-right: 5px;
}
.check {
color: var(--success);
}
.danger {
color: var(--danger);
}
.reportCardActionContainer {
display: flex;
justify-content: center;
flex-direction: column;
}
.reportCardActionText {
max-width: 300px;
font-size: var(--p-size);
}
.reportCardBtn {
background-color: #f2f2f2;
}
.healthDanger {
color: var(--danger);
}
.healthWarning {
color: var(--warning);
}
/** ReportToggleList **/
.reportToggleList {
width: 100%;
margin: var(--card-margin-y) 0;
}
.bulkAction {
background-color: #f2f2f2;
font-size: var(--p-size);
}
.sortIcon {
margin-left: 8px;
}
.reportToggleListHeader {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #f1f1f1;
padding: 1rem var(--card-padding-x);
}
.reportToggleListInnerContainer {
padding: var(--card-padding);
}
.reportToggleListHeading {
font-size: var(--h1-size);
margin: 0;
font-weight: 500;
}
.reportingToggleTable {
width: 100%;
border-spacing: 0 0.8rem;
}
.reportingToggleTable th {
text-align: left;
}
.expired {
color: var(--danger);
}
.active {
color: var(--success);
}
.stale {
color: var(--danger);
}
.reportStatus {
display: flex;
align-items: center;
}
.reportIcon {
font-size: 1.5rem;
margin-right: 5px;
}

View File

@ -0,0 +1,67 @@
export const testProjects = [
{ id: 'default', inital: true, name: 'Default' },
{ id: 'myProject', inital: false, name: 'MyProject' },
];
export const testFeatures = [
{
name: 'one',
description: '1234',
type: 'permission',
project: 'default',
enabled: false,
stale: false,
strategies: [],
variants: [],
createdAt: '2021-02-01T04:12:36.878Z',
lastSeenAt: '2021-02-21T19:34:21.830Z',
},
{
name: 'two',
description: '',
type: 'release',
project: 'default',
enabled: false,
stale: false,
strategies: [],
variants: [],
createdAt: '2021-02-22T16:05:39.717Z',
lastSeenAt: '2021-02-22T19:37:58.189Z',
},
{
name: 'three',
description: 'asdasds',
type: 'experiment',
project: 'default',
enabled: true,
stale: false,
strategies: [],
variants: [],
createdAt: '2021-02-06T18:38:18.133Z',
lastSeenAt: '2021-02-21T19:34:21.830Z',
},
{
name: 'four',
description: '',
type: 'experiment',
project: 'myProject',
enabled: true,
stale: false,
strategies: [],
variants: [],
createdAt: '2021-02-14T02:42:34.515Z',
lastSeenAt: '2021-02-21T19:34:21.830Z',
},
{
name: 'five',
description: '',
type: 'release',
project: 'myProject',
enabled: true,
stale: false,
strategies: [],
variants: [],
createdAt: '2021-02-16T15:26:11.474Z',
lastSeenAt: '2021-02-21T19:34:21.830Z',
},
];

View File

@ -0,0 +1,80 @@
import { useState } from 'react';
import {
sortFeaturesByNameAscending,
sortFeaturesByNameDescending,
sortFeaturesByLastSeenAscending,
sortFeaturesByLastSeenDescending,
sortFeaturesByCreatedAtAscending,
sortFeaturesByCreatedAtDescending,
sortFeaturesByExpiredAtAscending,
sortFeaturesByExpiredAtDescending,
sortFeaturesByStatusAscending,
sortFeaturesByStatusDescending,
} from './utils';
import { LAST_SEEN, NAME, CREATED, EXPIRED, STATUS, REPORT } from './constants';
const useSort = () => {
const [sortData, setSortData] = useState({
sortKey: NAME,
ascending: true,
});
const handleSortName = features => {
if (sortData.ascending) {
return sortFeaturesByNameAscending(features);
}
return sortFeaturesByNameDescending(features);
};
const handleSortLastSeen = features => {
if (sortData.ascending) {
return sortFeaturesByLastSeenAscending(features);
}
return sortFeaturesByLastSeenDescending(features);
};
const handleSortCreatedAt = features => {
if (sortData.ascending) {
return sortFeaturesByCreatedAtAscending(features);
}
return sortFeaturesByCreatedAtDescending(features);
};
const handleSortExpiredAt = features => {
if (sortData.ascending) {
return sortFeaturesByExpiredAtAscending(features);
}
return sortFeaturesByExpiredAtDescending(features);
};
const handleSortStatus = features => {
if (sortData.ascending) {
return sortFeaturesByStatusAscending(features);
}
return sortFeaturesByStatusDescending(features);
};
const sort = features => {
switch (sortData.sortKey) {
case NAME:
return handleSortName(features);
case LAST_SEEN:
return handleSortLastSeen(features);
case CREATED:
return handleSortCreatedAt(features);
case EXPIRED:
case REPORT:
return handleSortExpiredAt(features);
case STATUS:
return handleSortStatus(features);
default:
return features;
}
};
return [sort, setSortData];
};
export default useSort;

View File

@ -0,0 +1,144 @@
import parseISO from 'date-fns/parseISO';
import differenceInDays from 'date-fns/differenceInDays';
import { EXPERIMENT, OPERATIONAL, RELEASE, FOURTYDAYS, SEVENDAYS } from './constants';
export const toggleExpiryByTypeMap = {
[EXPERIMENT]: FOURTYDAYS,
[RELEASE]: FOURTYDAYS,
[OPERATIONAL]: SEVENDAYS,
};
export const applyCheckedToFeatures = (features, checkedState) =>
features.map(feature => ({ ...feature, checked: checkedState }));
export const getCheckedState = (name, features) => {
const feature = features.find(feature => feature.name === name);
if (feature) {
return feature.checked ? feature.checked : false;
}
return false;
};
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 expired = (diff, type) => {
if (diff >= toggleExpiryByTypeMap[type]) return true;
return false;
};
export const getObjectProperties = (target, ...keys) => {
const newObject = {};
keys.forEach(key => {
if (target[key] !== undefined) {
newObject[key] = target[key];
}
});
return newObject;
};
export const sortFeaturesByNameAscending = features => {
const sorted = [...features];
sorted.sort((a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
});
return sorted;
};
export const sortFeaturesByNameDescending = features => sortFeaturesByNameAscending([...features]).reverse();
export const sortFeaturesByLastSeenAscending = features => {
const sorted = [...features];
sorted.sort((a, b) => {
if (!a.lastSeenAt) return -1;
if (!b.lastSeenAt) return 1;
const dateA = parseISO(a.lastSeenAt);
const dateB = parseISO(b.lastSeenAt);
return dateA.getTime() - dateB.getTime();
});
return sorted;
};
export const sortFeaturesByLastSeenDescending = features => sortFeaturesByLastSeenAscending([...features]).reverse();
export const sortFeaturesByCreatedAtAscending = features => {
const sorted = [...features];
sorted.sort((a, b) => {
const dateA = parseISO(a.createdAt);
const dateB = parseISO(b.createdAt);
return dateA.getTime() - dateB.getTime();
});
return sorted;
};
export const sortFeaturesByCreatedAtDescending = features => sortFeaturesByCreatedAtAscending([...features]).reverse();
export const sortFeaturesByExpiredAtAscending = features => {
const sorted = [...features];
sorted.sort((a, b) => {
const now = new Date();
const dateA = parseISO(a.createdAt);
const dateB = parseISO(b.createdAt);
const diffA = getDiffInDays(dateA, now);
const diffB = getDiffInDays(dateB, now);
if (!expired(diffA, a.type)) return -1;
if (!expired(diffB, b.type)) return 1;
const expiredByA = diffA - toggleExpiryByTypeMap[a.type];
const expiredByB = diffB - toggleExpiryByTypeMap[b.type];
return expiredByA - expiredByB;
});
return sorted;
};
export const sortFeaturesByExpiredAtDescending = features => sortFeaturesByExpiredAtAscending([...features]).reverse();
export const sortFeaturesByStatusAscending = features => {
const sorted = [...features];
sorted.sort((a, b) => {
if (a.stale) return 1;
if (b.stale) return -1;
return 0;
});
return sorted;
};
export const sortFeaturesByStatusDescending = features => sortFeaturesByStatusAscending([...features]).reverse();
export const pluralize = (items, word) => {
if (items === 1) return `${items} ${word}`;
return `${items} ${word}s`;
};
export const getDates = dateString => {
const date = parseISO(dateString);
const now = new Date();
return [date, now];
};
export const filterByProject = selectedProject => feature => feature.project === selectedProject;
export const isFeatureExpired = feature => {
const [date, now] = getDates(feature.createdAt);
const diff = getDiffInDays(date, now);
return expired(diff, feature.type);
};

View File

@ -2,6 +2,7 @@ import 'whatwg-fetch';
import 'react-mdl/extra/material.js';
import 'react-mdl/extra/css/material.blue_grey-pink.min.css';
import './app.css';
import React from 'react';
import ReactDOM from 'react-dom';

View File

@ -0,0 +1,6 @@
import React from 'react';
import ReportingContainer from '../../component/reporting/reporting-container';
const render = () => <ReportingContainer />;
export default render;

15
frontend/vercel.json Normal file
View File

@ -0,0 +1,15 @@
{
"rewrites": [
{ "source": "/api/admin/user", "destination": "https://unleash.herokuapp.com/api/admin/user" },
{ "source": "/api/admin/uiconfig", "destination": "https://unleash.herokuapp.com/api/admin/uiconfig" },
{ "source": "/api/admin/context", "destination": "https://unleash.herokuapp.com/api/admin/context" },
{ "source": "/api/admin/feature-types", "destination": "https://unleash.herokuapp.com/api/admin/feature-types" },
{ "source": "/api/admin/strategies", "destination": "https://unleash.herokuapp.com/api/admin/strategies" },
{ "source": "/api/admin/tag-types", "destination": "https://unleash.herokuapp.com/api/admin/tag-types" },
{ "source": "/api/admin/features", "destination": "https://unleash.herokuapp.com/api/admin/features" },
{ "source": "/api/admin/login", "destination": "https://unleash.herokuapp.com/api/admin/login" },
{ "source": "/api/admin/metrics/feature-toggles", "destination": "https://unleash.herokuapp.com/api/admin/metrics/feature-toggles" },
{ "source": "/logout", "destination": "https://unleash.herokuapp.com/logout" },
{ "source": "/auth", "destination": "https://unleash.herokuapp.com/auth" }
]
}

View File

@ -1044,6 +1044,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.3.1", "@babel/runtime@^7.8.3":
version "7.13.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.2.tgz#9511c87d2808b2cf5fb9e9c5cf0d1ab789d75499"
integrity sha512-U9plpxyudmZNYe12YI6cXyeWTWYCTq2u1h+C0XVtC3+BoiuzTh1BHlMJgxMrbKTombYkf7wQGqoxYkptFehuZw==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.4.4", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7":
version "7.9.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.6.tgz#a9102eb5cadedf3f31d08a9ecf294af7827ea29f"
@ -1179,7 +1186,7 @@
"@emotion/utils" "0.11.3"
babel-plugin-emotion "^10.0.27"
"@emotion/hash@0.8.0":
"@emotion/hash@0.8.0", "@emotion/hash@^0.8.0":
version "0.8.0"
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413"
integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==
@ -1443,6 +1450,77 @@
"@types/yargs" "^15.0.0"
chalk "^4.0.0"
"@material-ui/core@^4.11.3":
version "4.11.3"
resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.11.3.tgz#f22e41775b0bd075e36a7a093d43951bf7f63850"
integrity sha512-Adt40rGW6Uds+cAyk3pVgcErpzU/qxc7KBR94jFHBYretU4AtWZltYcNsbeMn9tXL86jjVL1kuGcIHsgLgFGRw==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/styles" "^4.11.3"
"@material-ui/system" "^4.11.3"
"@material-ui/types" "^5.1.0"
"@material-ui/utils" "^4.11.2"
"@types/react-transition-group" "^4.2.0"
clsx "^1.0.4"
hoist-non-react-statics "^3.3.2"
popper.js "1.16.1-lts"
prop-types "^15.7.2"
react-is "^16.8.0 || ^17.0.0"
react-transition-group "^4.4.0"
"@material-ui/icons@^4.11.2":
version "4.11.2"
resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-4.11.2.tgz#b3a7353266519cd743b6461ae9fdfcb1b25eb4c5"
integrity sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/styles@^4.11.3":
version "4.11.3"
resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.11.3.tgz#1b8d97775a4a643b53478c895e3f2a464e8916f2"
integrity sha512-HzVzCG+PpgUGMUYEJ2rTEmQYeonGh41BYfILNFb/1ueqma+p1meSdu4RX6NjxYBMhf7k+jgfHFTTz+L1SXL/Zg==
dependencies:
"@babel/runtime" "^7.4.4"
"@emotion/hash" "^0.8.0"
"@material-ui/types" "^5.1.0"
"@material-ui/utils" "^4.11.2"
clsx "^1.0.4"
csstype "^2.5.2"
hoist-non-react-statics "^3.3.2"
jss "^10.5.1"
jss-plugin-camel-case "^10.5.1"
jss-plugin-default-unit "^10.5.1"
jss-plugin-global "^10.5.1"
jss-plugin-nested "^10.5.1"
jss-plugin-props-sort "^10.5.1"
jss-plugin-rule-value-function "^10.5.1"
jss-plugin-vendor-prefixer "^10.5.1"
prop-types "^15.7.2"
"@material-ui/system@^4.11.3":
version "4.11.3"
resolved "https://registry.yarnpkg.com/@material-ui/system/-/system-4.11.3.tgz#466bc14c9986798fd325665927c963eb47cc4143"
integrity sha512-SY7otguNGol41Mu2Sg6KbBP1ZRFIbFLHGK81y4KYbsV2yIcaEPOmsCK6zwWlp+2yTV3J/VwT6oSBARtGIVdXPw==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/utils" "^4.11.2"
csstype "^2.5.2"
prop-types "^15.7.2"
"@material-ui/types@^5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@material-ui/types/-/types-5.1.0.tgz#efa1c7a0b0eaa4c7c87ac0390445f0f88b0d88f2"
integrity sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==
"@material-ui/utils@^4.11.2":
version "4.11.2"
resolved "https://registry.yarnpkg.com/@material-ui/utils/-/utils-4.11.2.tgz#f1aefa7e7dff2ebcb97d31de51aecab1bb57540a"
integrity sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==
dependencies:
"@babel/runtime" "^7.4.4"
prop-types "^15.7.2"
react-is "^16.8.0 || ^17.0.0"
"@react-dnd/asap@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.0.tgz#b300eeed83e9801f51bd66b0337c9a6f04548651"
@ -1629,6 +1707,13 @@
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==
"@types/react-transition-group@^4.2.0":
version "4.4.1"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.1.tgz#e1a3cb278df7f47f17b5082b1b3da17170bd44b1"
integrity sha512-vIo69qKKcYoJ8wKCJjwSgCTM+z3chw3g18dkrDfVX665tMH7tmbDxEAnPdey4gTlwZz5QuHGzd+hul0OVZDqqQ==
dependencies:
"@types/react" "*"
"@types/react@*":
version "16.9.34"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.34.tgz#f7d5e331c468f53affed17a8a4d488cd44ea9349"
@ -2924,6 +3009,11 @@ clone-deep@^4.0.1:
kind-of "^6.0.2"
shallow-clone "^3.0.0"
clsx@^1.0.4:
version "1.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@ -3310,6 +3400,14 @@ css-tree@1.0.0-alpha.39:
mdn-data "2.0.6"
source-map "^0.6.1"
css-vendor@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/css-vendor/-/css-vendor-2.0.8.tgz#e47f91d3bd3117d49180a3c935e62e3d9f7f449d"
integrity sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==
dependencies:
"@babel/runtime" "^7.8.3"
is-in-browser "^1.0.2"
css-what@2.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
@ -3422,6 +3520,16 @@ csstype@^2.2.0, csstype@^2.5.7, csstype@^2.6.7:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.10.tgz#e63af50e66d7c266edb6b32909cfd0aabe03928b"
integrity sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w==
csstype@^2.5.2:
version "2.6.15"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.15.tgz#655901663db1d652f10cb57ac6af5a05972aea1f"
integrity sha512-FNeiVKudquehtR3t9TRRnsHL+lJhuHF5Zn9dt01jpojlurLEPDhhEtUkWmAUJ7/fOLaLG4dCDEnUsR0N1rZSsg==
csstype@^3.0.2:
version "3.0.6"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.6.tgz#865d0b5833d7d8d40f4e5b8a6d76aea3de4725ef"
integrity sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==
currently-unhandled@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
@ -3450,6 +3558,11 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0"
date-fns@^2.17.0:
version "2.17.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.17.0.tgz#afa55daea539239db0a64e236ce716ef3d681ba1"
integrity sha512-ZEhqxUtEZeGgg9eHNSOAJ8O9xqSgiJdrL0lzSSfMF54x6KXWJiOH/xntSJ9YomJPrYH/p08t6gWjGWq1SDJlSA==
debounce@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.0.tgz#44a540abc0ea9943018dc0eaa95cce87f65cd131"
@ -4981,7 +5094,7 @@ hmac-drbg@^1.0.0:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0:
hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
@ -5146,6 +5259,11 @@ human-signals@^1.1.1:
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
hyphenate-style-name@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d"
integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
iconv-lite@0.4.24, iconv-lite@^0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@ -5234,6 +5352,13 @@ in-publish@^2.0.0:
resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.1.tgz#948b1a535c8030561cea522f73f78f4be357e00c"
integrity sha512-oDM0kUSNFC31ShNxHKUyfZKy8ZeXZBWMjMdZHKLOk13uvT27VTL/QzRGfRUcevJhpkZAvlhPYuXkF7eNWrtyxQ==
indefinite-observable@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/indefinite-observable/-/indefinite-observable-2.0.1.tgz#574af29bfbc17eb5947793797bddc94c9d859400"
integrity sha512-G8vgmork+6H9S8lUAg1gtXEj2JxIQTo0g2PbFiYOdjkziSI0F7UYBiVwhZRuixhBCNGczAls34+5HJPyZysvxQ==
dependencies:
symbol-observable "1.2.0"
indent-string@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
@ -5544,6 +5669,11 @@ is-glob@^4.0.0, is-glob@^4.0.1:
dependencies:
is-extglob "^2.1.1"
is-in-browser@^1.0.2, is-in-browser@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835"
integrity sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=
is-number-object@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197"
@ -6310,6 +6440,77 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
jss-plugin-camel-case@^10.5.1:
version "10.5.1"
resolved "https://registry.yarnpkg.com/jss-plugin-camel-case/-/jss-plugin-camel-case-10.5.1.tgz#427b24a9951b4c2eaa7e3d5267acd2e00b0934f9"
integrity sha512-9+oymA7wPtswm+zxVti1qiowC5q7bRdCJNORtns2JUj/QHp2QPXYwSNRD8+D2Cy3/CEMtdJzlNnt5aXmpS6NAg==
dependencies:
"@babel/runtime" "^7.3.1"
hyphenate-style-name "^1.0.3"
jss "10.5.1"
jss-plugin-default-unit@^10.5.1:
version "10.5.1"
resolved "https://registry.yarnpkg.com/jss-plugin-default-unit/-/jss-plugin-default-unit-10.5.1.tgz#2be385d71d50aee2ee81c2a9ac70e00592ed861b"
integrity sha512-D48hJBc9Tj3PusvlillHW8Fz0y/QqA7MNmTYDQaSB/7mTrCZjt7AVRROExoOHEtd2qIYKOYJW3Jc2agnvsXRlQ==
dependencies:
"@babel/runtime" "^7.3.1"
jss "10.5.1"
jss-plugin-global@^10.5.1:
version "10.5.1"
resolved "https://registry.yarnpkg.com/jss-plugin-global/-/jss-plugin-global-10.5.1.tgz#0e1793dea86c298360a7e2004721351653c7e764"
integrity sha512-jX4XpNgoaB8yPWw/gA1aPXJEoX0LNpvsROPvxlnYe+SE0JOhuvF7mA6dCkgpXBxfTWKJsno7cDSCgzHTocRjCQ==
dependencies:
"@babel/runtime" "^7.3.1"
jss "10.5.1"
jss-plugin-nested@^10.5.1:
version "10.5.1"
resolved "https://registry.yarnpkg.com/jss-plugin-nested/-/jss-plugin-nested-10.5.1.tgz#8753a80ad31190fb6ac6fdd39f57352dcf1295bb"
integrity sha512-xXkWKOCljuwHNjSYcXrCxBnjd8eJp90KVFW1rlhvKKRXnEKVD6vdKXYezk2a89uKAHckSvBvBoDGsfZrldWqqQ==
dependencies:
"@babel/runtime" "^7.3.1"
jss "10.5.1"
tiny-warning "^1.0.2"
jss-plugin-props-sort@^10.5.1:
version "10.5.1"
resolved "https://registry.yarnpkg.com/jss-plugin-props-sort/-/jss-plugin-props-sort-10.5.1.tgz#ab1c167fd2d4506fb6a1c1d66c5f3ef545ff1cd8"
integrity sha512-t+2vcevNmMg4U/jAuxlfjKt46D/jHzCPEjsjLRj/J56CvP7Iy03scsUP58Iw8mVnaV36xAUZH2CmAmAdo8994g==
dependencies:
"@babel/runtime" "^7.3.1"
jss "10.5.1"
jss-plugin-rule-value-function@^10.5.1:
version "10.5.1"
resolved "https://registry.yarnpkg.com/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.5.1.tgz#37f4030523fb3032c8801fab48c36c373004de7e"
integrity sha512-3gjrSxsy4ka/lGQsTDY8oYYtkt2esBvQiceGBB4PykXxHoGRz14tbCK31Zc6DHEnIeqsjMUGbq+wEly5UViStQ==
dependencies:
"@babel/runtime" "^7.3.1"
jss "10.5.1"
tiny-warning "^1.0.2"
jss-plugin-vendor-prefixer@^10.5.1:
version "10.5.1"
resolved "https://registry.yarnpkg.com/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.5.1.tgz#45a183a3a0eb097bdfab0986b858d99920c0bbd8"
integrity sha512-cLkH6RaPZWHa1TqSfd2vszNNgxT1W0omlSjAd6hCFHp3KIocSrW21gaHjlMU26JpTHwkc+tJTCQOmE/O1A4FKQ==
dependencies:
"@babel/runtime" "^7.3.1"
css-vendor "^2.0.8"
jss "10.5.1"
jss@10.5.1, jss@^10.5.1:
version "10.5.1"
resolved "https://registry.yarnpkg.com/jss/-/jss-10.5.1.tgz#93e6b2428c840408372d8b548c3f3c60fa601c40"
integrity sha512-hbbO3+FOTqVdd7ZUoTiwpHzKXIo5vGpMNbuXH1a0wubRSWLWSBvwvaq4CiHH/U42CmjOnp6lVNNs/l+Z7ZdDmg==
dependencies:
"@babel/runtime" "^7.3.1"
csstype "^3.0.2"
indefinite-observable "^2.0.1"
is-in-browser "^1.1.3"
tiny-warning "^1.0.2"
jsx-ast-utils@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz#8a9364e402448a3ce7f14d357738310d9248054f"
@ -7641,6 +7842,11 @@ pkg-up@^2.0.0:
dependencies:
find-up "^2.1.0"
popper.js@1.16.1-lts:
version "1.16.1-lts"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1-lts.tgz#cf6847b807da3799d80ee3d6d2f90df8a3f50b05"
integrity sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==
portfinder@^1.0.26:
version "1.0.28"
resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778"
@ -8249,7 +8455,7 @@ react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-i
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^17.0.1:
"react-is@^16.8.0 || ^17.0.0", react-is@^17.0.1:
version "17.0.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339"
integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==
@ -8358,7 +8564,7 @@ react-timeago@^4.4.0:
resolved "https://registry.yarnpkg.com/react-timeago/-/react-timeago-4.4.0.tgz#4520dd9ba63551afc4d709819f52b14b9343ba2b"
integrity sha512-Zj8RchTqZEH27LAANemzMR2RpotbP2aMd+UIajfYMZ9KW4dMcViUVKzC7YmqfiqlFfz8B0bjDw2xUBjmcxDngA==
react-transition-group@^4.3.0:
react-transition-group@^4.3.0, react-transition-group@^4.4.0:
version "4.4.1"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"
integrity sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==
@ -9624,7 +9830,7 @@ svgo@^1.0.0:
unquote "~1.1.1"
util.promisify "~1.0.0"
symbol-observable@^1.2.0:
symbol-observable@1.2.0, symbol-observable@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==