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:
parent
74b04b7a43
commit
d11bee0b95
@ -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
26
frontend/src/app.css
Normal 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;
|
||||
}
|
32
frontend/src/component/common/conditionally-render.jsx
Normal file
32
frontend/src/component/common/conditionally-render.jsx
Normal 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;
|
@ -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}>
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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();
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
42
frontend/src/component/reporting/__tests__/reporting-test.js
Normal file
42
frontend/src/component/reporting/__tests__/reporting-test.js
Normal 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);
|
||||
});
|
126
frontend/src/component/reporting/__tests__/sorting-test.js
Normal file
126
frontend/src/component/reporting/__tests__/sorting-test.js
Normal 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');
|
||||
});
|
18
frontend/src/component/reporting/constants.js
Normal file
18
frontend/src/component/reporting/constants.js
Normal 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;
|
19
frontend/src/component/reporting/report-card-container.jsx
Normal file
19
frontend/src/component/reporting/report-card-container.jsx
Normal 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;
|
124
frontend/src/component/reporting/report-card.jsx
Normal file
124
frontend/src/component/reporting/report-card.jsx
Normal 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;
|
@ -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;
|
@ -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;
|
129
frontend/src/component/reporting/report-toggle-list-item.jsx
Normal file
129
frontend/src/component/reporting/report-toggle-list-item.jsx
Normal 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);
|
95
frontend/src/component/reporting/report-toggle-list.jsx
Normal file
95
frontend/src/component/reporting/report-toggle-list.jsx
Normal 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;
|
16
frontend/src/component/reporting/reporting-container.jsx
Normal file
16
frontend/src/component/reporting/reporting-container.jsx
Normal 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;
|
66
frontend/src/component/reporting/reporting.jsx
Normal file
66
frontend/src/component/reporting/reporting.jsx
Normal 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;
|
171
frontend/src/component/reporting/reporting.module.scss
Normal file
171
frontend/src/component/reporting/reporting.module.scss
Normal 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;
|
||||
}
|
67
frontend/src/component/reporting/testData.js
Normal file
67
frontend/src/component/reporting/testData.js
Normal 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',
|
||||
},
|
||||
];
|
80
frontend/src/component/reporting/useSort.js
Normal file
80
frontend/src/component/reporting/useSort.js
Normal 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;
|
144
frontend/src/component/reporting/utils.js
Normal file
144
frontend/src/component/reporting/utils.js
Normal 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);
|
||||
};
|
@ -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';
|
||||
|
6
frontend/src/page/reporting/index.js
Normal file
6
frontend/src/page/reporting/index.js
Normal 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
15
frontend/vercel.json
Normal 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" }
|
||||
]
|
||||
}
|
@ -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==
|
||||
|
Loading…
Reference in New Issue
Block a user