mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +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