1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-11-24 20:06:55 +01:00

Feat/material UI (#250)

Co-authored-by: Ivar Conradi Østhus <ivarconr@gmail.com>
Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>
Co-authored-by: Christopher Kolstad <git@chriswk.no>
This commit is contained in:
Fredrik Strand Oseberg 2021-03-30 15:14:02 +02:00 committed by GitHub
parent 335a0a3cc3
commit dbed06f300
313 changed files with 11408 additions and 7750 deletions

View File

@ -38,13 +38,8 @@
"prepublish": "npm run build" "prepublish": "npm run build"
}, },
"main": "./index.js", "main": "./index.js",
"dependencies": {},
"devDependencies": { "devDependencies": {
"@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "4.0.0-alpha.57", "@material-ui/lab": "4.0.0-alpha.57",
"classnames": "^2.2.6",
"date-fns": "^2.17.0",
"@babel/core": "^7.9.0", "@babel/core": "^7.9.0",
"@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-decorators": "^7.8.3", "@babel/plugin-proposal-decorators": "^7.8.3",
@ -52,12 +47,17 @@
"@babel/plugin-transform-runtime": "^7.9.0", "@babel/plugin-transform-runtime": "^7.9.0",
"@babel/preset-env": "^7.9.5", "@babel/preset-env": "^7.9.5",
"@babel/preset-react": "^7.9.4", "@babel/preset-react": "^7.9.4",
"@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2",
"@material-ui/styles": "^4.11.3",
"array-move": "^2.2.1", "array-move": "^2.2.1",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^25.3.0", "babel-jest": "^25.3.0",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"classnames": "^2.2.6",
"clean-webpack-plugin": "^3.0.0", "clean-webpack-plugin": "^3.0.0",
"css-loader": "^2.1.1", "css-loader": "^2.1.1",
"date-fns": "^2.17.0",
"debounce": "^1.2.0", "debounce": "^1.2.0",
"debug": "^4.1.1", "debug": "^4.1.1",
"enzyme": "^3.9.0", "enzyme": "^3.9.0",
@ -72,7 +72,8 @@
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"immutable": "^3.8.1", "immutable": "^3.8.1",
"jest": "^26.6.3", "jest": "^26.6.3",
"lodash": "^4.17.20", "lodash.clonedeep": "^4.5.0",
"lodash.flow": "^3.5.0",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"node-sass": "^4.5.3", "node-sass": "^4.5.3",
@ -84,11 +85,8 @@
"react-dnd": "^11.1.3", "react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3", "react-dnd-html5-backend": "^11.1.3",
"react-dom": "^16.14.0", "react-dom": "^16.14.0",
"react-mdl": "^2.1.0",
"react-modal": "^3.1.13",
"react-redux": "^7.2.0", "react-redux": "^7.2.0",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-select": "^3.1.0",
"react-test-renderer": "^16.14.0", "react-test-renderer": "^16.14.0",
"react-timeago": "^4.4.0", "react-timeago": "^4.4.0",
"redux": "^4.0.5", "redux": "^4.0.5",
@ -100,6 +98,7 @@
"toolbox-loader": "0.0.3", "toolbox-loader": "0.0.3",
"uglifyjs-webpack-plugin": "^2.2.0", "uglifyjs-webpack-plugin": "^2.2.0",
"webpack": "^4.17.1", "webpack": "^4.17.1",
"webpack-bundle-analyzer": "^4.4.0",
"webpack-cli": "^3.1.0", "webpack-cli": "^3.1.0",
"webpack-dev-server": "^3.11.2", "webpack-dev-server": "^3.11.2",
"whatwg-fetch": "^3.4.1" "whatwg-fetch": "^3.4.1"
@ -121,5 +120,7 @@
"testPathIgnorePatterns": [ "testPathIgnorePatterns": [
"/src/store/addons/__tests__/data.js" "/src/store/addons/__tests__/data.js"
] ]
},
"dependencies": {
} }
} }

View File

@ -1,37 +0,0 @@
module.exports = {
Card: 'react-mdl-Card',
CardActions: 'react-mdl-CardActions',
CardTitle: 'react-mdl-CardTitle',
CardText: 'react-mdl-CardText',
CardMenu: 'react-mdl-CardMenu',
DataTable: 'react-mdl-DataTable',
Drawer: 'react-mdl-Drawer',
Cell: 'react-mdl-Cell',
Chip: 'react-mdl-Chip',
Grid: 'react-mdl-Grid',
Button: 'react-mdl-Button',
FABButton: 'react-mdl-FABButton',
Icon: 'react-mdl-Icon',
IconButton: 'react-mdl-IconButton',
List: 'react-mdl-List',
ListItem: 'react-mdl-ListItem',
ListItemContent: 'react-mdl-ListItemContent',
ListItemAction: 'react-mdl-ListItemAction',
Menu: 'react-mdl-Menu',
MenuItem: 'react-mdl-MenuItem',
Navigation: 'react-mdl-Navigation',
ProgressBar: 'react-mdl-ProgressBar',
Switch: 'react-mdl-Switch',
Tab: 'react-mdl-Tab',
Tabs: 'react-mdl-Tabs',
TableHeader: 'react-mdl-TableHeader',
Textfield: 'react-mdl-Textfield',
FooterDropDownSection: 'react-mdl-FooterDropDownSection',
FooterSection: 'react-mdl-FooterSection',
FooterLinkList: 'react-mdl-FooterLinkList',
Tooltip: 'react-mdl-Tooltip',
Dialog: 'react-mdl-Dialog',
DialogTitle: 'react-mdl-DialogTitle',
DialogContent: 'react-mdl-DialogContent',
DialogActions: 'react-mdl-DialogActions',
};

View File

@ -1,12 +0,0 @@
// __mocks__/react-modal.js
const Modal = require('react-modal');
const oldFn = Modal.setAppElement;
Modal.setAppElement = element => {
if (element === '#app') {
// otherwise it will throw aria warnings.
return oldFn(document.createElement('div'));
}
oldFn(element);
};
module.exports = Modal;

View File

@ -1,17 +1,74 @@
* {
box-sizing: border-box;
}
html {
height: 100%;
overflow: auto;
}
body {
height: 100%;
}
html { height: 100%; overflow:auto; } .skeleton {
body { height: 100%; } position: relative;
overflow: hidden;
background-color: #e2e8f0;
z-index: 9999;
box-shadow: none;
}
.skeleton::before {
background-color: #e2e8f0;
content: "";
position: absolute;
top: 0;
right: 0;
content-visibility: hidden;
bottom: 0;
z-index: 5000;
left: 0;
}
.skeleton::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.2) 100%,
rgba(255, 255, 255, 0.5) 100%,
rgba(255, 255, 255, 0)
);
animation: shimmer 3s infinite;
content: "";
z-index: 5001;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
:root { :root {
/* FONT SIZE */ /* FONT SIZE */
--h1-size: 1.25rem; --h1-size: 1.25rem;
--p-size: 1.1rem; --p-size: 1rem;
--caption-size: 0.9rem;
/* PADDING */ /* PADDING */
--card-padding: 2rem; --card-padding: 2rem;
--card-padding-x: 2rem; --card-padding-x: 2rem;
--card-padding-y: 2rem; --card-padding-y: 2rem;
--card-header-padding: 1rem 2rem;
--drawer-padding: 1rem 1.5rem;
--page-padding: 2rem 0;
--list-header-padding: 1rem;
/* MARGIN */ /* MARGIN */
--card-margin-y: 1rem; --card-margin-y: 1rem;
@ -21,6 +78,25 @@ body { height: 100%; }
--success: #3bd86e; --success: #3bd86e;
--danger: #d95e5e; --danger: #d95e5e;
--warning: #d67c3d; --warning: #d67c3d;
--drawer-link-active: #000;
--drawer-link-active-bg: #f1f1f1;
--drawer-link-inactive: #424242;
--primary: #607d8b;
/* WIDTHS */
--drawer-width: 250px;
--dropdownMenuWidth: 200px;
/* BOX SHADOWS */
--chip-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14),
0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 1px 5px 0 rgba(0, 0, 0, 0.12);
/* BORDERS */
--default-border: 1px solid #f1f1f1;
}
body {
font-size: 16px;
} }
h1, h1,
@ -29,3 +105,22 @@ h2 {
margin: 0; margin: 0;
line-height: 24px; line-height: 24px;
} }
p {
margin: 0;
padding: 0;
}
#app {
height: 100%;
}
.MuiCardHeader-title {
font-size: var(--p-size);
}
@media screen and (max-width: 1024px) {
:root {
--drawer-padding: 0.75rem 1.25rem;
}
}

View File

@ -0,0 +1,35 @@
import { makeStyles } from '@material-ui/styles';
export const useCommonStyles = makeStyles(theme => ({
contentSpacingY: {
'& > *': {
margin: '0.6rem 0',
},
},
contentSpacingX: {
'& > *': {
margin: '0 0.8rem',
},
},
divider: {
margin: '1rem 0',
backgroundColor: theme.palette.division.main,
height: '3px',
},
bold: {
fontWeight: 'bold',
},
flexRow: {
display: 'flex',
},
flexColumn: {
display: 'flex',
flexDirection: 'column',
},
flexWrap: {
flexWrap: 'wrap',
},
textCenter: {
textAlign: 'center',
},
}));

View File

@ -1,15 +1,15 @@
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { Card } from 'react-mdl'; import { Paper } from '@material-ui/core';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import CheckIcon from '@material-ui/icons/Check'; import CheckIcon from '@material-ui/icons/Check';
import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined'; import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined';
import ConditionallyRender from '../common/conditionally-render'; import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import { isFeatureExpired } from './utils'; import { isFeatureExpired } from '../utils';
import styles from './reporting.module.scss'; import styles from './ReportCard.module.scss';
const ReportCard = ({ features }) => { const ReportCard = ({ features }) => {
const getActiveToggles = () => { const getActiveToggles = () => {
@ -76,7 +76,7 @@ const ReportCard = ({ features }) => {
); );
return ( return (
<Card className={styles.card}> <Paper className={styles.card}>
<div className={styles.reportCardContainer}> <div className={styles.reportCardContainer}>
<div className={styles.reportCardListContainer}> <div className={styles.reportCardListContainer}>
<h2 className={styles.header}>Toggle report</h2> <h2 className={styles.header}>Toggle report</h2>
@ -113,7 +113,7 @@ const ReportCard = ({ features }) => {
</div> </div>
</div> </div>
</div> </div>
</Card> </Paper>
); );
}; };

View File

@ -0,0 +1,85 @@
.card {
width: 100%;
padding: var(--card-padding);
margin: var(--card-margin-y) 0;
}
.header {
font-size: var(--h1-size);
font-weight: 500;
margin: 0 0 0.5rem 0;
}
.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);
}

View File

@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ReportCard from './report-card'; import ReportCard from './ReportCard';
import { filterByProject } from './utils'; import { filterByProject } from '../utils';
const mapStateToProps = (state, ownProps) => { const mapStateToProps = (state, ownProps) => {
const features = state.features.toJS(); const features = state.features.toJS();

View File

@ -1,18 +1,17 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import classnames from 'classnames'; import { Paper, MenuItem } from '@material-ui/core';
import { Card, Menu, MenuItem } from 'react-mdl';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ReportToggleListItem from './report-toggle-list-item'; import ReportToggleListItem from './ReportToggleListItem/ReportToggleListItem';
import ReportToggleListHeader from './report-toggle-list-header'; import ReportToggleListHeader from './ReportToggleListHeader/ReportToggleListHeader';
import ConditionallyRender from '../common/conditionally-render'; import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import DropdownMenu from '../../common/dropdown-menu';
import { getObjectProperties, getCheckedState, applyCheckedToFeatures } from './utils'; import { getObjectProperties, getCheckedState, applyCheckedToFeatures } from '../utils';
import useSort from './useSort'; import useSort from '../useSort';
import styles from './reporting.module.scss'; import styles from './ReportToggleList.module.scss';
import { DropdownButton } from '../common';
/* FLAG TO TOGGLE UNFINISHED BULK ACTIONS FEATURE */ /* FLAG TO TOGGLE UNFINISHED BULK ACTIONS FEATURE */
const BULK_ACTIONS_ON = false; const BULK_ACTIONS_ON = false;
@ -47,26 +46,20 @@ const ReportToggleList = ({ features, selectedProject }) => {
)); ));
const renderBulkActionsMenu = () => ( const renderBulkActionsMenu = () => (
<span> <DropdownMenu
<DropdownButton id="bulk-actions"
className={classnames('mdl-button', styles.bulkAction)}
id="bulk_actions"
label="Bulk actions" label="Bulk actions"
/> renderOptions={() => (
<Menu <>
target="bulk_actions"
/* eslint-disable-next-line */
onClick={() => console.log("Hi")}
style={{ width: '168px' }}
>
<MenuItem>Mark toggles as stale</MenuItem> <MenuItem>Mark toggles as stale</MenuItem>
<MenuItem>Delete toggles</MenuItem> <MenuItem>Delete toggles</MenuItem>
</Menu> </>
</span> )}
/>
); );
return ( return (
<Card className={styles.reportToggleList}> <Paper className={styles.reportToggleList}>
<div className={styles.reportToggleListHeader}> <div className={styles.reportToggleListHeader}>
<h3 className={styles.reportToggleListHeading}>Overview</h3> <h3 className={styles.reportToggleListHeading}>Overview</h3>
<ConditionallyRender condition={BULK_ACTIONS_ON} show={renderBulkActionsMenu} /> <ConditionallyRender condition={BULK_ACTIONS_ON} show={renderBulkActionsMenu} />
@ -83,7 +76,7 @@ const ReportToggleList = ({ features, selectedProject }) => {
<tbody>{renderListRows()}</tbody> <tbody>{renderListRows()}</tbody>
</table> </table>
</div> </div>
</Card> </Paper>
); );
}; };

View File

@ -0,0 +1,71 @@
.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;
}
.tableRow {
cursor: pointer;
}
.checkbox {
margin: 0;
padding: 0;
}

View File

@ -1,8 +1,8 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { filterByProject } from './utils'; import { filterByProject } from '../utils';
import ReportToggleList from './report-toggle-list'; import ReportToggleList from './ReportToggleList';
const mapStateToProps = (state, ownProps) => { const mapStateToProps = (state, ownProps) => {
const features = state.features.toJS(); const features = state.features.toJS();

View File

@ -1,13 +1,13 @@
import React from 'react'; import React from 'react';
import { Checkbox } from 'react-mdl'; import { Checkbox } from '@material-ui/core';
import UnfoldMoreOutlinedIcon from '@material-ui/icons/UnfoldMoreOutlined'; import UnfoldMoreOutlinedIcon from '@material-ui/icons/UnfoldMoreOutlined';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ConditionallyRender from '../common/conditionally-render'; import ConditionallyRender from '../../../common/ConditionallyRender/ConditionallyRender';
import { NAME, LAST_SEEN, CREATED, EXPIRED, STATUS, REPORT } from './constants'; import { NAME, LAST_SEEN, CREATED, EXPIRED, STATUS, REPORT } from '../../constants';
import styles from './reporting.module.scss'; import styles from '../ReportToggleList.module.scss';
const ReportToggleListHeader = ({ handleCheckAll, checkAll, setSortData, bulkActionsOn }) => { const ReportToggleListHeader = ({ handleCheckAll, checkAll, setSortData, bulkActionsOn }) => {
const handleSort = type => { const handleSort = type => {
@ -24,7 +24,12 @@ const ReportToggleListHeader = ({ handleCheckAll, checkAll, setSortData, bulkAct
condition={bulkActionsOn} condition={bulkActionsOn}
show={ show={
<th> <th>
<Checkbox onChange={handleCheckAll} value={checkAll} checked={checkAll} /> <Checkbox
onChange={handleCheckAll}
value={checkAll}
checked={checkAll}
className={styles.checkbox}
/>
</th> </th>
} }
/> />

View File

@ -3,15 +3,15 @@ import classnames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { Checkbox } from 'react-mdl'; import { Checkbox } from '@material-ui/core';
import CheckIcon from '@material-ui/icons/Check'; import CheckIcon from '@material-ui/icons/Check';
import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined'; import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined';
import ConditionallyRender from '../common/conditionally-render'; import ConditionallyRender from '../../../common/ConditionallyRender/ConditionallyRender';
import { pluralize, getDates, expired, toggleExpiryByTypeMap, getDiffInDays } from './utils'; import { pluralize, getDates, expired, toggleExpiryByTypeMap, getDiffInDays } from '../../utils';
import { KILLSWITCH, PERMISSION } from './constants'; import { KILLSWITCH, PERMISSION } from '../../constants';
import styles from './reporting.module.scss'; import styles from '../ReportToggleList.module.scss';
const ReportToggleListItem = ({ name, stale, lastSeenAt, createdAt, type, checked, bulkActionsOn, setFeatures }) => { const ReportToggleListItem = ({ name, stale, lastSeenAt, createdAt, type, checked, bulkActionsOn, setFeatures }) => {
const nameMatches = feature => feature.name === name; const nameMatches = feature => feature.name === name;
@ -107,7 +107,12 @@ const ReportToggleListItem = ({ name, stale, lastSeenAt, createdAt, type, checke
condition={bulkActionsOn} condition={bulkActionsOn}
show={ show={
<td> <td>
<Checkbox checked={checked} value={checked} onChange={handleChange} /> <Checkbox
checked={checked}
value={checked}
onChange={handleChange}
className={styles.checkbox}
/>
</td> </td>
} }
/> />

View File

@ -3,14 +3,15 @@ import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Select from '../common/select'; import Select from '../common/select';
import ReportCardContainer from './report-card-container'; import ReportCardContainer from './ReportCard/ReportCardContainer';
import ReportToggleListContainer from './report-toggle-list-container'; import ReportToggleListContainer from './ReportToggleList/ReportToggleListContainer';
import ConditionallyRender from '../common/conditionally-render'; import ConditionallyRender from '../common/ConditionallyRender/ConditionallyRender';
import { formatProjectOptions } from './utils'; import { formatProjectOptions } from './utils';
import { REPORTING_SELECT_ID } from '../../testIds';
import styles from './reporting.module.scss'; import styles from './Reporting.module.scss';
const Reporting = ({ fetchFeatureToggles, projects }) => { const Reporting = ({ fetchFeatureToggles, projects }) => {
const [projectOptions, setProjectOptions] = useState([{ key: 'default', label: 'Default' }]); const [projectOptions, setProjectOptions] = useState([{ key: 'default', label: 'Default' }]);
@ -40,11 +41,13 @@ const Reporting = ({ fetchFeatureToggles, projects }) => {
name="project" name="project"
className={styles.select} className={styles.select}
options={projectOptions} options={projectOptions}
value={setSelectedProject.label} value={selectedProject}
onChange={onChange} onChange={onChange}
inputProps={{ ['data-test']: REPORTING_SELECT_ID }}
/> />
</div> </div>
); );
const multipleProjects = projects.length > 1; const multipleProjects = projects.length > 1;
return ( return (

View File

@ -125,7 +125,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: 1px solid #f1f1f1; border-bottom: var(--default-border);
padding: 1rem var(--card-padding-x); padding: 1rem var(--card-padding-x);
} }

View File

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

View File

@ -5,7 +5,8 @@ import { HashRouter } from 'react-router-dom';
import { createStore } from 'redux'; import { createStore } from 'redux';
import { mount } from 'enzyme/build'; import { mount } from 'enzyme/build';
import Reporting from '../reporting'; import Reporting from '../Reporting';
import { REPORTING_SELECT_ID } from '../../../testIds';
import { testProjects, testFeatures } from '../testData'; import { testProjects, testFeatures } from '../testData';
@ -15,13 +16,6 @@ const mockStore = {
}; };
const mockReducer = state => state; 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', () => { test('changing projects renders only toggles from that project', () => {
const wrapper = mount( const wrapper = mount(
<HashRouter> <HashRouter>
@ -31,9 +25,7 @@ test('changing projects renders only toggles from that project', () => {
</HashRouter> </HashRouter>
); );
const select = wrapper.find('.mdl-textfield__input').first(); const select = wrapper.find(`input[data-test="${REPORTING_SELECT_ID}"][value="default"]`).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'); let list = wrapper.find('tr');
/* Length of projects belonging to project (3) + header row (1) */ /* Length of projects belonging to project (3) + header row (1) */

View File

@ -0,0 +1,70 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import ConfiguredAddons from './ConfiguredAddons';
import AvailableAddons from './AvailableAddons';
import { Avatar, Icon } from '@material-ui/core';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
const style = {
width: '40px',
height: '40px',
marginRight: '16px',
float: 'left',
};
const getIcon = name => {
switch (name) {
case 'slack':
return <img style={style} src="public/slack.svg" />;
case 'jira-comment':
return <img style={style} src="public/jira.svg" />;
case 'webhook':
return <img style={style} src="public/webhooks.svg" />;
default:
return (
<Avatar>
<Icon>device_hub</Icon>
</Avatar>
);
}
};
const AddonList = ({ addons, providers, fetchAddons, removeAddon, toggleAddon, history, hasPermission }) => {
useEffect(() => {
if (addons.length === 0) {
fetchAddons();
}
}, []);
return (
<>
<ConditionallyRender
condition={addons.length > 0}
show={
<ConfiguredAddons
addons={addons}
toggleAddon={toggleAddon}
hasPermission={hasPermission}
removeAddon={removeAddon}
getIcon={getIcon}
/>
}
/>
<br />
<AvailableAddons providers={providers} hasPermission={hasPermission} history={history} getIcon={getIcon} />
</>
);
};
AddonList.propTypes = {
addons: PropTypes.array.isRequired,
providers: PropTypes.array.isRequired,
fetchAddons: PropTypes.func.isRequired,
removeAddon: PropTypes.func.isRequired,
toggleAddon: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
hasPermission: PropTypes.func.isRequired,
};
export default AddonList;

View File

@ -0,0 +1,44 @@
import React from 'react';
import PageContent from '../../../common/PageContent/PageContent';
import { Button, List, ListItem, ListItemAvatar, ListItemSecondaryAction, ListItemText } from '@material-ui/core';
import ConditionallyRender from '../../../common/ConditionallyRender/ConditionallyRender';
import { CREATE_ADDON } from '../../../../permissions';
import PropTypes from 'prop-types';
const AvailableAddons = ({ providers, getIcon, hasPermission, history }) => {
const renderProvider = provider => (
<ListItem key={provider.name}>
<ListItemAvatar>{getIcon(provider.name)}</ListItemAvatar>
<ListItemText primary={provider.displayName} secondary={provider.description} />
<ListItemSecondaryAction>
<ConditionallyRender
condition={hasPermission(CREATE_ADDON)}
show={
<Button
variant="contained"
name="device_hub"
onClick={() => history.push(`/addons/create/${provider.name}`)}
title="Configure"
>
Configure
</Button>
}
/>
</ListItemSecondaryAction>
</ListItem>
);
return (
<PageContent headerContent="Available addons">
<List>{providers.map(provider => renderProvider(provider))}</List>
</PageContent>
);
};
AvailableAddons.propTypes = {
providers: PropTypes.array.isRequired,
getIcon: PropTypes.func.isRequired,
hasPermission: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
};
export default AvailableAddons;

View File

@ -0,0 +1,3 @@
import AvailableAddons from './AvailableAddons';
export default AvailableAddons;

View File

@ -0,0 +1,77 @@
import React from 'react';
import {
Icon,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
} from '@material-ui/core';
import ConditionallyRender from '../../../common/ConditionallyRender/ConditionallyRender';
import { DELETE_ADDON, UPDATE_ADDON } from '../../../../permissions';
import { Link } from 'react-router-dom';
import PageContent from '../../../common/PageContent/PageContent';
import PropTypes from 'prop-types';
const ConfiguredAddons = ({ addons, hasPermission, removeAddon, getIcon, toggleAddon }) => {
const onRemoveAddon = addon => () => removeAddon(addon);
const renderAddon = addon => (
<ListItem key={addon.id}>
<ListItemAvatar>{getIcon(addon.provider)}</ListItemAvatar>
<ListItemText
primary={
<span>
<ConditionallyRender
condition={hasPermission(UPDATE_ADDON)}
show={
<Link to={`/addons/edit/${addon.id}`}>
<strong>{addon.provider}</strong>
</Link>
}
elseShow={<strong>{addon.provider}</strong>}
/>
{addon.enabled ? null : <small> (Disabled)</small>}
</span>
}
secondary={addon.description}
/>
<ListItemSecondaryAction>
<ConditionallyRender
condition={hasPermission(UPDATE_ADDON)}
show={
<IconButton
size="small"
title={addon.enabled ? 'Disable addon' : 'Enable addon'}
onClick={() => toggleAddon(addon)}
>
<Icon>{addon.enabled ? 'visibility' : 'visibility_off'}</Icon>
</IconButton>
}
/>
<ConditionallyRender
condition={hasPermission(DELETE_ADDON)}
show={
<IconButton size="small" title="Remove addon" onClick={onRemoveAddon(addon)}>
<Icon>delete</Icon>
</IconButton>
}
/>
</ListItemSecondaryAction>
</ListItem>
);
return (
<PageContent headerContent="Configured addons">
<List>{addons.map(addon => renderAddon(addon))}</List>
</PageContent>
);
};
ConfiguredAddons.propTypes = {
addons: PropTypes.array.isRequired,
hasPermission: PropTypes.func.isRequired,
removeAddon: PropTypes.func.isRequired,
toggleAddon: PropTypes.func.isRequired,
getIcon: PropTypes.func.isRequired,
};
export default ConfiguredAddons;

View File

@ -0,0 +1,3 @@
import ConfiguredAddons from './ConfiguredAddons';
export default ConfiguredAddons;

View File

@ -0,0 +1,3 @@
import AddonListComponent from './AddonList';
export default AddonListComponent;

View File

@ -1,12 +1,15 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Textfield, Card, CardTitle, CardText, CardActions, Switch, Grid, Cell } from 'react-mdl'; import { TextField, FormControlLabel, Switch } from '@material-ui/core';
import { FormButtons, styles as commonStyles } from '../common'; import { FormButtons, styles as commonStyles } from '../common';
import { trim } from '../common/util'; import { trim } from '../common/util';
import AddonParameters from './form-addon-parameters'; import AddonParameters from './form-addon-parameters';
import AddonEvents from './form-addon-events'; import AddonEvents from './form-addon-events';
import { cloneDeep } from 'lodash'; import cloneDeep from 'lodash.clonedeep';
import styles from './form-addon-component.module.scss';
import PageContent from '../common/PageContent/PageContent';
const AddonFormComponent = ({ editMode, provider, addon, fetch, cancel, submit }) => { const AddonFormComponent = ({ editMode, provider, addon, fetch, cancel, submit }) => {
const [config, setConfig] = useState(addon); const [config, setConfig] = useState(addon);
@ -98,49 +101,46 @@ const AddonFormComponent = ({ editMode, provider, addon, fetch, cancel, submit }
const { name, description, documentationUrl = 'https://unleash.github.io/docs/addons' } = provider ? provider : {}; const { name, description, documentationUrl = 'https://unleash.github.io/docs/addons' } = provider ? provider : {};
return ( return (
<Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}> <PageContent headerContent={`Configure ${name} addon`}>
<CardTitle style={{ paddingTop: '24px', paddingBottom: '0', wordBreak: 'break-all' }}> <section className={styles.formSection}>
Configure {name}
</CardTitle>
<CardText>
{description}&nbsp; {description}&nbsp;
<a href={documentationUrl} target="_blank"> <a href={documentationUrl} target="_blank">
Read more Read more
</a> </a>
<p className={commonStyles.error}>{errors.general}</p> <p className={commonStyles.error}>{errors.general}</p>
</CardText> </section>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<section style={{ padding: '16px' }}> <section className={styles.formSection}>
<Grid noSpacing> <TextField
<Cell col={4}> size="small"
<Textfield
floatingLabel
label="Provider" label="Provider"
name="provider" name="provider"
value={config.provider} value={config.provider}
disabled disabled
variant="outlined"
className={styles.nameInput}
/> />
</Cell> <FormControlLabel
<Cell col={4} style={{ paddingTop: '14px' }}> control={<Switch checked={config.enabled} onChange={onEnabled} />}
<Switch checked={config.enabled} onChange={onEnabled}> label={config.enabled ? 'Enabled' : 'Disabled'}
{config.enabled ? 'Enabled' : 'Disabled'} />
</Switch> </section>
</Cell> <section className={styles.formSection}>
</Grid> <TextField
size="small"
<Textfield
floatingLabel
style={{ width: '80%' }} style={{ width: '80%' }}
rows={1} rows={4}
multiline
label="Description" label="Description"
name="description" name="description"
placeholder="" placeholder=""
value={config.description} value={config.description}
error={errors.description} error={errors.description}
onChange={setFieldValue('description')} onChange={setFieldValue('description')}
variant="outlined"
/> />
</section> </section>
<section style={{ padding: '16px' }}> <section className={styles.formSection}>
<AddonEvents <AddonEvents
provider={provider} provider={provider}
checkedEvents={config.events} checkedEvents={config.events}
@ -148,7 +148,7 @@ const AddonFormComponent = ({ editMode, provider, addon, fetch, cancel, submit }
error={errors.events} error={errors.events}
/> />
</section> </section>
<section style={{ padding: '16px' }}> <section className={styles.formSection}>
<AddonParameters <AddonParameters
provider={provider} provider={provider}
config={config} config={config}
@ -157,11 +157,11 @@ const AddonFormComponent = ({ editMode, provider, addon, fetch, cancel, submit }
setParameterValue={setParameterValue} setParameterValue={setParameterValue}
/> />
</section> </section>
<CardActions> <section className={styles.formSection}>
<FormButtons submitText={submitText} onCancel={cancel} /> <FormButtons submitText={submitText} onCancel={cancel} />
</CardActions> </section>
</form> </form>
</Card> </PageContent>
); );
}; };

View File

@ -0,0 +1,17 @@
.nameInput {
margin-right: 1.5rem;
}
.formContainer {
margin-bottom: 1.5rem;
max-width: 350px;
}
.formSection {
padding: 10px 28px;
}
.header {
font-size: var(--h1-size);
padding: var(--card-header-padding);
}

View File

@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import FormComponent from './form-addon-component'; import FormComponent from './form-addon-component';
import { updateAddon, createAddon, fetchAddons } from '../../store/addons/actions'; import { updateAddon, createAddon, fetchAddons } from '../../store/addons/actions';
import { cloneDeep } from 'lodash'; import cloneDeep from 'lodash.clonedeep';
// Required for to fill the initial form. // Required for to fill the initial form.
const DEFAULT_DATA = { const DEFAULT_DATA = {

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Checkbox, Grid, Cell } from 'react-mdl'; import { Grid, FormControlLabel, Checkbox } from '@material-ui/core';
import { styles as commonStyles } from '../common'; import { styles as commonStyles } from '../common';
@ -11,11 +11,14 @@ const AddonEvents = ({ provider, checkedEvents, setEventValue, error }) => {
<React.Fragment> <React.Fragment>
<h4>Events</h4> <h4>Events</h4>
<span className={commonStyles.error}>{error}</span> <span className={commonStyles.error}>{error}</span>
<Grid className="demo-grid-ruler"> <Grid container spacing={0}>
{provider.events.map(e => ( {provider.events.map(e => (
<Cell col={4} key={e}> <Grid item xs={4} key={e}>
<Checkbox label={e} ripple checked={checkedEvents.includes(e)} onChange={setEventValue(e)} /> <FormControlLabel
</Cell> control={<Checkbox checked={checkedEvents.includes(e)} onChange={setEventValue(e)} />}
label={e}
/>
</Grid>
))} ))}
</Grid> </Grid>
</React.Fragment> </React.Fragment>

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Textfield } from 'react-mdl'; import { TextField } from '@material-ui/core';
const MASKED_VALUE = '*****'; const MASKED_VALUE = '*****';
@ -18,23 +18,27 @@ const AddonParameter = ({ definition, config, errors, setParameterValue }) => {
const value = config.parameters[definition.name] || ''; const value = config.parameters[definition.name] || '';
const type = resolveType(definition, value); const type = resolveType(definition, value);
const error = errors.parameters[definition.name]; const error = errors.parameters[definition.name];
const descStyle = { fontSize: '0.8em', color: 'gray', marginTop: error ? '2px' : '-15px' };
return ( return (
<div style={{ width: '80%', marginTop: '25px' }}> <div style={{ width: '80%', marginTop: '25px' }}>
<Textfield <TextField
floatingLabel size="small"
style={{ width: '100%' }} style={{ width: '100%' }}
rows={definition.type === 'textfield' ? 9 : 0} rows={definition.type === 'textfield' ? 9 : 0}
multiline={definition.type === 'textfield'}
type={type} type={type}
label={definition.displayName} label={definition.displayName}
name={definition.name} name={definition.name}
placeholder={definition.placeholder || ''} placeholder={definition.placeholder || ''}
InputLabelProps={{
shrink: true,
}}
value={value} value={value}
error={error} error={error}
onChange={setParameterValue(definition.name)} onChange={setParameterValue(definition.name)}
variant="outlined"
helperText={definition.description}
/> />
<div style={descStyle}>{definition.description}</div>
</div> </div>
); );
}; };
@ -54,8 +58,8 @@ const AddonParameters = ({ provider, config, errors, setParameterValue, editMode
<h4>Parameters</h4> <h4>Parameters</h4>
{editMode ? ( {editMode ? (
<p> <p>
Sensitive parameters will be masked with value "<i>*****</i>". If you don't change the value they Sensitive parameters will be masked with value "<i>*****</i>
will not be updated when saving. ". If you don't change the value they will not be updated when saving.
</p> </p>
) : null} ) : null}
{provider.parameters.map(p => ( {provider.parameters.map(p => (
@ -76,7 +80,7 @@ AddonParameters.propTypes = {
config: PropTypes.object.isRequired, config: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired, errors: PropTypes.object.isRequired,
setParameterValue: PropTypes.func.isRequired, setParameterValue: PropTypes.func.isRequired,
editMode: PropTypes.bool.optional, editMode: PropTypes.bool,
}; };
export default AddonParameters; export default AddonParameters;

View File

@ -1,5 +1,5 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import AddonsListComponent from './list-component.jsx'; import AddonsListComponent from './AddonList';
import { fetchAddons, removeAddon, updateAddon } from '../../store/addons/actions'; import { fetchAddons, removeAddon, updateAddon } from '../../store/addons/actions';
import { hasPermission } from '../../permissions'; import { hasPermission } from '../../permissions';

View File

@ -1,117 +0,0 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { List, ListItem, ListItemAction, IconButton, Card, Button } from 'react-mdl';
import { HeaderTitle, styles as commonStyles } from '../common';
import { CREATE_ADDON, DELETE_ADDON, UPDATE_ADDON } from '../../permissions';
const style = { width: '40px', height: '40px', marginRight: '16px', float: 'left' };
const getIcon = name => {
switch (name) {
case 'slack':
return <img style={style} src="public/slack.svg" />;
case 'jira-comment':
return <img style={style} src="public/jira.svg" />;
case 'webhook':
return <img style={style} src="public/webhooks.svg" />;
default:
return <i className="material-icons mdl-list__item-avatar">device_hub</i>;
}
};
const AddonListComponent = ({ addons, providers, fetchAddons, removeAddon, toggleAddon, history, hasPermission }) => {
useEffect(() => {
if (addons.length === 0) {
fetchAddons();
}
}, []);
const onRemoveAddon = addon => () => removeAddon(addon);
return (
<div>
{addons.length > 0 ? (
<Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}>
<HeaderTitle title="Configured addons" />
<List>
{addons.map(addon => (
<ListItem key={addon.id} threeLine>
<span className={['mdl-list__item-primary-content'].join(' ')}>
{getIcon(addon.provider)}
<span>
{hasPermission(UPDATE_ADDON) ? (
<Link to={`/addons/edit/${addon.id}`}>
<strong>{addon.provider}</strong>
</Link>
) : (
<strong>{addon.provider}</strong>
)}
{addon.enabled ? null : <small> (Disabled)</small>}
</span>
<span className="mdl-list__item-text-body">{addon.description}</span>
</span>
<ListItemAction>
{hasPermission(UPDATE_ADDON) ? (
<IconButton
name={addon.enabled ? 'visibility' : 'visibility_off'}
title={addon.enabled ? 'Disable addon' : 'Enable addon'}
onClick={() => toggleAddon(addon)}
/>
) : null}
{hasPermission(DELETE_ADDON) ? (
<IconButton name="delete" title="Remove addon" onClick={onRemoveAddon(addon)} />
) : null}
</ListItemAction>
</ListItem>
))}
</List>
</Card>
) : null}
<br />
<Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}>
<HeaderTitle title="Available addons" />
<List>
{providers.map((provider, i) => (
<ListItem key={i} threeLine>
<span className={['mdl-list__item-primary-content'].join(' ')}>
{getIcon(provider.name)}
<span>
<strong>{provider.displayName}</strong>&nbsp;
</span>
<span className="mdl-list__item-text-body">{provider.description}</span>
</span>
<ListItemAction>
{hasPermission(CREATE_ADDON) ? (
<Button
raised
colored
name="device_hub"
onClick={() => history.push(`/addons/create/${provider.name}`)}
title="Configure"
>
Configure
</Button>
) : (
''
)}
</ListItemAction>
</ListItem>
))}
</List>
</Card>
</div>
);
};
AddonListComponent.propTypes = {
addons: PropTypes.array.isRequired,
providers: PropTypes.array.isRequired,
fetchAddons: PropTypes.func.isRequired,
removeAddon: PropTypes.func.isRequired,
toggleAddon: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
hasPermission: PropTypes.func.isRequired,
};
export default AddonListComponent;

View File

@ -1,68 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly with empty version 1`] = ` exports[`renders correctly with empty version 1`] = `
<react-mdl-FooterSection <section
logo="Unleash " title="API details"
type="bottom"
> >
<h4>
Unleash
</h4>
<small> <small>
(test) \`($
test
)\`
</small> </small>
<br /> <br />
<small> <br />
</small>
<br /> <br />
<small> <small>
We are the best! We are the best!
</small> </small>
<br /> <br />
<small> </section>
</small>
</react-mdl-FooterSection>
`; `;
exports[`renders correctly with ui-config 1`] = ` exports[`renders correctly with ui-config 1`] = `
<react-mdl-FooterSection <section
logo="Unleash 1.1.0" title="API details"
type="bottom"
> >
<h4>
Unleash 1.1.0
</h4>
<small> <small>
(test) \`($
test
)\`
</small> </small>
<br /> <br />
<small> <br />
</small>
<br /> <br />
<small> <small>
We are the best! We are the best!
</small> </small>
<br /> <br />
<small> </section>
</small>
</react-mdl-FooterSection>
`; `;
exports[`renders correctly without uiConfig 1`] = ` exports[`renders correctly without uiConfig 1`] = `
<react-mdl-FooterSection <section
logo="Unleash 1.1.0" title="API details"
type="bottom"
> >
<small> <h4>
Unleash 1.1.0
</small> </h4>
<br />
<br /> <br />
<small>
</small>
<br /> <br />
<small /> <small />
<br /> <br />
<small> </section>
</small>
</react-mdl-FooterSection>
`; `;

View File

@ -3,8 +3,6 @@ import React from 'react';
import ShowApiDetailsComponent from '../show-api-details-component'; import ShowApiDetailsComponent from '../show-api-details-component';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
jest.mock('react-mdl');
test('renders correctly with empty version', () => { test('renders correctly with empty version', () => {
const uiConfig = { const uiConfig = {
name: 'Unleash', name: 'Unleash',

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FooterSection } from 'react-mdl'; import ConditionallyRender from '../common/ConditionallyRender/ConditionallyRender';
class ShowApiDetailsComponent extends Component { class ShowApiDetailsComponent extends Component {
static propTypes = { static propTypes = {
@ -15,12 +15,12 @@ class ShowApiDetailsComponent extends Component {
if (versionInfo) { if (versionInfo) {
if (versionInfo.current.enterprise) { if (versionInfo.current.enterprise) {
versionStr = `${name} ${versionInfo.current.enterprise}`; versionStr = `${name} ${versionInfo.current.enterprise}`;
if (versionInfo.latest && !versionInfo.isLatest) { if (Object.keys(versionInfo.latest).includes('enterprise') && !versionInfo.isLatest) {
updateNotification = `Upgrade available - Latest Enterprise release: ${versionInfo.latest.enterprise}`; updateNotification = `Upgrade available - Latest Enterprise release: ${versionInfo.latest.enterprise}`;
} }
} else { } else {
versionStr = `${name} ${versionInfo.current.oss}`; versionStr = `${name} ${versionInfo.current.oss}`;
if (versionInfo.latest && !versionInfo.isLatest) { if (Object.keys(versionInfo.latest).includes('oss') && !versionInfo.isLatest) {
updateNotification = `Upgrade available - Latest OSS release: ${versionInfo.latest.oss}`; updateNotification = `Upgrade available - Latest OSS release: ${versionInfo.latest.oss}`;
} }
} }
@ -29,15 +29,17 @@ class ShowApiDetailsComponent extends Component {
versionStr = `${name} ${version}`; versionStr = `${name} ${version}`;
} }
return ( return (
<FooterSection type="bottom" logo={`${versionStr}`}> <section title="API details">
<small>{environment ? `(${environment})` : ''}</small> <h4>{`${versionStr}`}</h4>
<ConditionallyRender condition={environment} show={<small>`(${environment})`</small>} />
<br />
<ConditionallyRender condition={updateNotification} show={<small>{updateNotification}`</small>} />
<br /> <br />
<small>{updateNotification ? `${updateNotification}` : ''}</small>
<br /> <br />
<small>{slogan}</small> <small>{slogan}</small>
<br /> <br />
<small>{instanceId ? `${instanceId}` : ''}</small> <ConditionallyRender condition={instanceId} show={<small>{`${instanceId}`}</small>} />
</FooterSection> </section>
); );
} }
} }

View File

@ -4,11 +4,12 @@ import PropTypes from 'prop-types';
import { Route, Redirect, Switch } from 'react-router-dom'; import { Route, Redirect, Switch } from 'react-router-dom';
import Features from '../page/features'; import Features from '../page/features';
import { routes } from './menu/routes';
import styles from './styles.module.scss';
import AuthenticationContainer from './user/authentication-container'; import AuthenticationContainer from './user/authentication-container';
import MainLayout from './layout/main'; import MainLayout from './layout/main';
import { routes } from './menu/routes';
import styles from './styles.module.scss';
class App extends PureComponent { class App extends PureComponent {
static propTypes = { static propTypes = {
location: PropTypes.object.isRequired, location: PropTypes.object.isRequired,

View File

@ -5,149 +5,346 @@ exports[`renders correctly if no application 1`] = `
<p> <p>
Loading... Loading...
</p> </p>
<react-mdl-ProgressBar <div
indeterminate={true} className="MuiLinearProgress-root MuiLinearProgress-colorPrimary MuiLinearProgress-indeterminate"
role="progressbar"
>
<div
className="MuiLinearProgress-bar MuiLinearProgress-barColorPrimary MuiLinearProgress-bar1Indeterminate"
style={Object {}}
/> />
<div
className="MuiLinearProgress-bar MuiLinearProgress-bar2Indeterminate MuiLinearProgress-barColorPrimary"
style={Object {}}
/>
</div>
</div> </div>
`; `;
exports[`renders correctly with permissions 1`] = ` exports[`renders correctly with permissions 1`] = `
<react-mdl-Card <div
className="fullwidth" className="MuiPaper-root MuiPaper-elevation1 MuiPaper-rounded"
shadow={0}
> >
<react-mdl-CardTitle <div
className="makeStyles-headerContainer-1"
>
<div
className="makeStyles-headerTitleContainer-5"
>
<div
className=""
>
<h2
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
>
<span
style={ style={
Object { Object {
"paddingRight": "64px", "alignItems": "center",
"paddingTop": "24px", "display": "flex",
"wordBreak": "break-all",
} }
} }
> >
<react-mdl-Icon <div
name="apps" className="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
/> style={
  Object {
"marginRight": "8px",
}
}
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
apps
</span>
</div>
test-app test-app
</react-mdl-CardTitle> </span>
<react-mdl-CardText </h2>
style={ </div>
Object { <div
"paddingTop": "0", className="makeStyles-headerActions-7"
} >
} <a
aria-disabled={false}
className="MuiTypography-root MuiLink-root MuiLink-underlineHover MuiButtonBase-root MuiIconButton-root MuiTypography-colorPrimary"
href="http://example.org"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="button"
tabIndex={0}
>
<span
className="MuiIconButton-label"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
link
</span>
</span>
</a>
<button
className="MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-textSecondary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
title="Delete application"
type="button"
>
<span
className="MuiButton-label"
>
Delete
</span>
</button>
</div>
</div>
</div>
<div
className="makeStyles-bodyContainer-2"
>
<div>
<p
className="MuiTypography-root MuiTypography-body1"
> >
<p>
app description app description
</p> </p>
<p> <p
className="MuiTypography-root MuiTypography-body2"
>
Created: Created:
<strong> <strong>
Invalid Date Invalid Date
</strong> </strong>
</p> </p>
</react-mdl-CardText> </div>
<react-mdl-CardMenu>
<a
className="mdl-color-text--grey-600"
href="http://example.org"
rel="noopener"
target="_blank"
>
<react-mdl-Icon
name="link"
/>
</a>
</react-mdl-CardMenu>
<div> <div>
<react-mdl-CardActions <div
border={true} className="MuiPaper-root makeStyles-tabNav-8 MuiPaper-elevation1 MuiPaper-rounded"
>
<div
className="MuiTabs-root"
>
<div
className="MuiTabs-scroller MuiTabs-fixed"
onScroll={[Function]}
style={ style={
Object { Object {
"alignItems": "center", "marginBottom": null,
"display": "flex", "overflow": "hidden",
"justifyContent": "space-between",
} }
} }
> >
<span /> <div
<react-mdl-Button className="MuiTabs-flexContainer MuiTabs-centered"
accent={true} onKeyDown={[Function]}
role="tablist"
>
<button
aria-controls="tabpanel-0"
aria-selected={true}
className="MuiButtonBase-root MuiTab-root MuiTab-textColorPrimary Mui-selected"
disabled={false}
id="tab-0"
onBlur={[Function]}
onClick={[Function]} onClick={[Function]}
title="Delete application" onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="tab"
tabIndex={0}
type="button"
> >
Delete <span
</react-mdl-Button> className="MuiTab-wrapper"
</react-mdl-CardActions>
<hr />
<react-mdl-Tabs
activeTab={0}
className="mdl-color--grey-100"
onChange={[Function]}
ripple={true}
tabBarProps={
Object {
"style": Object {
"width": "100%",
},
}
}
> >
<react-mdl-Tab> Application overview
Details </span>
</react-mdl-Tab> <span
<react-mdl-Tab> className="PrivateTabIndicator-root-9 PrivateTabIndicator-colorPrimary-10 MuiTabs-indicator"
Edit style={Object {}}
</react-mdl-Tab> />
</react-mdl-Tabs> </button>
<button
aria-controls="tabpanel-1"
aria-selected={false}
className="MuiButtonBase-root MuiTab-root MuiTab-textColorPrimary"
disabled={false}
id="tab-1"
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="tab"
tabIndex={-1}
type="button"
>
<span
className="MuiTab-wrapper"
>
Edit application
</span>
</button>
</div> </div>
<react-mdl-Grid </div>
</div>
</div>
<div>
<div
aria-labelledby="wrapped-tab-0"
hidden={false}
id="wrapped-tabpanel-0"
role="tabpanel"
>
<div
className="MuiGrid-root MuiGrid-container"
style={ style={
Object { Object {
"margin": 0, "margin": 0,
} }
} }
> >
<react-mdl-Cell <div
col={6} className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-6 MuiGrid-grid-xl-6"
hidePhone={true} >
phone={12} <h6
tablet={4} className="MuiTypography-root MuiTypography-subtitle1"
style={
Object {
"padding": "1rem 0",
}
}
> >
<h6>
Toggles Toggles
</h6> </h6>
<hr /> <hr />
<react-mdl-List> <ul
<react-mdl-ListItem className="MuiList-root MuiList-padding"
twoLine={true}
> >
<react-mdl-ListItemContent <li
icon={ className="MuiListItem-root MuiListItem-gutters"
<span> disabled={false}
<react-mdl-Switch >
checked={true} <div
className="MuiListItemAvatar-root"
>
<span
className="MuiSwitch-root"
>
<span
aria-disabled={true}
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-13 MuiSwitch-switchBase MuiSwitch-colorSecondary PrivateSwitchBase-disabled-15 Mui-disabled Mui-disabled Mui-disabled"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={-1}
>
<span
className="MuiIconButton-label"
>
<input
className="PrivateSwitchBase-input-16 MuiSwitch-input"
disabled={true} disabled={true}
onChange={[Function]}
type="checkbox"
value={true}
/>
<span
className="MuiSwitch-thumb"
/> />
</span> </span>
} </span>
subtitle="this is A toggle" <span
className="MuiSwitch-track"
/>
</span>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
> >
<a <a
href="/features/view/ToggleA" href="/features/strategies/ToggleA"
onClick={[Function]} onClick={[Function]}
> >
ToggleA ToggleA
</a> </a>
</react-mdl-ListItemContent> </span>
</react-mdl-ListItem> <p
<react-mdl-ListItem className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
twoLine={true}
> >
<react-mdl-ListItemContent this is A toggle
icon="report" </p>
subtitle="Missing, want to create?" </div>
</li>
<li
className="MuiListItem-root MuiListItem-gutters"
disabled={false}
>
<div
className="MuiListItemAvatar-root"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
report
</span>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
> >
<a <a
href="/features/create?name=ToggleB" href="/features/create?name=ToggleB"
@ -155,26 +352,52 @@ exports[`renders correctly with permissions 1`] = `
> >
ToggleB ToggleB
</a> </a>
</react-mdl-ListItemContent> </span>
</react-mdl-ListItem> <p
</react-mdl-List> className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
</react-mdl-Cell> >
<react-mdl-Cell Missing, want to create?
col={6} </p>
phone={12} </div>
tablet={4} </li>
</ul>
</div>
<div
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-6 MuiGrid-grid-xl-6"
>
<h6
className="MuiTypography-root MuiTypography-subtitle1"
style={
Object {
"padding": "1rem 0",
}
}
> >
<h6>
Implemented strategies Implemented strategies
</h6> </h6>
<hr /> <hr />
<react-mdl-List> <ul
<react-mdl-ListItem className="MuiList-root MuiList-padding"
twoLine={true}
> >
<react-mdl-ListItemContent <li
icon="extension" className="MuiListItem-root MuiListItem-gutters"
subtitle="A description" disabled={false}
>
<div
className="MuiListItemAvatar-root"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
extension
</span>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
> >
<a <a
href="/strategies/view/StrategyA" href="/strategies/view/StrategyA"
@ -182,14 +405,33 @@ exports[`renders correctly with permissions 1`] = `
> >
StrategyA StrategyA
</a> </a>
</react-mdl-ListItemContent> </span>
</react-mdl-ListItem> <p
<react-mdl-ListItem className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
twoLine={true}
> >
<react-mdl-ListItemContent A description
icon="report" </p>
subtitle="Missing, want to create?" </div>
</li>
<li
className="MuiListItem-root MuiListItem-gutters"
disabled={false}
>
<div
className="MuiListItemAvatar-root"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
report
</span>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
> >
<a <a
href="/strategies/create?name=StrategyB" href="/strategies/create?name=StrategyB"
@ -197,26 +439,59 @@ exports[`renders correctly with permissions 1`] = `
> >
StrategyB StrategyB
</a> </a>
</react-mdl-ListItemContent> </span>
</react-mdl-ListItem> <p
</react-mdl-List> className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
</react-mdl-Cell> >
<react-mdl-Cell Missing, want to create?
col={12} </p>
tablet={12} </div>
</li>
</ul>
</div>
<div
className="MuiGrid-root MuiGrid-item MuiGrid-grid-md-12 MuiGrid-grid-xl-12"
>
<h6
className="MuiTypography-root MuiTypography-subtitle1"
style={
Object {
"padding": "1rem 0",
}
}
> >
<h6>
1 1
Instances registered Instances registered
</h6> </h6>
<hr /> <hr />
<react-mdl-List> <ul
<react-mdl-ListItem className="MuiList-root MuiList-padding"
twoLine={true} >
<li
className="MuiListItem-root MuiListItem-gutters"
disabled={false}
>
<div
className="MuiListItemAvatar-root"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
timeline
</span>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
instance-1 (4.0)
</span>
<p
className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
> >
<react-mdl-ListItemContent
icon="timeline"
subtitle={
<span> <span>
123.123.123.123 123.123.123.123
last seen at last seen at
@ -224,190 +499,121 @@ exports[`renders correctly with permissions 1`] = `
02/23/2017, 03:56:49 PM 02/23/2017, 03:56:49 PM
</small> </small>
</span> </span>
} </p>
> </div>
instance-1 </li>
</ul>
(4.0) </div>
</react-mdl-ListItemContent> </div>
</react-mdl-ListItem> </div>
</react-mdl-List> <div
</react-mdl-Cell> aria-labelledby="wrapped-tab-1"
</react-mdl-Grid> hidden={true}
</react-mdl-Card> id="wrapped-tabpanel-1"
role="tabpanel"
/>
</div>
</div>
</div>
</div>
`; `;
exports[`renders correctly without permission 1`] = ` exports[`renders correctly without permission 1`] = `
<react-mdl-Card <div
className="fullwidth" className="MuiPaper-root MuiPaper-elevation1 MuiPaper-rounded"
shadow={0}
> >
<react-mdl-CardTitle <div
className="makeStyles-headerContainer-1"
>
<div
className="makeStyles-headerTitleContainer-5"
>
<div
className=""
>
<h2
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
>
<span
style={ style={
Object { Object {
"paddingRight": "64px", "alignItems": "center",
"paddingTop": "24px", "display": "flex",
"wordBreak": "break-all",
} }
} }
> >
<react-mdl-Icon <div
name="apps" className="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
/> style={
  Object {
"marginRight": "8px",
}
}
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
apps
</span>
</div>
test-app test-app
</react-mdl-CardTitle> </span>
<react-mdl-CardText </h2>
style={ </div>
Object { <div
"paddingTop": "0", className="makeStyles-headerActions-7"
} >
} <a
aria-disabled={false}
className="MuiTypography-root MuiLink-root MuiLink-underlineHover MuiButtonBase-root MuiIconButton-root MuiTypography-colorPrimary"
href="http://example.org"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="button"
tabIndex={0}
>
<span
className="MuiIconButton-label"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
link
</span>
</span>
</a>
</div>
</div>
</div>
<div
className="makeStyles-bodyContainer-2"
>
<div>
<p
className="MuiTypography-root MuiTypography-body1"
> >
<p>
app description app description
</p> </p>
<p> <p
className="MuiTypography-root MuiTypography-body2"
>
Created: Created:
<strong> <strong>
Invalid Date Invalid Date
</strong> </strong>
</p> </p>
</react-mdl-CardText> </div>
<react-mdl-CardMenu> </div>
<a </div>
className="mdl-color-text--grey-600"
href="http://example.org"
rel="noopener"
target="_blank"
>
<react-mdl-Icon
name="link"
/>
</a>
</react-mdl-CardMenu>
<react-mdl-Grid
style={
Object {
"margin": 0,
}
}
>
<react-mdl-Cell
col={6}
hidePhone={true}
phone={12}
tablet={4}
>
<h6>
Toggles
</h6>
<hr />
<react-mdl-List>
<react-mdl-ListItem
twoLine={true}
>
<react-mdl-ListItemContent
icon={
<span>
<react-mdl-Switch
checked={true}
disabled={true}
/>
</span>
}
subtitle="this is A toggle"
>
<a
href="/features/view/ToggleA"
onClick={[Function]}
>
ToggleA
</a>
</react-mdl-ListItemContent>
</react-mdl-ListItem>
<react-mdl-ListItem
twoLine={true}
>
<react-mdl-ListItemContent
icon="report"
subtitle="Missing"
>
ToggleB
</react-mdl-ListItemContent>
</react-mdl-ListItem>
</react-mdl-List>
</react-mdl-Cell>
<react-mdl-Cell
col={6}
phone={12}
tablet={4}
>
<h6>
Implemented strategies
</h6>
<hr />
<react-mdl-List>
<react-mdl-ListItem
twoLine={true}
>
<react-mdl-ListItemContent
icon="extension"
subtitle="A description"
>
<a
href="/strategies/view/StrategyA"
onClick={[Function]}
>
StrategyA
</a>
</react-mdl-ListItemContent>
</react-mdl-ListItem>
<react-mdl-ListItem
twoLine={true}
>
<react-mdl-ListItemContent
icon="report"
subtitle="Missing"
>
StrategyB
</react-mdl-ListItemContent>
</react-mdl-ListItem>
</react-mdl-List>
</react-mdl-Cell>
<react-mdl-Cell
col={12}
tablet={12}
>
<h6>
1
Instances registered
</h6>
<hr />
<react-mdl-List>
<react-mdl-ListItem
twoLine={true}
>
<react-mdl-ListItemContent
icon="timeline"
subtitle={
<span>
123.123.123.123
last seen at
<small>
02/23/2017, 03:56:49 PM
</small>
</span>
}
>
instance-1
(4.0)
</react-mdl-ListItemContent>
</react-mdl-ListItem>
</react-mdl-List>
</react-mdl-Cell>
</react-mdl-Grid>
</react-mdl-Card>
`; `;

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { ThemeProvider } from '@material-ui/core';
import ClientApplications from '../application-edit-component'; import ClientApplications from '../application-edit-component';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { CREATE_FEATURE, CREATE_STRATEGY, UPDATE_APPLICATION } from '../../../permissions'; import { CREATE_FEATURE, CREATE_STRATEGY, UPDATE_APPLICATION } from '../../../permissions';
import theme from '../../../themes/main-theme';
jest.mock('react-mdl');
test('renders correctly if no application', () => { test('renders correctly if no application', () => {
const tree = renderer const tree = renderer
@ -27,6 +27,7 @@ test('renders correctly without permission', () => {
const tree = renderer const tree = renderer
.create( .create(
<MemoryRouter> <MemoryRouter>
<ThemeProvider theme={theme}>
<ClientApplications <ClientApplications
fetchApplication={() => Promise.resolve({})} fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()} storeApplicationMetaData={jest.fn()}
@ -72,6 +73,7 @@ test('renders correctly without permission', () => {
location={{ locale: 'en-GB' }} location={{ locale: 'en-GB' }}
hasPermission={() => false} hasPermission={() => false}
/> />
</ThemeProvider>
</MemoryRouter> </MemoryRouter>
) )
.toJSON(); .toJSON();
@ -83,6 +85,7 @@ test('renders correctly with permissions', () => {
const tree = renderer const tree = renderer
.create( .create(
<MemoryRouter> <MemoryRouter>
<ThemeProvider theme={theme}>
<ClientApplications <ClientApplications
fetchApplication={() => Promise.resolve({})} fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()} storeApplicationMetaData={jest.fn()}
@ -130,6 +133,7 @@ test('renders correctly with permissions', () => {
[CREATE_FEATURE, CREATE_STRATEGY, UPDATE_APPLICATION].indexOf(permission) !== -1 [CREATE_FEATURE, CREATE_STRATEGY, UPDATE_APPLICATION].indexOf(permission) !== -1
} }
/> />
</ThemeProvider>
</MemoryRouter> </MemoryRouter>
) )
.toJSON(); .toJSON();

View File

@ -2,12 +2,16 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, Card, CardActions, CardTitle, CardText, CardMenu, Icon, ProgressBar, Tabs, Tab } from 'react-mdl'; import { Avatar, Link, Icon, IconButton, Button, LinearProgress, Typography } from '@material-ui/core';
import { IconLink, styles as commonStyles } from '../common'; import ConditionallyRender from '../common/ConditionallyRender/ConditionallyRender';
import { formatFullDateTimeWithLocale, formatDateWithLocale } from '../common/util'; import { formatFullDateTimeWithLocale, formatDateWithLocale } from '../common/util';
import { UPDATE_APPLICATION } from '../../permissions'; import { UPDATE_APPLICATION } from '../../permissions';
import ApplicationView from './application-view'; import ApplicationView from './application-view';
import ApplicationUpdate from './application-update'; import ApplicationUpdate from './application-update';
import TabNav from '../common/TabNav/TabNav';
import Dialogue from '../common/Dialogue';
import PageContent from '../common/PageContent';
import HeaderTitle from '../common/HeaderTitle';
class ClientApplications extends PureComponent { class ClientApplications extends PureComponent {
static propTypes = { static propTypes = {
@ -23,7 +27,11 @@ class ClientApplications extends PureComponent {
constructor(props) { constructor(props) {
super(); super();
this.state = { activeTab: 0, loading: !props.application }; this.state = {
activeTab: 0,
loading: !props.application,
prompt: false,
};
} }
componentDidMount() { componentDidMount() {
@ -34,9 +42,11 @@ class ClientApplications extends PureComponent {
deleteApplication = async evt => { deleteApplication = async evt => {
evt.preventDefault(); evt.preventDefault();
// if (window.confirm('Are you sure you want to remove this application?')) {
const { deleteApplication, appName } = this.props; const { deleteApplication, appName } = this.props;
await deleteApplication(appName); await deleteApplication(appName);
this.props.history.push('/applications'); this.props.history.push('/applications');
// }
}; };
render() { render() {
@ -44,7 +54,7 @@ class ClientApplications extends PureComponent {
return ( return (
<div> <div>
<p>Loading...</p> <p>Loading...</p>
<ProgressBar indeterminate /> <LinearProgress />
</div> </div>
); );
} else if (!this.props.application) { } else if (!this.props.application) {
@ -53,8 +63,23 @@ class ClientApplications extends PureComponent {
const { application, storeApplicationMetaData, hasPermission } = this.props; const { application, storeApplicationMetaData, hasPermission } = this.props;
const { appName, instances, strategies, seenToggles, url, description, icon = 'apps', createdAt } = application; const { appName, instances, strategies, seenToggles, url, description, icon = 'apps', createdAt } = application;
const content = const toggleModal = () => {
this.state.activeTab === 0 ? ( this.setState(prev => ({ ...prev, prompt: !prev.prompt }));
};
const renderModal = () => (
<Dialogue
open={this.state.prompt}
onClose={toggleModal}
onClick={this.deleteApplication}
title="Are you sure you want to delete this application?"
/>
);
const tabData = [
{
label: 'Application overview',
component: (
<ApplicationView <ApplicationView
strategies={strategies} strategies={strategies}
instances={instances} instances={instances}
@ -62,60 +87,74 @@ class ClientApplications extends PureComponent {
hasPermission={hasPermission} hasPermission={hasPermission}
formatFullDateTime={this.formatFullDateTime} formatFullDateTime={this.formatFullDateTime}
/> />
) : ( ),
},
{
label: 'Edit application',
component: (
<ApplicationUpdate application={application} storeApplicationMetaData={storeApplicationMetaData} /> <ApplicationUpdate application={application} storeApplicationMetaData={storeApplicationMetaData} />
); ),
},
];
return ( return (
<Card shadow={0} className={commonStyles.fullwidth}> <PageContent
<CardTitle style={{ paddingTop: '24px', paddingRight: '64px', wordBreak: 'break-all' }}> headerContent={
<Icon name={icon || 'apps'} /> <HeaderTitle
&nbsp;{appName} title={
</CardTitle> <span
<CardText style={{ paddingTop: '0' }}>
<p>{description || ''}</p>
<p>
Created: <strong>{this.formatDate(createdAt)}</strong>
</p>
</CardText>
{url && (
<CardMenu>
<IconLink url={url} icon="link" />
</CardMenu>
)}
{hasPermission(UPDATE_APPLICATION) ? (
<div>
<CardActions
border
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
}} }}
> >
<span /> <Avatar style={{ marginRight: '8px' }}>
<Button accent title="Delete application" onClick={this.deleteApplication}> <Icon>{icon || 'apps'}</Icon>
</Avatar>
{appName}
</span>
}
actions={
<>
<ConditionallyRender
condition={url}
show={
<IconButton component={Link} href={url}>
<Icon>link</Icon>
</IconButton>
}
/>
<ConditionallyRender
condition={hasPermission(UPDATE_APPLICATION)}
show={
<Button color="secondary" title="Delete application" onClick={toggleModal}>
Delete Delete
</Button> </Button>
</CardActions> }
<hr /> />
<Tabs </>
activeTab={this.state.activeTab} }
onChange={tabId => this.setState({ activeTab: tabId })} />
ripple }
tabBarProps={{ style: { width: '100%' } }}
className="mdl-color--grey-100"
> >
<Tab>Details</Tab> <div>
<Tab>Edit</Tab> <Typography variant="body1">{description || ''}</Typography>
</Tabs> <Typography variant="body2">
Created: <strong>{this.formatDate(createdAt)}</strong>
</Typography>
</div> </div>
) : ( <ConditionallyRender
'' condition={hasPermission(UPDATE_APPLICATION)}
)} show={
<div>
{renderModal()}
{content} <TabNav tabData={tabData} />
</Card> </div>
}
/>
</PageContent>
); );
} }
} }

View File

@ -16,10 +16,10 @@ const mapStateToProps = (state, props) => {
}; };
}; };
const Constainer = connect(mapStateToProps, { const Container = connect(mapStateToProps, {
fetchApplication, fetchApplication,
storeApplicationMetaData, storeApplicationMetaData,
deleteApplication, deleteApplication,
})(ApplicationEdit); })(ApplicationEdit);
export default Constainer; export default Container;

View File

@ -1,13 +1,15 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ProgressBar, Card, CardText, Icon } from 'react-mdl'; import { Icon, CircularProgress } from '@material-ui/core';
import { AppsLinkList, styles as commonStyles } from '../common'; import { AppsLinkList, styles as commonStyles } from '../common';
import SearchField from '../common/search-field'; import SearchField from '../common/SearchField/SearchField';
import PageContent from '../common/PageContent/PageContent';
import HeaderTitle from '../common/HeaderTitle';
const Empty = () => ( const Empty = () => (
<React.Fragment> <React.Fragment>
<CardText style={{ textAlign: 'center' }}> <section style={{ textAlign: 'center' }}>
<Icon name="warning" style={{ fontSize: '5em' }} /> <br /> <Icon>warning</Icon> <br />
<br /> <br />
Oh snap, it does not seem like you have connected any applications. To connect your application to Unleash Oh snap, it does not seem like you have connected any applications. To connect your application to Unleash
you will require a Client SDK. you will require a Client SDK.
@ -15,7 +17,7 @@ const Empty = () => (
<br /> <br />
You can read more about how to use Unleash in your application in the{' '} You can read more about how to use Unleash in your application in the{' '}
<a href="https://www.unleash-hosted.com/docs/use-feature-toggle">documentation.</a> <a href="https://www.unleash-hosted.com/docs/use-feature-toggle">documentation.</a>
</CardText> </section>
</React.Fragment> </React.Fragment>
); );
@ -35,20 +37,22 @@ class ClientStrategies extends Component {
const { applications } = this.props; const { applications } = this.props;
if (!applications) { if (!applications) {
return <ProgressBar indeterminate />; return <CircularProgress variant="indeterminate" />;
} }
return ( return (
<div> <>
<div className={commonStyles.toolbar}> <div className={commonStyles.searchField}>
<SearchField <SearchField
value={this.props.settings.filter} value={this.props.settings.filter}
updateValue={this.props.updateSetting.bind(this, 'filter')} updateValue={this.props.updateSetting.bind(this, 'filter')}
/> />
</div> </div>
<Card shadow={0} className={commonStyles.fullwidth}> <PageContent headerContent={<HeaderTitle title="Applications" />}>
<div className={commonStyles.fullwidth}>
{applications.length > 0 ? <AppsLinkList apps={applications} /> : <Empty />} {applications.length > 0 ? <AppsLinkList apps={applications} /> : <Empty />}
</Card>
</div> </div>
</PageContent>
</>
); );
} }
} }

View File

@ -1,39 +1,51 @@
import React from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Grid, Cell } from 'react-mdl'; import { TextField, Grid } from '@material-ui/core';
import StatefulTextfield from './stateful-textfield'; import { useCommonStyles } from '../../common.styles';
import icons from './icon-names'; import icons from './icon-names';
import MySelect from '../common/select'; import MySelect from '../common/select';
function ApplicationUpdate({ application, storeApplicationMetaData }) { function ApplicationUpdate({ application, storeApplicationMetaData }) {
const { appName, icon, url, description } = application; const { appName, icon, url, description } = application;
const [localUrl, setLocalUrl] = useState(url);
const [localDescription, setLocalDescription] = useState(description);
const commonStyles = useCommonStyles();
return ( return (
<Grid> <Grid container style={{ marginTop: '1rem' }}>
<Cell col={12}> <Grid item sm={12} xs={12} className={commonStyles.contentSpacingY}>
<Grid item>
<MySelect <MySelect
label="Icon" label="Icon"
options={icons.map(v => ({ key: v, label: v }))} options={icons.map(v => ({ key: v, label: v }))}
value={icon || 'apps'} value={icon || 'apps'}
onChange={e => storeApplicationMetaData(appName, 'icon', e.target.value)} onChange={e => storeApplicationMetaData(appName, 'icon', e.target.value)}
filled
/> />
<StatefulTextfield </Grid>
value={url} <Grid item>
<TextField
value={localUrl}
onChange={e => setLocalUrl(e.target.value)}
label="Application URL" label="Application URL"
placeholder="https://example.com" placeholder="https://example.com"
type="url" type="url"
onBlur={e => storeApplicationMetaData(appName, 'url', e.target.value)} variant="outlined"
size="small"
onBlur={() => storeApplicationMetaData(appName, 'url', localUrl)}
/> />
</Grid>
<br /> <Grid item>
<StatefulTextfield <TextField
value={description} value={localDescription}
label="Description" label="Description"
variant="outlined"
size="small"
rows={2} rows={2}
onBlur={e => storeApplicationMetaData(appName, 'description', e.target.value)} onChange={e => setLocalDescription(e.target.value)}
onBlur={() => storeApplicationMetaData(appName, 'description', localDescription)}
/> />
</Cell> </Grid>
</Grid>
</Grid> </Grid>
); );
} }

View File

@ -1,99 +1,151 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Grid, Cell, List, ListItem, ListItemContent, Switch } from 'react-mdl'; import { Grid, List, ListItem, ListItemText, ListItemAvatar, Switch, Icon, Typography } from '@material-ui/core';
import { shorten } from '../common'; import { shorten } from '../common';
import { CREATE_FEATURE, CREATE_STRATEGY } from '../../permissions'; import { CREATE_FEATURE, CREATE_STRATEGY } from '../../permissions';
import ConditionallyRender from '../common/ConditionallyRender/ConditionallyRender';
function ApplicationView({ seenToggles, hasPermission, strategies, instances, formatFullDateTime }) { function ApplicationView({ seenToggles, hasPermission, strategies, instances, formatFullDateTime }) {
return ( const notFoundListItem = ({ createUrl, name, permission }) => (
<Grid style={{ margin: 0 }}> <ConditionallyRender
<Cell col={6} tablet={4} phone={12} hidePhone> key={`not_found_conditional_${name}`}
<h6> Toggles</h6> condition={hasPermission(permission)}
<hr /> show={
<List> <ListItem key={`not_found_${name}`}>
{seenToggles.map(({ name, description, enabled, notFound }, i) => <ListItemAvatar>
notFound ? ( <Icon>report</Icon>
<ListItem twoLine key={i}> </ListItemAvatar>
{hasPermission(CREATE_FEATURE) ? ( <ListItemText
<ListItemContent icon={'report'} subtitle={'Missing, want to create?'}> primary={<Link to={`${createUrl}?name=${name}`}>{name}</Link>}
<Link to={`/features/create?name=${name}`}>{name}</Link> secondary={'Missing, want to create?'}
</ListItemContent> />
) : (
<ListItemContent icon={'report'} subtitle={'Missing'}>
{name}
</ListItemContent>
)}
</ListItem> </ListItem>
) : (
<ListItem twoLine key={i}>
<ListItemContent
icon={
<span>
<Switch disabled checked={!!enabled} />
</span>
} }
subtitle={shorten(description, 60)} elseShow={
> <ListItem key={`not_found_${name}`}>
<Link to={`/features/view/${name}`}>{shorten(name, 50)}</Link> <ListItemAvatar>
</ListItemContent> <Icon>report</Icon>
</ListItemAvatar>
<ListItemText primary={name} secondary={`Could not find feature toggle with name ${name}`} />
</ListItem> </ListItem>
) }
)} />
</List> );
</Cell>
<Cell col={6} tablet={4} phone={12}> // eslint-disable-next-line react/prop-types
<h6>Implemented strategies</h6> const foundListItem = ({ viewUrl, name, showSwitch, enabled, description, i }) => (
<ListItem key={`found_${name}-${i}`}>
<ListItemAvatar>
<ConditionallyRender
key={`conditional_avatar_${name}`}
condition={showSwitch}
show={<Switch disabled value={!!enabled} />}
elseShow={<Icon>extension</Icon>}
/>
</ListItemAvatar>
<ListItemText
primary={<Link to={`${viewUrl}/${name}`}>{shorten(name, 50)}</Link>}
secondary={shorten(description, 60)}
/>
</ListItem>
);
return (
<Grid container style={{ margin: 0 }}>
<Grid item xl={6} md={6} xs={12}>
<Typography variant="subtitle1" style={{ padding: '1rem 0' }}>
Toggles
</Typography>
<hr /> <hr />
<List> <List>
{strategies.map(({ name, description, notFound }, i) => {seenToggles.map(({ name, description, enabled, notFound }, i) => (
notFound ? ( <ConditionallyRender
<ListItem twoLine key={`${name}-${i}`}> key={`toggle_conditional_${name}`}
{hasPermission(CREATE_STRATEGY) ? ( condition={notFound}
<ListItemContent icon={'report'} subtitle={'Missing, want to create?'}> show={notFoundListItem({
<Link to={`/strategies/create?name=${name}`}>{name}</Link> createUrl: '/features/create',
</ListItemContent> name,
) : ( permission: CREATE_FEATURE,
<ListItemContent icon={'report'} subtitle={'Missing'}> i,
{name} })}
</ListItemContent> elseShow={foundListItem({
)} viewUrl: '/features/strategies',
</ListItem> name,
) : ( showSwitch: true,
<ListItem twoLine key={`${name}-${i}`}> enabled,
<ListItemContent icon={'extension'} subtitle={shorten(description, 60)}> description,
<Link to={`/strategies/view/${name}`}>{shorten(name, 50)}</Link> i,
</ListItemContent> })}
</ListItem> />
) ))}
)}
</List> </List>
</Cell> </Grid>
<Cell col={12} tablet={12}> <Grid item xl={6} md={6} xs={12}>
<h6>{instances.length} Instances registered</h6> <Typography variant="subtitle1" style={{ padding: '1rem 0' }}>
Implemented strategies
</Typography>
<hr /> <hr />
<List> <List>
{instances.map(({ instanceId, clientIp, lastSeen, sdkVersion }, i) => ( {strategies.map(({ name, description, notFound }, i) => (
<ListItem key={i} twoLine> <ConditionallyRender
<ListItemContent key={`strategies_conditional_${name}`}
icon="timeline" condition={notFound}
subtitle={ show={notFoundListItem({
createUrl: '/strategies/create',
name,
permission: CREATE_STRATEGY,
i,
})}
elseShow={foundListItem({
viewUrl: '/strategies/view',
name,
showSwitch: false,
enabled: undefined,
description,
i,
})}
/>
))}
</List>
</Grid>
<Grid item xl={12} md={12}>
<Typography variant="subtitle1" style={{ padding: '1rem 0' }}>
{instances.length} Instances registered
</Typography>
<hr />
<List>
{instances.map(({ instanceId, clientIp, lastSeen, sdkVersion }) => (
<ListItem key={`${instanceId}`}>
<ListItemAvatar>
<Icon>timeline</Icon>
</ListItemAvatar>
<ListItemText
primary={
<ConditionallyRender
key={`${instanceId}_conditional`}
condition={sdkVersion}
show={`${instanceId} (${sdkVersion})`}
elseShow={instanceId}
/>
}
secondary={
<span> <span>
{clientIp} last seen at <small>{formatFullDateTime(lastSeen)}</small> {clientIp} last seen at <small>{formatFullDateTime(lastSeen)}</small>
</span> </span>
} }
> />
{instanceId} {sdkVersion ? `(${sdkVersion})` : ''}
</ListItemContent>
</ListItem> </ListItem>
))} ))}
</List> </List>
</Cell> </Grid>
</Grid> </Grid>
); );
} }
ApplicationView.propTypes = { ApplicationView.propTypes = {
createUrl: PropTypes.string,
name: PropTypes.string,
permission: PropTypes.string,
instances: PropTypes.array.isRequired, instances: PropTypes.array.isRequired,
seenToggles: PropTypes.array.isRequired, seenToggles: PropTypes.array.isRequired,
strategies: PropTypes.array.isRequired, strategies: PropTypes.array.isRequired,

View File

@ -1,35 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Textfield } from 'react-mdl';
function StatefulTextfield({ value, label, placeholder, rows, onBlur }) {
const [localValue, setLocalValue] = useState(value);
const onChange = e => {
e.preventDefault();
setLocalValue(e.target.value);
};
return (
<Textfield
style={{ width: '100%' }}
label={label}
placeholder={placeholder}
floatingLabel
rows={rows}
value={localValue}
onChange={onChange}
onBlur={onBlur}
/>
);
}
StatefulTextfield.propTypes = {
value: PropTypes.string,
label: PropTypes.string,
placeholder: PropTypes.string,
rows: PropTypes.number,
onBlur: PropTypes.func.isRequired,
};
export default StatefulTextfield;

View File

@ -1,12 +1,12 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import FeatureListComponent from './../feature/list/list-component'; import FeatureListComponent from '../feature/FeatureToggleList/FeatureToggleList';
import { fetchArchive, revive } from './../../store/archive/actions'; import { fetchArchive, revive } from './../../store/archive/actions';
import { updateSettingForGroup } from './../../store/settings/actions'; import { updateSettingForGroup } from './../../store/settings/actions';
import { mapStateToPropsConfigurable } from '../feature/list/list-container'; import { mapStateToPropsConfigurable } from '../feature/FeatureToggleList';
const mapStateToProps = mapStateToPropsConfigurable(false); const mapStateToProps = mapStateToPropsConfigurable(false);
const mapDispatchToProps = { const mapDispatchToProps = {
fetchArchive, fetcher: () => fetchArchive(),
revive, revive,
updateSetting: updateSettingForGroup('feature'), updateSetting: updateSettingForGroup('feature'),
}; };

View File

@ -1,6 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchArchive, revive } from './../../store/archive/actions'; import { fetchArchive, revive } from './../../store/archive/actions';
import ViewToggleComponent from './../feature/view/view-component'; import ViewToggleComponent from '../feature/FeatureView/FeatureView';
import { hasPermission } from '../../permissions'; import { hasPermission } from '../../permissions';
import { fetchTags } from '../../store/feature-tags/actions'; import { fetchTags } from '../../store/feature-tags/actions';

View File

@ -0,0 +1,3 @@
import ConditionallyRender from './ConditionallyRender';
export default ConditionallyRender;

View File

@ -0,0 +1,37 @@
import React from 'react';
import { Dialog, DialogTitle, DialogActions, DialogContent, Button } from '@material-ui/core';
import PropTypes from 'prop-types';
import ConditionallyRender from '../ConditionallyRender/ConditionallyRender';
const ConfirmDialogue = ({ children, open, onClick, onClose, title, primaryButtonText, secondaryButtonText }) => (
<Dialog
open={open}
onClose={onClose}
aria-labelledby={'simple-modal-title'}
aria-describedby={'simple-modal-description'}
>
<DialogTitle>{title}</DialogTitle>
<ConditionallyRender condition={children} show={<DialogContent>{children}</DialogContent>} />
<DialogActions>
<Button color="primary" onClick={onClick} autoFocus>
{primaryButtonText || "Yes, I'm sure"}
</Button>
<Button onClick={onClose}>{secondaryButtonText || 'No take me back.'} </Button>
</DialogActions>
</Dialog>
);
ConfirmDialogue.propTypes = {
primaryButtonText: PropTypes.string,
secondaryButtonText: PropTypes.string,
children: PropTypes.object,
open: PropTypes.bool,
onClick: PropTypes.func,
onClose: PropTypes.func,
ariaLabel: PropTypes.string,
ariaDescription: PropTypes.string,
title: PropTypes.string,
};
export default ConfirmDialogue;

View File

@ -0,0 +1,3 @@
import Dialogue from './Dialogue';
export default Dialogue;

View File

@ -0,0 +1,36 @@
import React from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Typography } from '@material-ui/core';
import ConditionallyRender from '../ConditionallyRender/ConditionallyRender';
import { useStyles } from './styles';
const HeaderTitle = ({ title, actions, subtitle, variant, loading }) => {
const styles = useStyles();
const headerClasses = classnames({ skeleton: loading });
return (
<div className={styles.headerTitleContainer}>
<div className={headerClasses}>
<Typography variant={variant || 'h2'} className={styles.headerTitle}>
{title}
</Typography>
{subtitle && <small>{subtitle}</small>}
</div>
<ConditionallyRender condition={actions} show={<div className={styles.headerActions}>{actions}</div>} />
</div>
);
};
export default HeaderTitle;
HeaderTitle.propTypes = {
title: PropTypes.oneOfType([PropTypes.element, PropTypes.string]),
subtitle: PropTypes.string,
variant: PropTypes.string,
loading: PropTypes.bool,
actions: PropTypes.element,
};

View File

@ -0,0 +1,3 @@
import HeaderTitle from './HeaderTitle';
export default HeaderTitle;

View File

@ -0,0 +1,18 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
headerTitleContainer: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
position: 'relative',
},
headerTitle: {
fontSize: theme.fontSizes.mainHeader,
fontWeight: 500,
},
headerActions: {
position: 'absolute',
right: 0,
},
}));

View File

@ -0,0 +1,51 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import HeaderTitle from '../HeaderTitle';
import { Paper } from '@material-ui/core';
import { useStyles } from './styles';
const PageContent = ({ children, headerContent, disablePadding, disableBorder, ...rest }) => {
const styles = useStyles();
const headerClasses = classnames(styles.headerContainer, {
[styles.paddingDisabled]: disablePadding,
[styles.borderDisabled]: disableBorder,
});
const bodyClasses = classnames(styles.bodyContainer, {
[styles.paddingDisabled]: disablePadding,
[styles.borderDisabled]: disableBorder,
});
let header = null;
if (headerContent) {
if (typeof headerContent === 'string') {
header = (
<div className={headerClasses}>
<HeaderTitle title={headerContent} />
</div>
);
} else {
header = <div className={headerClasses}>{headerContent}</div>;
}
}
const paperProps = disableBorder ? { elevation: 0 } : {};
return (
<Paper {...rest} {...paperProps}>
{header}
<div className={bodyClasses}>{children}</div>
</Paper>
);
};
export default PageContent;
PageContent.propTypes = {
headerContent: PropTypes.oneOfType([PropTypes.element, PropTypes.string]),
disablePadding: PropTypes.bool,
disableBorder: PropTypes.bool,
};

View File

@ -0,0 +1,3 @@
import PageContent from './PageContent';
export default PageContent;

View File

@ -0,0 +1,23 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
headerContainer: {
padding: theme.padding.pageContent.header,
borderBottom: theme.borders.default,
[theme.breakpoints.down('sm')]: {
padding: '1.5rem 1rem',
},
},
bodyContainer: {
padding: theme.padding.pageContent.body,
[theme.breakpoints.down('sm')]: {
padding: '1rem',
},
},
paddingDisabled: {
padding: '0',
},
borderDisabled: {
border: 'none',
},
}));

View File

@ -0,0 +1,65 @@
import React from 'react';
import { MenuItem } from '@material-ui/core';
import PropTypes from 'prop-types';
import DropdownMenu from '../dropdown-menu';
const ALL_PROJECTS = { id: '*', name: '> All projects' };
const ProjectSelect = ({ projects, currentProjectId, updateCurrentProject }) => {
const setProject = v => {
const id = typeof v === 'string' ? v.trim() : '';
updateCurrentProject(id);
};
if (!projects || projects.length === 1) {
return null;
}
// TODO fixme
let curentProject = projects.find(i => i.id === currentProjectId);
if (!curentProject) {
curentProject = ALL_PROJECTS;
}
const handleChangeProject = e => {
const target = e.target.getAttribute('data-target');
setProject(target);
};
const renderProjectItem = (selectedId, item) => (
<MenuItem disabled={selectedId === item.id} data-target={item.id} key={item.id}>
{item.name}
</MenuItem>
);
const renderProjectOptions = () => {
const start = [
<MenuItem disabled={curentProject === ALL_PROJECTS} data-target={ALL_PROJECTS.id}>
{ALL_PROJECTS.name}
</MenuItem>,
];
return [...start, ...projects.map(p => renderProjectItem(currentProjectId, p))];
};
return (
<React.Fragment>
<DropdownMenu
id={'project'}
title="Select project"
label={`${curentProject.name}`}
callback={handleChangeProject}
renderOptions={renderProjectOptions}
/>
</React.Fragment>
);
};
ProjectSelect.propTypes = {
projects: PropTypes.array.isRequired,
fetchProjects: PropTypes.func.isRequired,
currentProjectId: PropTypes.string.isRequired,
updateCurrentProject: PropTypes.func.isRequired,
};
export default ProjectSelect;

View File

@ -1,13 +1,11 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Component from './project-component'; import ProjectSelect from './ProjectSelect';
import { fetchProjects } from './../../../store/project/actions'; import { fetchProjects } from '../../../store/project/actions';
import { P } from '../../common/flags';
const mapStateToProps = (state, ownProps) => ({ const mapStateToProps = (state, ownProps) => ({
enabled: !!state.uiConfig.toJS().flags[P],
projects: state.projects.toJS(), projects: state.projects.toJS(),
currentProjectId: ownProps.settings.currentProjectId || '*', currentProjectId: ownProps.settings.currentProjectId || '*',
updateCurrentProject: id => ownProps.updateSetting('currentProjectId', id), updateCurrentProject: id => ownProps.updateSetting('currentProjectId', id),
}); });
export default connect(mapStateToProps, { fetchProjects })(Component); export default connect(mapStateToProps, { fetchProjects })(ProjectSelect);

View File

@ -0,0 +1,59 @@
import React, { useState } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { debounce } from 'debounce';
import { InputBase } from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search';
import { useStyles } from './styles';
function SearchField({ value = '', updateValue, className }) {
const styles = useStyles();
const [localValue, setLocalValue] = useState(value);
const debounceUpdateValue = debounce(updateValue, 500);
const handleCange = e => {
e.preventDefault();
const v = e.target.value || '';
setLocalValue(v);
debounceUpdateValue(v);
};
const handleKeyPress = e => {
if (e.key === 'Enter') {
updateValue(localValue);
}
};
const updateNow = () => {
updateValue(localValue);
};
return (
<div>
<div className={classnames(styles.search, className)}>
<SearchIcon className={styles.searchIcon} />
<InputBase
placeholder="Search…"
classes={{
root: styles.inputRoot,
input: styles.input,
}}
inputProps={{ 'aria-label': 'search' }}
value={localValue}
onChange={handleCange}
onBlur={updateNow}
onKeyPress={handleKeyPress}
/>
</div>
</div>
);
}
SearchField.propTypes = {
value: PropTypes.string,
updateValue: PropTypes.func.isRequired,
};
export default SearchField;

View File

@ -0,0 +1,3 @@
import SearchField from './SearchField';
export default SearchField;

View File

@ -0,0 +1,24 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
search: {
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.searchField.main,
borderRadius: theme.borders.radius.main,
padding: '0.25rem 0.5rem',
maxWidth: '450px',
[theme.breakpoints.down('sm')]: {
margin: '0 auto',
},
[theme.breakpoints.down('xs')]: {
width: '100%',
},
},
searchIcon: {
marginRight: '8px',
},
inputRoot: {
width: '100%',
},
}));

View File

@ -0,0 +1,63 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Tabs, Tab, Paper } from '@material-ui/core';
import TabPanel from './TabPanel';
import { useStyles } from './styles';
import { useHistory } from 'react-router-dom';
const a11yProps = index => ({
id: `tab-${index}`,
'aria-controls': `tabpanel-${index}`,
});
const TabNav = ({ tabData, className, startingTab = 0 }) => {
const styles = useStyles();
const [activeTab, setActiveTab] = useState(startingTab);
const history = useHistory();
const renderTabs = () =>
tabData.map((tab, index) => (
<Tab
key={`${tab.label}_${index}`}
label={tab.label}
{...a11yProps(index)}
onClick={() => history.push(tab.path)}
/>
));
const renderTabPanels = () =>
tabData.map((tab, index) => (
<TabPanel key={`tab_panel_${index}`} value={activeTab} index={index}>
{tab.component}
</TabPanel>
));
return (
<>
<Paper className={styles.tabNav}>
<Tabs
value={activeTab}
onChange={(_, tabId) => {
setActiveTab(tabId);
}}
indicatorColor="primary"
textColor="primary"
centered
>
{renderTabs()}
</Tabs>
</Paper>
<div className={className}>{renderTabPanels()}</div>
</>
);
};
TabNav.propTypes = {
tabData: PropTypes.array.isRequired,
className: PropTypes.string,
startingTab: PropTypes.number,
};
export default TabNav;

View File

@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
const TabPanel = ({ children, value, index, ...other }) => (
<div
role="tabpanel"
hidden={value !== index}
id={`wrapped-tabpanel-${index}`}
aria-labelledby={`wrapped-tab-${index}`}
{...other}
>
{value === index && children}
</div>
);
TabPanel.propTypes = {
value: PropTypes.number,
index: PropTypes.number,
children: PropTypes.object,
};
export default TabPanel;

View File

@ -0,0 +1,3 @@
import TabPanel from './TabPanel';
export default TabPanel;

View File

@ -0,0 +1,3 @@
import TabNav from './TabNav';
export default TabNav;

View File

@ -0,0 +1,7 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
tabNav: {
backgroundColor: theme.palette.tabs.main,
},
}));

View File

@ -1,3 +1,5 @@
/** Select **/
.truncate { .truncate {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -42,21 +44,15 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
height: 64px;
.title {
padding: 20px 16px 20px 24px;
}
.titleText { .titleText {
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
line-height: 24px line-height: 24px;
} }
.actions { .actions {
flex-shrink: 0; flex-shrink: 0;
padding: 20px 14px 20px 16px;
} }
} }
@ -107,3 +103,29 @@
.error { .error {
color: #d50000; color: #d50000;
} }
.headerTitle {
font-size: var(--h1-size);
margin: 0;
}
.listItem {
padding: 0;
}
.section {
padding: 8px 16px 8px 16px;
}
.contentPadding {
padding: var(--card-padding);
}
.contentSpacing > * {
margin: 0.5rem 0;
}
.searchField {
margin-bottom: 2rem;
max-width: 400px;
}

View File

@ -0,0 +1,57 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Menu } from '@material-ui/core';
import { DropdownButton } from '.';
import styles from './common.module.scss';
const DropdownMenu = ({ renderOptions, id, title, callback, icon = 'arrow_drop_down', label, startIcon, ...rest }) => {
const [anchor, setAnchor] = React.useState(null);
const handleOpen = e => setAnchor(e.currentTarget);
const handleClose = e => {
if (callback && typeof callback === 'function') {
callback(e);
}
setAnchor(null);
};
return (
<>
<DropdownButton
id={id}
label={label}
title={title}
startIcon={startIcon}
onClick={handleOpen}
aria-controls={id}
aria-haspopup="true"
icon={icon}
{...rest}
/>
<Menu
id={id}
className={styles.dropdownMenu}
onClick={handleClose}
anchorEl={anchor}
open={Boolean(anchor)}
>
{renderOptions()}
</Menu>
</>
);
};
DropdownMenu.propTypes = {
renderOptions: PropTypes.func,
id: PropTypes.string,
title: PropTypes.string,
callback: PropTypes.func,
icon: PropTypes.string,
label: PropTypes.string,
startIcon: PropTypes.string,
};
export default DropdownMenu;

View File

@ -1,62 +1,65 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { List, ListItem, ListItemContent, Button, Icon, Switch, MenuItem } from 'react-mdl'; import {
List,
MenuItem,
Icon,
ListItem,
ListItemText,
ListItemAvatar,
Button,
Avatar,
Typography,
} from '@material-ui/core';
import styles from './common.module.scss'; import styles from './common.module.scss';
import ConditionallyRender from './ConditionallyRender/ConditionallyRender';
export { styles }; export { styles };
export const shorten = (str, len = 50) => (str && str.length > len ? `${str.substring(0, len)}...` : str); export const shorten = (str, len = 50) => (str && str.length > len ? `${str.substring(0, len)}...` : str);
export const AppsLinkList = ({ apps }) => ( export const AppsLinkList = ({ apps }) => (
<List> <List>
{apps.length > 0 && <ConditionallyRender
apps.map(({ appName, description, icon }) => ( condition={apps.length > 0}
<ListItem twoLine key={appName}> show={apps.map(({ appName, description, icon }) => (
<span className="mdl-list__item-primary-content" style={{ minWidth: 0 }}> <ListItem key={appName} className={styles.listItem}>
<Icon name={icon || 'apps'} className="mdl-list__item-avatar" /> <ListItemAvatar>
<Link to={`/applications/${appName}`} className={[styles.listLink, styles.truncate].join(' ')}> <Avatar>
<ConditionallyRender
key={`avatar_conditional_${appName}`}
condition={icon}
show={<Icon>{icon}</Icon>}
elseShow={<Icon>apps</Icon>}
/>
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Link
to={`/applications/${appName}`}
className={[styles.listLink, styles.truncate].join(' ')}
>
{appName} {appName}
<span className={['mdl-list__item-sub-title', styles.truncate].join(' ')}>
{description || 'No description'}
</span>
</Link> </Link>
</span> }
secondary={description || 'No description'}
/>
</ListItem> </ListItem>
))} ))}
/>
</List> </List>
); );
AppsLinkList.propTypes = { AppsLinkList.propTypes = {
apps: PropTypes.array.isRequired, apps: PropTypes.array.isRequired,
}; };
export const HeaderTitle = ({ title, actions, subtitle }) => (
<div
style={{
display: 'flex',
borderBottom: '1px solid #f9f9f9',
marginBottom: '10px',
padding: '16px',
}}
>
<div style={{ flex: '2' }}>
<h6 style={{ margin: 0 }}>{title}</h6>
{subtitle && <small>{subtitle}</small>}
</div>
{actions && <div style={{ flex: '1', textAlign: 'right' }}>{actions}</div>}
</div>
);
HeaderTitle.propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
actions: PropTypes.any,
};
export const DataTableHeader = ({ title, actions }) => ( export const DataTableHeader = ({ title, actions }) => (
<div className={styles.dataTableHeader}> <div className={styles.dataTableHeader}>
<div className={styles.title}> <div className={styles.title}>
<h2 className={styles.titleText}>{title}</h2> <Typography variant="h2" className={styles.titleText}>
{title}
</Typography>
</div> </div>
{actions && <div className={styles.actions}>{actions}</div>} {actions && <div className={styles.actions}>{actions}</div>}
</div> </div>
@ -66,11 +69,15 @@ DataTableHeader.propTypes = {
actions: PropTypes.any, actions: PropTypes.any,
}; };
export const FormButtons = ({ submitText = 'Create', onCancel }) => ( export const FormButtons = ({ submitText = 'Create', onCancel, primaryButtonTestId }) => (
<div> <div>
<Button type="submit" ripple raised primary icon="add"> <Button
<Icon name="add" /> data-test={primaryButtonTestId}
&nbsp;&nbsp;&nbsp; type="submit"
color="primary"
variant="contained"
startIcon={<Icon>add</Icon>}
>
{submitText} {submitText}
</Button> </Button>
&nbsp; &nbsp;
@ -82,37 +89,7 @@ export const FormButtons = ({ submitText = 'Create', onCancel }) => (
FormButtons.propTypes = { FormButtons.propTypes = {
submitText: PropTypes.string, submitText: PropTypes.string,
onCancel: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired,
}; primaryButtonTestId: PropTypes.string,
export const SwitchWithLabel = ({ onChange, checked, children, ...switchProps }) => (
<span className={styles.switchWithLabel}>
<span className={styles.label}>{children}</span>
<span className={styles.switch}>
<Switch checked={checked} onChange={onChange} {...switchProps} />
</span>
</span>
);
SwitchWithLabel.propTypes = {
checked: PropTypes.bool,
onChange: PropTypes.func,
};
export const TogglesLinkList = ({ toggles }) => (
<List style={{ textAlign: 'left' }} className={styles.truncate}>
{toggles.length > 0 &&
toggles.map(({ name, description = '-', icon = 'toggle' }) => (
<ListItem twoLine key={name}>
<ListItemContent avatar={icon} subtitle={description}>
<Link key={name} to={`/features/view/${name}`}>
{name}
</Link>
</ListItemContent>
</ListItem>
))}
</List>
);
TogglesLinkList.propTypes = {
toggles: PropTypes.array,
}; };
export function getIcon(type) { export function getIcon(type) {
@ -132,7 +109,7 @@ export function getIcon(type) {
export const IconLink = ({ url, icon }) => ( export const IconLink = ({ url, icon }) => (
<a href={url} target="_blank" rel="noopener" className="mdl-color-text--grey-600"> <a href={url} target="_blank" rel="noopener" className="mdl-color-text--grey-600">
<Icon name={icon} /> <Icon>{icon}</Icon>
</a> </a>
); );
IconLink.propTypes = { IconLink.propTypes = {
@ -140,22 +117,41 @@ IconLink.propTypes = {
icon: PropTypes.string, icon: PropTypes.string,
}; };
export const DropdownButton = ({ label, id, className = styles.dropdownButton, title, style }) => ( export const DropdownButton = ({
<Button id={id} className={className} title={title} style={style}> label,
id,
className = styles.dropdownButton,
title,
icon,
startIcon,
style,
...rest
}) => (
<Button
id={id}
className={className}
title={title}
style={style}
{...rest}
startIcon={startIcon}
endIcon={<Icon>{icon}</Icon>}
>
{label} {label}
<Icon name="arrow_drop_down" className="mdl-color-text--grey-600" />
</Button> </Button>
); );
DropdownButton.propTypes = { DropdownButton.propTypes = {
label: PropTypes.string, label: PropTypes.string,
style: PropTypes.object, style: PropTypes.object,
id: PropTypes.string, id: PropTypes.string,
title: PropTypes.string, title: PropTypes.string,
icon: PropTypes.string,
startIcon: PropTypes.string,
}; };
export const MenuItemWithIcon = ({ icon, label, disabled, ...menuItemProps }) => ( export const MenuItemWithIcon = ({ icon, label, disabled, ...menuItemProps }) => (
<MenuItem disabled={disabled} style={{ display: 'flex', alignItems: 'center' }} {...menuItemProps}> <MenuItem disabled={disabled} style={{ display: 'flex', alignItems: 'center' }} {...menuItemProps}>
<Icon name={icon} style={{ paddingRight: '16px' }} /> <Icon style={{ paddingRight: '16px' }}>{icon}</Icon>
{label} {label}
</MenuItem> </MenuItem>
); );

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Textfield } from 'react-mdl'; import { TextField } from '@material-ui/core';
function InputListField({ label, values = [], error, name, updateValues, placeholder = '', onBlur = () => {} }) { function InputListField({ label, values = [], error, name, updateValues, placeholder = '', onBlur = () => {} }) {
const handleChange = evt => { const handleChange = evt => {
@ -21,10 +21,10 @@ function InputListField({ label, values = [], error, name, updateValues, placeho
}; };
return ( return (
<Textfield <TextField
name={name} name={name}
floatingLabel error={error !== undefined}
error={error} helperText={error}
placeholder={placeholder} placeholder={placeholder}
value={values ? values.join(', ') : ''} value={values ? values.join(', ') : ''}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@ -32,6 +32,8 @@ function InputListField({ label, values = [], error, name, updateValues, placeho
onBlur={onBlur} onBlur={onBlur}
label={label} label={label}
style={{ width: '100%' }} style={{ width: '100%' }}
variant="outlined"
size="small"
/> />
); );
} }

View File

@ -1,50 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { debounce } from 'debounce';
import { FABButton, Icon, Textfield } from 'react-mdl';
function SearchField({ value = '', updateValue }) {
const [localValue, setLocalValue] = useState(value);
const debounceUpdateValue = debounce(updateValue, 500);
const handleCange = e => {
e.preventDefault();
const v = e.target.value || '';
setLocalValue(v);
debounceUpdateValue(v);
};
const handleKeyPress = e => {
if (e.key === 'Enter') {
updateValue(localValue);
}
};
const updateNow = () => {
updateValue(localValue);
};
return (
<div>
<Textfield
floatingLabel
value={localValue}
onChange={handleCange}
onBlur={updateNow}
onKeyPress={handleKeyPress}
label="Search"
style={{ width: '500px', maxWidth: '80%' }}
/>
<FABButton mini className={'mdl-cell--hide-phone'}>
<Icon name="search" />
</FABButton>
</div>
);
}
SearchField.propTypes = {
value: PropTypes.string,
updateValue: PropTypes.func.isRequired,
};
export default SearchField;

View File

@ -1,50 +1,46 @@
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import { Select, FormControl, MenuItem, InputLabel } from '@material-ui/core';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const Select = ({ name, value, label, options, style, onChange, disabled = false, filled, className }) => { const SelectMenu = ({ name, value, label, options, onChange, id, disabled = false, className, ...rest }) => {
const wrapper = Object.assign({ width: 'auto' }, style); const renderSelectItems = () =>
options.map(option => (
<MenuItem key={option.key} value={option.key} title={option.title}>
{option.label}
</MenuItem>
));
return ( return (
<div <FormControl variant="outlined" size="small">
className={classnames( <InputLabel htmlFor={id} id={id}>
'mdl-textfield mdl-js-textfield mdl-textfield--floating-label is-dirty is-upgraded', {label}
className </InputLabel>
)} <Select
style={wrapper}
>
<select
className="mdl-textfield__input"
name={name} name={name}
disabled={disabled} disabled={disabled}
onChange={onChange} onChange={onChange}
className={className}
label={label}
id={id}
size="small"
value={value} value={value}
style={{ {...rest}
width: 'auto',
background: filled ? '#f5f5f5' : 'none',
}}
> >
{options.map(o => ( {renderSelectItems()}
<option key={o.key} value={o.key} title={o.title}> </Select>
{o.label} </FormControl>
</option>
))}
</select>
<label className="mdl-textfield__label" htmlFor="textfield-contextName">
{label}
</label>
</div>
); );
}; };
Select.propTypes = { SelectMenu.propTypes = {
name: PropTypes.string, name: PropTypes.string,
id: PropTypes.string,
value: PropTypes.string, value: PropTypes.string,
label: PropTypes.string, label: PropTypes.string,
options: PropTypes.array, options: PropTypes.array,
style: PropTypes.object, style: PropTypes.object,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool, disabled: PropTypes.bool,
filled: PropTypes.bool,
}; };
export default Select; export default SelectMenu;

View File

@ -0,0 +1,65 @@
.header {
padding: var(--card-header-padding);
margin-bottom: var(--card-margin-y);
word-break: break-all;
border-bottom: var(--default-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
font-size: var(--h1-size);
}
.formButtons {
padding-top: 1rem;
}
.supporting {
font-size: var(--caption-size);
max-width: 450px;
}
.container {
padding: var(--card-padding);
}
.container section {
margin: 1rem 0
}
.h6 {
margin-top: 0;
}
.alpha {
color: rgba(0,0,0,.54);
}
.inset {
background-color: rgb(250, 250, 250);
padding: var(--card-padding);
max-width: 450px;
}
.chip {
margin-right: 4px;
}
.valueField {
width: 130px;
}
.legalValueButton {
margin-left: 10px;
}
.formContainer {
margin-bottom: 1.5rem;
max-width: 350px;
}
.formContainer > *, .inset > * {
margin: 0.5rem 0;
}

View File

@ -0,0 +1,94 @@
import PropTypes from 'prop-types';
import PageContent from '../../common/PageContent/PageContent';
import HeaderTitle from '../../common/HeaderTitle';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import { CREATE_CONTEXT_FIELD, DELETE_CONTEXT_FIELD } from '../../../permissions';
import { Icon, IconButton, List, ListItem, ListItemIcon, ListItemText, Tooltip } from '@material-ui/core';
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useStyles } from './styles';
import ConfirmDialogue from '../../common/Dialogue';
const ContextList = ({ removeContextField, hasPermission, history, contextFields }) => {
const [showDelDialogue, setShowDelDialogue] = useState(false);
const [name, setName] = useState();
const styles = useStyles();
const contextList = () =>
contextFields.map(field => (
<ListItem key={field.name} classes={{ root: styles.listItem }}>
<ListItemIcon>
<Icon>album</Icon>
</ListItemIcon>
<ListItemText
primary={
<Link to={`/context/edit/${field.name}`}>
<strong>{field.name}</strong>
</Link>
}
secondary={field.description}
/>
<ConditionallyRender
condition={hasPermission(DELETE_CONTEXT_FIELD)}
show={
<Tooltip title="Delete context field">
<IconButton
aria-label="delete"
onClick={() => {
setName(field.name);
setShowDelDialogue(true);
}}
>
<Icon>delete</Icon>
</IconButton>
</Tooltip>
}
/>
</ListItem>
));
const headerButton = () => (
<ConditionallyRender
condition={hasPermission(CREATE_CONTEXT_FIELD)}
show={
<Tooltip title="Add context type">
<IconButton onClick={() => history.push('/context/create')}>
<Icon>add</Icon>
</IconButton>
</Tooltip>
}
/>
);
return (
<PageContent headerContent={<HeaderTitle actions={headerButton()} title={'Context fields'} />}>
<List>
<ConditionallyRender
condition={contextFields.length > 0}
show={contextList}
elseShow={<ListItem>No context fields defined</ListItem>}
/>
</List>
<ConfirmDialogue
open={showDelDialogue}
onClick={() => {
removeContextField({ name });
setName(undefined);
setShowDelDialogue(false);
}}
onClose={() => {
setName(undefined);
setShowDelDialogue(false);
}}
title="Really delete context field"
/>
</PageContent>
);
};
ContextList.propTypes = {
contextFields: PropTypes.array.isRequired,
removeContextField: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
hasPermission: PropTypes.func.isRequired,
};
export default ContextList;

View File

@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ContextFieldListComponent from './list-component.jsx'; import ContextList from './ContextList';
import { fetchContext, removeContextField } from './../../store/context/actions'; import { fetchContext, removeContextField } from '../../../store/context/actions';
import { hasPermission } from '../../permissions'; import { hasPermission } from '../../../permissions';
const mapStateToProps = state => { const mapStateToProps = state => {
const list = state.context.toJS(); const list = state.context.toJS();
@ -14,14 +14,11 @@ const mapStateToProps = state => {
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
removeContextField: contextField => { removeContextField: contextField => {
// eslint-disable-next-line no-alert
if (window.confirm('Are you sure you want to remove this context field?')) {
removeContextField(contextField)(dispatch); removeContextField(contextField)(dispatch);
}
}, },
fetchContext: () => fetchContext()(dispatch), fetchContext: () => fetchContext()(dispatch),
}); });
const ContextFieldListContainer = connect(mapStateToProps, mapDispatchToProps)(ContextFieldListComponent); const ContextFieldListContainer = connect(mapStateToProps, mapDispatchToProps)(ContextList);
export default ContextFieldListContainer; export default ContextFieldListContainer;

View File

@ -0,0 +1,7 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles({
listItem: {
padding: 0,
},
});

View File

@ -1,9 +1,11 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, Chip, Textfield, Card, CardTitle, CardText, CardActions, Checkbox } from 'react-mdl'; import { Button, Chip, TextField, Switch, Icon, Typography } from '@material-ui/core';
import styles from './Context.module.scss';
import classnames from 'classnames';
import { FormButtons, styles as commonStyles } from '../common'; import { FormButtons, styles as commonStyles } from '../common';
import { trim } from '../common/util'; import { trim } from '../common/util';
import PageContent from '../common/PageContent/PageContent';
const sortIgnoreCase = (a, b) => { const sortIgnoreCase = (a, b) => {
a = a.toLowerCase(); a = a.toLowerCase();
@ -99,12 +101,10 @@ class AddContextComponent extends Component {
renderLegalValue = (value, index) => ( renderLegalValue = (value, index) => (
<Chip <Chip
key={`${value}:${index}`} key={`${value}:${index}`}
className="mdl-color--blue-grey-100" className={styles.chip}
style={{ marginRight: '4px' }} onDelete={() => this.removeLegalValue(index)}
onClose={() => this.removeLegalValue(index)} label={value}
> />
{value}
</Chip>
); );
render() { render() {
@ -113,65 +113,78 @@ class AddContextComponent extends Component {
const submitText = editMode ? 'Update' : 'Create'; const submitText = editMode ? 'Update' : 'Create';
return ( return (
<Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}> <PageContent headerContent="Create context field">
<CardTitle style={{ paddingTop: '24px', paddingBottom: '0', wordBreak: 'break-all' }}> <div className={styles.supporting}>
Create context field
</CardTitle>
<CardText>
Context fields are a basic building block used in Unleash to control roll-out. They can be used Context fields are a basic building block used in Unleash to control roll-out. They can be used
together with strategy constraints as part of the activation strategy evaluation. together with strategy constraints as part of the activation strategy evaluation.
</CardText> </div>
<form onSubmit={this.onSubmit}> <form onSubmit={this.onSubmit}>
<section style={{ padding: '16px' }}> <section className={styles.formContainer}>
<Textfield <TextField
floatingLabel className={commonStyles.fullwidth}
label="Name" label="Name"
name="name" name="name"
value={contextField.name} defaultValue={contextField.name}
error={errors.name} error={errors.name}
helperText={errors.name}
disabled={editMode} disabled={editMode}
variant="outlined"
size="small"
onBlur={v => this.validateContextName(v.target.value)} onBlur={v => this.validateContextName(v.target.value)}
onChange={v => this.setValue('name', trim(v.target.value))} onChange={v => this.setValue('name', trim(v.target.value))}
/> />
<Textfield <TextField
floatingLabel className={commonStyles.fullwidth}
style={{ width: '100%' }} rowsMax={1}
rows={1}
label="Description" label="Description"
error={errors.description} error={errors.description}
value={contextField.description} helperText={errors.description}
variant="outlined"
size="small"
defaultValue={contextField.description}
onChange={v => this.setValue('description', v.target.value)} onChange={v => this.setValue('description', v.target.value)}
/> />
<br /> <br />
<br /> <br />
<section style={{ padding: '16px', background: '#fafafa' }}> </section>
<h6 style={{ marginTop: '0' }}>Legal values</h6> <section className={styles.inset}>
<p style={{ color: 'rgba(0,0,0,.54)' }}> <h6 className={styles.h6}>Legal values</h6>
By defining the legal values the Unleash Admin UI will validate the user input. A <p className={styles.alpha}>
concrete example would be that we know all values for our environment (local, By defining the legal values the Unleash Admin UI will validate the user input. A concrete
development, stage, production). example would be that we know all values for our environment (local, development, stage,
production).
</p> </p>
<Textfield <div>
floatingLabel <TextField
label="Value" label="Value"
name="value" name="value"
style={{ width: '130px' }} className={styles.valueField}
value={this.state.currentLegalValue} value={this.state.currentLegalValue}
error={errors.currentLegalValue} error={!!errors.currentLegalValue}
helperText={errors.currentLegalValue}
variant="outlined"
size="small"
onChange={this.updateCurrentLegalValue} onChange={this.updateCurrentLegalValue}
/> />
<Button onClick={this.addLegalValue} colored accent raised> <Button
className={styles.legalValueButton}
startIcon={<Icon>add</Icon>}
onClick={this.addLegalValue}
variant="contained"
color="primary"
>
Add Add
</Button> </Button>
</div>
<div>{contextField.legalValues.map(this.renderLegalValue)}</div> <div>{contextField.legalValues.map(this.renderLegalValue)}</div>
</section> </section>
<br /> <br />
<section style={{ padding: '16px' }}> <section>
<h6 style={{ marginTop: '0' }}>Custom stickiness (beta)</h6> <Typography variant="subtitle1">Custom stickiness (beta)</Typography>
<p style={{ color: 'rgba(0,0,0,.54)' }}> <p className={classnames(styles.alpha, styles.formContainer)}>
By enabling stickiness on this context field you can use it together with the By enabling stickiness on this context field you can use it together with the
flexible-rollout strategy. This will guarantee a consistent behavior for specific values flexible-rollout strategy. This will guarantee a consistent behavior for specific values of
of this context field. PS! Not all client SDK's support this feature yet!{' '} this context field. PS! Not all client SDK's support this feature yet!{' '}
<a <a
href="https://unleash.github.io/docs/activation_strategy#flexiblerollout" href="https://unleash.github.io/docs/activation_strategy#flexiblerollout"
target="_blank" target="_blank"
@ -179,19 +192,17 @@ class AddContextComponent extends Component {
Read more Read more
</a> </a>
</p> </p>
<Checkbox <Switch
label="Allow stickiness" label="Allow stickiness"
ripple value={contextField.stickiness}
checked={contextField.stickiness}
onChange={() => this.setValue('stickiness', !contextField.stickiness)} onChange={() => this.setValue('stickiness', !contextField.stickiness)}
/> />
</section> </section>
</section> <div className={styles.formButtons}>
<CardActions>
<FormButtons submitText={submitText} onCancel={this.onCancel} /> <FormButtons submitText={submitText} onCancel={this.onCancel} />
</CardActions> </div>
</form> </form>
</Card> </PageContent>
); );
} }
} }

View File

@ -1,80 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { List, ListItem, ListItemAction, ListItemContent, IconButton, Card } from 'react-mdl';
import { HeaderTitle, styles as commonStyles } from '../common';
import { CREATE_CONTEXT_FIELD, DELETE_CONTEXT_FIELD } from '../../permissions';
class ContextFieldListComponent extends Component {
static propTypes = {
contextFields: PropTypes.array.isRequired,
fetchContext: PropTypes.func.isRequired,
removeContextField: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
hasPermission: PropTypes.func.isRequired,
};
componentDidMount() {
// this.props.fetchContext();
}
removeContextField = (contextField, evt) => {
evt.preventDefault();
this.props.removeContextField(contextField);
};
render() {
const { contextFields, hasPermission } = this.props;
return (
<Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}>
<HeaderTitle
title="Context Fields"
actions={
hasPermission(CREATE_CONTEXT_FIELD) ? (
<IconButton
raised
colored
accent
name="add"
onClick={() => this.props.history.push('/context/create')}
title="Add new context field"
/>
) : (
''
)
}
/>
<List>
{contextFields.length > 0 ? (
contextFields.map((field, i) => (
<ListItem key={i} twoLine>
<ListItemContent icon="album" subtitle={field.description}>
<Link to={`/context/edit/${field.name}`}>
<strong>{field.name}</strong>
</Link>
</ListItemContent>
<ListItemAction>
{hasPermission(DELETE_CONTEXT_FIELD) ? (
<IconButton
name="delete"
title="Remove contextField"
onClick={this.removeContextField.bind(this, field)}
/>
) : (
''
)}
</ListItemAction>
</ListItem>
))
) : (
<ListItem>No context fields defined</ListItem>
)}
</List>
</Card>
);
}
}
export default ContextFieldListComponent;

View File

@ -1,16 +1,31 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Snackbar, Icon } from 'react-mdl'; import { Snackbar, Icon, IconButton } from '@material-ui/core';
const ErrorComponent = ({ errors, ...props }) => { const ErrorComponent = ({ errors, ...props }) => {
const showError = errors.length > 0; const showError = errors.length > 0;
const error = showError ? errors[0] : undefined; const error = showError ? errors[0] : undefined;
const muteError = () => props.muteError(error); const muteError = () => props.muteError(error);
return ( return (
<Snackbar action="Dismiss" active={showError} onActionClick={muteError} onTimeout={muteError} timeout={10000}> <Snackbar
<Icon name="question_answer" /> {error} action={
</Snackbar> <React.Fragment>
<IconButton size="small" aria-label="close" color="inherit" onClick={muteError}>
<Icon>close</Icon>
</IconButton>
</React.Fragment>
}
open={showError}
onClose={muteError}
autoHideDuration={10000}
message={
<div>
<Icon>question_answer</Icon>
{error}
</div>
}
/>
); );
}; };

View File

@ -0,0 +1,172 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { Link } from 'react-router-dom';
import { Button, List, Tooltip, IconButton, Icon } from '@material-ui/core';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import FeatureToggleListItem from './FeatureToggleListItem';
import SearchField from '../../common/SearchField/SearchField';
import FeatureToggleListActions from './FeatureToggleListActions';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import PageContent from '../../common/PageContent/PageContent';
import HeaderTitle from '../../common/HeaderTitle';
import loadingFeatures from './loadingFeatures';
import { CREATE_FEATURE } from '../../../permissions';
import { useStyles } from './styles';
const FeatureToggleList = ({
fetcher,
features,
hasPermission,
settings,
revive,
updateSetting,
featureMetrics,
toggleFeature,
loading,
}) => {
const styles = useStyles();
const smallScreen = useMediaQuery('(max-width:700px)');
useEffect(() => {
fetcher();
}, [fetcher]);
const toggleMetrics = () => {
updateSetting('showLastHour', !settings.showLastHour);
};
const setSort = v => {
updateSetting('sort', typeof v === 'string' ? v.trim() : '');
};
const renderFeatures = () => {
features.forEach(e => {
e.reviveName = e.name;
});
if (loading) {
return loadingFeatures.map(feature => (
<FeatureToggleListItem
key={feature.name}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
toggleFeature={toggleFeature}
revive={revive}
hasPermission={hasPermission}
className={'skeleton'}
/>
));
}
return features.map(feature => (
<FeatureToggleListItem
key={feature.name}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
toggleFeature={toggleFeature}
revive={revive}
hasPermission={hasPermission}
/>
));
};
return (
<div className={styles.featureContainer}>
<div className={styles.searchBarContainer}>
<SearchField
value={settings.filter}
updateValue={updateSetting.bind(this, 'filter')}
className={classnames(styles.searchBar, {
skeleton: loading,
})}
/>
</div>
<PageContent
headerContent={
<HeaderTitle
loading={loading}
title="Feature toggles"
actions={
<div className={styles.actionsContainer}>
<ConditionallyRender
condition={!smallScreen}
show={
<FeatureToggleListActions
settings={settings}
toggleMetrics={toggleMetrics}
setSort={setSort}
updateSetting={updateSetting}
loading={loading}
/>
}
/>
<ConditionallyRender
condition={hasPermission(CREATE_FEATURE)}
show={
<ConditionallyRender
condition={smallScreen}
show={
<Tooltip title="Create feature toggle">
<IconButton
component={Link}
to="/features/create"
data-test="add-feature-btn"
>
<Icon>add</Icon>
</IconButton>
</Tooltip>
}
elseShow={
<Button
to="/features/create"
data-test="add-feature-btn"
size="large"
color="secondary"
variant="contained"
component={Link}
className={classnames({
skeleton: loading,
})}
>
Create feature toggle
</Button>
}
/>
}
/>
</div>
}
/>
}
>
<List>{renderFeatures()}</List>
</PageContent>
</div>
);
};
FeatureToggleList.propTypes = {
features: PropTypes.array.isRequired,
featureMetrics: PropTypes.object.isRequired,
fetcher: PropTypes.func,
revive: PropTypes.func,
updateSetting: PropTypes.func.isRequired,
toggleFeature: PropTypes.func,
settings: PropTypes.object,
history: PropTypes.object.isRequired,
hasPermission: PropTypes.func.isRequired,
loading: PropTypes.bool,
};
export default FeatureToggleList;

View File

@ -0,0 +1,88 @@
import React from 'react';
import PropTypes from 'prop-types';
import { MenuItem } from '@material-ui/core';
import { MenuItemWithIcon } from '../../../common';
import DropdownMenu from '../../../common/dropdown-menu';
import ProjectSelect from '../../../common/ProjectSelect';
const sortingOptions = [
{ type: 'name', displayName: 'Name' },
{ type: 'type', displayName: 'Type' },
{ type: 'enabled', displayName: 'Enabled' },
{ type: 'stale', displayName: 'Stale' },
{ type: 'created', displayName: 'Created' },
{ type: 'Last seen', displayName: 'Last seen' },
{ type: 'strategies', displayName: 'Strategies' },
{ type: 'metrics', displayName: 'Metrics' },
];
import { useStyles } from './styles';
import classnames from 'classnames';
const FeatureToggleListActions = ({ settings, setSort, toggleMetrics, updateSetting, loading }) => {
const styles = useStyles();
const handleSort = e => {
const target = e.target.getAttribute('data-target');
setSort(target);
};
const isDisabled = type => settings.sort === type;
const renderSortingOptions = () =>
sortingOptions.map(option => (
<MenuItem key={option.type} disabled={isDisabled(option.type)} data-target={option.type}>
{option.displayName}
</MenuItem>
));
const renderMetricsOptions = () => [
<MenuItemWithIcon
icon="hourglass_empty"
disabled={!settings.showLastHour}
data-target="minute"
label="Last minute"
key={1}
/>,
<MenuItemWithIcon
icon="hourglass_full"
disabled={settings.showLastHour}
data-target="hour"
label="Last hour"
key={2}
/>,
];
return (
<div className={styles.actions}>
<DropdownMenu
id={'metric'}
label={`Last ${settings.showLastHour ? 'hour' : 'minute'}`}
title="Metric interval"
callback={toggleMetrics}
renderOptions={renderMetricsOptions}
className={classnames({ skeleton: loading })}
/>
<DropdownMenu
id={'sorting'}
label={`By ${settings.sort}`}
callback={handleSort}
renderOptions={renderSortingOptions}
title="Sort by"
className={classnames({ skeleton: loading })}
/>
<ProjectSelect settings={settings} updateSetting={updateSetting} />
</div>
);
};
FeatureToggleListActions.propTypes = {
settings: PropTypes.object,
setSort: PropTypes.func,
toggleMetrics: PropTypes.func,
updateSetting: PropTypes.func,
loading: PropTypes.bool,
};
export default FeatureToggleListActions;

View File

@ -0,0 +1,3 @@
import FeatureToggleListActions from './FeatureToggleListActions';
export default FeatureToggleListActions;

View File

@ -0,0 +1,10 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles({
actions: {
'& > *': {
margin: '0 0.25rem',
},
marginRight: '0.25rem',
},
});

View File

@ -0,0 +1,98 @@
import React, { memo } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { Link } from 'react-router-dom';
import { Switch, Icon, IconButton, ListItem } from '@material-ui/core';
import TimeAgo from 'react-timeago';
import Progress from '../../progress-component';
import Status from '../../status-component';
import FeatureToggleListItemChip from './FeatureToggleListItemChip';
import ConditionallyRender from '../../../common/ConditionallyRender/ConditionallyRender';
import { UPDATE_FEATURE } from '../../../../permissions';
import { calc, styles as commonStyles } from '../../../common';
import { useStyles } from './styles';
const FeatureToggleListItem = ({
feature,
toggleFeature,
settings,
metricsLastHour = { yes: 0, no: 0, isFallback: true },
metricsLastMinute = { yes: 0, no: 0, isFallback: true },
revive,
hasPermission,
...rest
}) => {
const styles = useStyles();
const { name, description, enabled, type, stale, createdAt } = feature;
const { showLastHour = false } = settings;
const isStale = showLastHour ? metricsLastHour.isFallback : metricsLastMinute.isFallback;
const percent =
1 *
(showLastHour
? calc(metricsLastHour.yes, metricsLastHour.yes + metricsLastHour.no, 0)
: calc(metricsLastMinute.yes, metricsLastMinute.yes + metricsLastMinute.no, 0));
const featureUrl = toggleFeature === undefined ? `/archive/strategies/${name}` : `/features/strategies/${name}`;
return (
<ListItem {...rest} className={classnames(styles.listItem, rest.className)}>
<span className={styles.listItemMetric}>
<Progress strokeWidth={15} percentage={percent} isFallback={isStale} />
</span>
<span className={styles.listItemToggle}>
<ConditionallyRender
condition={hasPermission(UPDATE_FEATURE)}
show={
<Switch
disabled={toggleFeature === undefined}
title={`Toggle ${name}`}
key="left-actions"
onChange={() => toggleFeature(!enabled, name)}
checked={enabled}
/>
}
elseShow={<Switch disabled title={`Toggle ${name}`} key="left-actions" checked={enabled} />}
/>
</span>
<span className={classnames(styles.listItemLink)}>
<Link to={featureUrl} className={classnames(commonStyles.listLink, commonStyles.truncate)}>
<span className={commonStyles.toggleName}>{name}&nbsp;</span>
<small>
<TimeAgo date={createdAt} live={false} />
</small>
<div>
<span className={commonStyles.truncate}>{description}</span>
</div>
</Link>
</span>
<span className={classnames(styles.listItemStrategies, commonStyles.hideLt920)}>
<Status stale={stale} showActive={false} />
<FeatureToggleListItemChip type={type} />
</span>
<ConditionallyRender
condition={revive && hasPermission(UPDATE_FEATURE)}
show={
<IconButton onClick={() => revive(feature.name)}>
<Icon>undo</Icon>
</IconButton>
}
elseShow={<span />}
/>
</ListItem>
);
};
FeatureToggleListItem.propTypes = {
feature: PropTypes.object,
toggleFeature: PropTypes.func,
settings: PropTypes.object,
metricsLastHour: PropTypes.object,
metricsLastMinute: PropTypes.object,
revive: PropTypes.func,
hasPermission: PropTypes.func.isRequired,
};
export default memo(FeatureToggleListItem);

View File

@ -0,0 +1,26 @@
import React, { memo } from 'react';
import { Chip } from '@material-ui/core';
import PropTypes from 'prop-types';
import { useStyles } from './styles';
const FeatureToggleListItemChip = ({ type, types, onClick }) => {
const styles = useStyles();
const typeObject = types.find(o => o.id === type) || {
id: type,
name: type,
};
return (
<Chip className={styles.typeChip} title={typeObject.description} label={typeObject.name} onClick={onClick} />
);
};
FeatureToggleListItemChip.propTypes = {
type: PropTypes.string.isRequired,
types: PropTypes.array,
onClick: PropTypes.func,
};
export default memo(FeatureToggleListItemChip);

View File

@ -1,5 +1,5 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Component from './feature-type-component'; import Component from './FeatureToggleListItemChip';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
types: state.featureTypes.toJS(), types: state.featureTypes.toJS(),

View File

@ -0,0 +1,9 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
typeChip: {
margin: '0 8px',
boxShadow: theme.boxShadows.chip.main,
backgroundColor: theme.palette.chips.main,
},
}));

View File

@ -0,0 +1,3 @@
import FeatureToggleListItem from './FeatureToggleListItem';
export default FeatureToggleListItem;

View File

@ -0,0 +1,23 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
listItem: {
padding: '0',
margin: '0.25rem 0',
},
listItemMetric: {
width: '40px',
marginRight: '1rem',
flexShrink: '0',
},
listItemSvg: {
fill: theme.palette.icons.lightGrey,
},
listItemLink: {
marginLeft: '10px',
minWidth: '0',
},
listItemStrategies: {
marginLeft: 'auto',
},
}));

View File

@ -0,0 +1,193 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly with one feature 1`] = `
<li
className="MuiListItem-root makeStyles-listItem-1 MuiListItem-gutters"
disabled={false}
>
<span
className="makeStyles-listItemMetric-2"
>
<svg
viewBox="0 0 24 24"
>
<path
d="M17.3,18C19,16.5 20,14.4 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12C4,14.4 5,16.5 6.7,18C8.2,16.7 10,16 12,16C14,16 15.9,16.7 17.3,18M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M7,9A1,1 0 0,1 8,10A1,1 0 0,1 7,11A1,1 0 0,1 6,10A1,1 0 0,1 7,9M10,6A1,1 0 0,1 11,7A1,1 0 0,1 10,8A1,1 0 0,1 9,7A1,1 0 0,1 10,6M17,9A1,1 0 0,1 18,10A1,1 0 0,1 17,11A1,1 0 0,1 16,10A1,1 0 0,1 17,9M14.4,6.1C14.9,6.3 15.1,6.9 15,7.4L13.6,10.8C13.8,11.1 14,11.5 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12C10,11 10.7,10.1 11.7,10L13.1,6.7C13.3,6.1 13.9,5.9 14.4,6.1Z"
fill="#E0E0E0"
/>
</svg>
</span>
<span>
<span
className="MuiSwitch-root"
>
<span
aria-disabled={false}
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-6 MuiSwitch-switchBase MuiSwitch-colorSecondary"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={null}
title="Toggle Another"
>
<span
className="MuiIconButton-label"
>
<input
checked={false}
className="PrivateSwitchBase-input-9 MuiSwitch-input"
disabled={false}
onChange={[Function]}
type="checkbox"
/>
<span
className="MuiSwitch-thumb"
/>
</span>
</span>
<span
className="MuiSwitch-track"
/>
</span>
</span>
<span
className="makeStyles-listItemLink-4"
>
<a
className="listLink truncate"
href="/features/strategies/Another"
onClick={[Function]}
>
<span
className="toggleName"
>
Another
 
</span>
<small>
<time
dateTime="2018-02-04T20:27:52.127Z"
title="2018-02-04T20:27:52.127Z"
>
3 years ago
</time>
</small>
<div>
<span
className="truncate"
>
another's description
</span>
</div>
</a>
</span>
<span
className="makeStyles-listItemStrategies-5 hideLt920"
/>
<span />
</li>
`;
exports[`renders correctly with one feature without permission 1`] = `
<li
className="MuiListItem-root makeStyles-listItem-1 MuiListItem-gutters"
disabled={false}
>
<span
className="makeStyles-listItemMetric-2"
>
<svg
viewBox="0 0 24 24"
>
<path
d="M17.3,18C19,16.5 20,14.4 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12C4,14.4 5,16.5 6.7,18C8.2,16.7 10,16 12,16C14,16 15.9,16.7 17.3,18M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M7,9A1,1 0 0,1 8,10A1,1 0 0,1 7,11A1,1 0 0,1 6,10A1,1 0 0,1 7,9M10,6A1,1 0 0,1 11,7A1,1 0 0,1 10,8A1,1 0 0,1 9,7A1,1 0 0,1 10,6M17,9A1,1 0 0,1 18,10A1,1 0 0,1 17,11A1,1 0 0,1 16,10A1,1 0 0,1 17,9M14.4,6.1C14.9,6.3 15.1,6.9 15,7.4L13.6,10.8C13.8,11.1 14,11.5 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12C10,11 10.7,10.1 11.7,10L13.1,6.7C13.3,6.1 13.9,5.9 14.4,6.1Z"
fill="#E0E0E0"
/>
</svg>
</span>
<span>
<span
className="MuiSwitch-root"
>
<span
aria-disabled={true}
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-6 MuiSwitch-switchBase MuiSwitch-colorSecondary PrivateSwitchBase-disabled-8 Mui-disabled Mui-disabled Mui-disabled"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={-1}
title="Toggle Another"
>
<span
className="MuiIconButton-label"
>
<input
checked={false}
className="PrivateSwitchBase-input-9 MuiSwitch-input"
disabled={true}
onChange={[Function]}
type="checkbox"
/>
<span
className="MuiSwitch-thumb"
/>
</span>
</span>
<span
className="MuiSwitch-track"
/>
</span>
</span>
<span
className="makeStyles-listItemLink-4"
>
<a
className="listLink truncate"
href="/features/strategies/Another"
onClick={[Function]}
>
<span
className="toggleName"
>
Another
 
</span>
<small>
<time
dateTime="2018-02-04T20:27:52.127Z"
title="2018-02-04T20:27:52.127Z"
>
3 years ago
</time>
</small>
<div>
<span
className="truncate"
>
another's description
</span>
</div>
</a>
</span>
<span
className="makeStyles-listItemStrategies-5 hideLt920"
/>
<span />
</li>
`;

View File

@ -0,0 +1,381 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly with one feature 1`] = `
<div>
<div
className="makeStyles-searchBarContainer-3"
>
<div>
<div
className="makeStyles-search-4"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root makeStyles-searchIcon-5"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
/>
</svg>
<div
className="MuiInputBase-root makeStyles-inputRoot-6"
onClick={[Function]}
onKeyPress={[Function]}
>
<input
aria-label="search"
className="MuiInputBase-input"
onAnimationStart={[Function]}
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Search…"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
<div
className="MuiPaper-root MuiPaper-elevation1 MuiPaper-rounded"
>
<div
className="makeStyles-headerContainer-7"
>
<div
className="makeStyles-headerTitleContainer-11"
>
<div
className=""
>
<h2
className="MuiTypography-root makeStyles-headerTitle-12 MuiTypography-h2"
>
Feature toggles
</h2>
</div>
<div
className="makeStyles-headerActions-13"
>
<div
className="makeStyles-actionsContainer-1"
>
<div
className="makeStyles-actions-14"
>
<button
aria-controls="metric"
aria-haspopup="true"
className="MuiButtonBase-root MuiButton-root MuiButton-text"
disabled={false}
id="metric"
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
title="Metric interval"
type="button"
>
<span
className="MuiButton-label"
>
Last minute
<span
className="MuiButton-endIcon MuiButton-iconSizeMedium"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
arrow_drop_down
</span>
</span>
</span>
</button>
<button
aria-controls="sorting"
aria-haspopup="true"
className="MuiButtonBase-root MuiButton-root MuiButton-text"
disabled={false}
id="sorting"
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
title="Sort by"
type="button"
>
<span
className="MuiButton-label"
>
By name
<span
className="MuiButton-endIcon MuiButton-iconSizeMedium"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
arrow_drop_down
</span>
</span>
</span>
</button>
</div>
<a
aria-disabled={false}
className="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedSecondary MuiButton-containedSizeLarge MuiButton-sizeLarge"
data-test="add-feature-btn"
href="/features/create"
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="button"
tabIndex={0}
>
<span
className="MuiButton-label"
>
Create feature toggle
</span>
</a>
</div>
</div>
</div>
</div>
<div
className="makeStyles-bodyContainer-8"
>
<ul
className="MuiList-root MuiList-padding"
>
<ListItem
feature={
Object {
"name": "Another",
"reviveName": "Another",
}
}
hasPermission={[Function]}
settings={
Object {
"sort": "name",
}
}
toggleFeature={[MockFunction]}
/>
</ul>
</div>
</div>
</div>
`;
exports[`renders correctly with one feature without permissions 1`] = `
<div>
<div
className="makeStyles-searchBarContainer-3"
>
<div>
<div
className="makeStyles-search-4"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root makeStyles-searchIcon-5"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
/>
</svg>
<div
className="MuiInputBase-root makeStyles-inputRoot-6"
onClick={[Function]}
onKeyPress={[Function]}
>
<input
aria-label="search"
className="MuiInputBase-input"
onAnimationStart={[Function]}
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Search…"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
<div
className="MuiPaper-root MuiPaper-elevation1 MuiPaper-rounded"
>
<div
className="makeStyles-headerContainer-7"
>
<div
className="makeStyles-headerTitleContainer-11"
>
<div
className=""
>
<h2
className="MuiTypography-root makeStyles-headerTitle-12 MuiTypography-h2"
>
Feature toggles
</h2>
</div>
<div
className="makeStyles-headerActions-13"
>
<div
className="makeStyles-actionsContainer-1"
>
<div
className="makeStyles-actions-14"
>
<button
aria-controls="metric"
aria-haspopup="true"
className="MuiButtonBase-root MuiButton-root MuiButton-text"
disabled={false}
id="metric"
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
title="Metric interval"
type="button"
>
<span
className="MuiButton-label"
>
Last minute
<span
className="MuiButton-endIcon MuiButton-iconSizeMedium"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
arrow_drop_down
</span>
</span>
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
<button
aria-controls="sorting"
aria-haspopup="true"
className="MuiButtonBase-root MuiButton-root MuiButton-text"
disabled={false}
id="sorting"
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
title="Sort by"
type="button"
>
<span
className="MuiButton-label"
>
By name
<span
className="MuiButton-endIcon MuiButton-iconSizeMedium"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
arrow_drop_down
</span>
</span>
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
</div>
</div>
</div>
</div>
</div>
<div
className="makeStyles-bodyContainer-8"
>
<ul
className="MuiList-root MuiList-padding"
>
<ListItem
feature={
Object {
"name": "Another",
"reviveName": "Another",
}
}
hasPermission={[Function]}
settings={
Object {
"sort": "name",
}
}
toggleFeature={[MockFunction]}
/>
</ul>
</div>
</div>
</div>
`;

View File

@ -1,12 +1,14 @@
import React from 'react'; import React from 'react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '@material-ui/core';
import Feature from '../list-item-component'; import FeatureToggleListItem from '../FeatureToggleListItem';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import { UPDATE_FEATURE } from '../../../../permissions'; import { UPDATE_FEATURE } from '../../../../permissions';
jest.mock('react-mdl'); import theme from '../../../../themes/main-theme';
jest.mock('../feature-type-container');
jest.mock('../FeatureToggleListItem/FeatureToggleListItemChip');
test('renders correctly with one feature', () => { test('renders correctly with one feature', () => {
const feature = { const feature = {
@ -28,7 +30,8 @@ test('renders correctly with one feature', () => {
const settings = { sort: 'name' }; const settings = { sort: 'name' };
const tree = renderer.create( const tree = renderer.create(
<MemoryRouter> <MemoryRouter>
<Feature <ThemeProvider theme={theme}>
<FeatureToggleListItem
key={0} key={0}
settings={settings} settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]} metricsLastHour={featureMetrics.lastHour[feature.name]}
@ -37,6 +40,7 @@ test('renders correctly with one feature', () => {
toggleFeature={jest.fn()} toggleFeature={jest.fn()}
hasPermission={permission => permission === UPDATE_FEATURE} hasPermission={permission => permission === UPDATE_FEATURE}
/> />
</ThemeProvider>
</MemoryRouter> </MemoryRouter>
); );
@ -63,7 +67,8 @@ test('renders correctly with one feature without permission', () => {
const settings = { sort: 'name' }; const settings = { sort: 'name' };
const tree = renderer.create( const tree = renderer.create(
<MemoryRouter> <MemoryRouter>
<Feature <ThemeProvider theme={theme}>
<FeatureToggleListItem
key={0} key={0}
settings={settings} settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]} metricsLastHour={featureMetrics.lastHour[feature.name]}
@ -72,6 +77,7 @@ test('renders correctly with one feature without permission', () => {
toggleFeature={jest.fn()} toggleFeature={jest.fn()}
hasPermission={() => false} hasPermission={() => false}
/> />
</ThemeProvider>
</MemoryRouter> </MemoryRouter>
); );

View File

@ -0,0 +1,71 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '@material-ui/core';
import FeatureToggleList from '../FeatureToggleList';
import renderer from 'react-test-renderer';
import { CREATE_FEATURE } from '../../../../permissions';
import theme from '../../../../themes/main-theme';
jest.mock('../FeatureToggleListItem', () => ({
__esModule: true,
default: 'ListItem',
}));
jest.mock('../../../common/ProjectSelect');
test('renders correctly with one feature', () => {
const features = [
{
name: 'Another',
},
];
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
const settings = { sort: 'name' };
const tree = renderer.create(
<MemoryRouter>
<ThemeProvider theme={theme}>
<FeatureToggleList
updateSetting={jest.fn()}
settings={settings}
history={{}}
featureMetrics={featureMetrics}
features={features}
toggleFeature={jest.fn()}
fetcher={jest.fn()}
hasPermission={permission => permission === CREATE_FEATURE}
/>
</ThemeProvider>
</MemoryRouter>
);
expect(tree).toMatchSnapshot();
});
test('renders correctly with one feature without permissions', () => {
const features = [
{
name: 'Another',
},
];
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
const settings = { sort: 'name' };
const tree = renderer.create(
<MemoryRouter>
<ThemeProvider theme={theme}>
<FeatureToggleList
updateSetting={jest.fn()}
settings={settings}
history={{}}
featureMetrics={featureMetrics}
features={features}
toggleFeature={jest.fn()}
fetcher={jest.fn()}
hasPermission={() => false}
/>
</ThemeProvider>
</MemoryRouter>
);
expect(tree).toMatchSnapshot();
});

View File

@ -1,8 +1,8 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { toggleFeature, fetchFeatureToggles } from '../../../store/feature-toggle/actions'; import { toggleFeature, fetchFeatureToggles } from '../../../store/feature-toggle/actions';
import { updateSettingForGroup } from '../../../store/settings/actions'; import { updateSettingForGroup } from '../../../store/settings/actions';
import FeatureToggleList from './FeatureToggleList';
import FeatureListComponent from './list-component';
import { hasPermission } from '../../../permissions'; import { hasPermission } from '../../../permissions';
function checkConstraints(strategy, regex) { function checkConstraints(strategy, regex) {
@ -99,15 +99,16 @@ export const mapStateToPropsConfigurable = isFeature => state => {
featureMetrics, featureMetrics,
settings, settings,
hasPermission: hasPermission.bind(null, state.user.get('profile')), hasPermission: hasPermission.bind(null, state.user.get('profile')),
loading: state.apiCalls.fetchTogglesState.loading,
}; };
}; };
const mapStateToProps = mapStateToPropsConfigurable(true); const mapStateToProps = mapStateToPropsConfigurable(true);
const mapDispatchToProps = { const mapDispatchToProps = {
toggleFeature, toggleFeature,
fetchFeatureToggles, fetcher: () => fetchFeatureToggles(),
updateSetting: updateSettingForGroup('feature'), updateSetting: updateSettingForGroup('feature'),
}; };
const FeatureListContainer = connect(mapStateToProps, mapDispatchToProps)(FeatureListComponent); const FeatureToggleListContainer = connect(mapStateToProps, mapDispatchToProps)(FeatureToggleList);
export default FeatureListContainer; export default FeatureToggleListContainer;

View File

@ -0,0 +1,134 @@
const loadingFeatures = [
{
createdAt: '2021-03-19T09:16:21.329Z',
description: '',
enabled: true,
lastSeenAt: '2021-03-24T10:46:38.036Z',
name: 'one',
project: 'default',
reviveName: 'cool-thing',
stale: true,
strategies: [],
variants: [],
type: 'release',
},
{
createdAt: '2021-03-19T09:16:21.329Z',
description: '',
enabled: true,
lastSeenAt: '2021-03-24T10:46:38.036Z',
name: 'two',
project: 'default',
reviveName: 'cool-thing',
stale: true,
strategies: [],
variants: [],
type: 'release',
},
{
createdAt: '2021-03-19T09:16:21.329Z',
description: '',
enabled: true,
lastSeenAt: '2021-03-24T10:46:38.036Z',
name: 'three',
project: 'default',
reviveName: 'cool-thing',
stale: true,
strategies: [],
variants: [],
type: 'release',
},
{
createdAt: '2021-03-19T09:16:21.329Z',
description: '',
enabled: true,
lastSeenAt: '2021-03-24T10:46:38.036Z',
name: 'four',
project: 'default',
reviveName: 'cool-thing',
stale: true,
strategies: [],
variants: [],
type: 'release',
},
{
createdAt: '2021-03-19T09:16:21.329Z',
description: '',
enabled: true,
lastSeenAt: '2021-03-24T10:46:38.036Z',
name: 'five',
project: 'default',
reviveName: 'cool-thing',
stale: true,
strategies: [],
variants: [],
type: 'release',
},
{
createdAt: '2021-03-19T09:16:21.329Z',
description: '',
enabled: true,
lastSeenAt: '2021-03-24T10:46:38.036Z',
name: 'six',
project: 'default',
reviveName: 'cool-thing',
stale: true,
strategies: [],
variants: [],
type: 'release',
},
{
createdAt: '2021-03-19T09:16:21.329Z',
description: '',
enabled: true,
lastSeenAt: '2021-03-24T10:46:38.036Z',
name: 'seven',
project: 'default',
reviveName: 'cool-thing',
stale: true,
strategies: [],
variants: [],
type: 'release',
},
{
createdAt: '2021-03-19T09:16:21.329Z',
description: '',
enabled: true,
lastSeenAt: '2021-03-24T10:46:38.036Z',
name: 'eight',
project: 'default',
reviveName: 'cool-thing',
stale: true,
strategies: [],
variants: [],
type: 'release',
},
{
createdAt: '2021-03-19T09:16:21.329Z',
description: '',
enabled: true,
lastSeenAt: '2021-03-24T10:46:38.036Z',
name: 'nine',
project: 'default',
reviveName: 'cool-thing',
stale: true,
strategies: [],
variants: [],
type: 'release',
},
{
createdAt: '2021-03-19T09:16:21.329Z',
description: '',
enabled: true,
lastSeenAt: '2021-03-24T10:46:38.036Z',
name: 'ten',
project: 'default',
reviveName: 'cool-thing',
stale: true,
strategies: [],
variants: [],
type: 'release',
},
];
export default loadingFeatures;

View File

@ -0,0 +1,14 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles({
actionsContainer: {
display: 'flex',
alignItems: 'center',
},
listParagraph: {
textAlign: 'center',
},
searchBarContainer: {
marginBottom: '2rem',
},
});

View File

@ -0,0 +1,326 @@
import React, { useEffect, useLayoutEffect, useState } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { Paper, Typography, Button, Switch, LinearProgress } from '@material-ui/core';
import HistoryComponent from '../../history/history-list-toggle-container';
import MetricComponent from '../view/metric-container';
import UpdateStrategies from '../view/update-strategies-container';
import EditVariants from '../variant/update-variant-container';
import FeatureTypeSelect from '../feature-type-select-container';
import ProjectSelect from '../project-select-container';
import UpdateDescriptionComponent from '../view/update-description-component';
import { CREATE_FEATURE, DELETE_FEATURE, UPDATE_FEATURE } from '../../../permissions';
import StatusComponent from '../status-component';
import FeatureTagComponent from '../feature-tag-component';
import StatusUpdateComponent from '../view/status-update-component';
import AddTagDialog from '../add-tag-dialog-container';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import TabNav from '../../common/TabNav';
import { scrollToTop } from '../../common/util';
import styles from './FeatureView.module.scss';
import ConfirmDialogue from '../../common/Dialogue';
import { useCommonStyles } from '../../../common.styles';
const FeatureView = ({
activeTab,
featureToggleName,
features,
toggleFeature,
setStale,
removeFeatureToggle,
revive,
fetchArchive,
fetchFeatureToggles,
editFeatureToggle,
featureToggle,
history,
hasPermission,
untagFeature,
featureTags,
fetchTags,
tagTypes,
}) => {
const isFeatureView = !!fetchFeatureToggles;
const [delDialog, setDelDialog] = useState(false);
const commonStyles = useCommonStyles();
useEffect(() => {
scrollToTop();
fetchTags(featureToggleName);
}, []);
useLayoutEffect(() => {
if (features.length === 0) {
if (isFeatureView) {
fetchFeatureToggles();
} else {
fetchArchive();
}
}
}, [features]);
const getTabComponent = key => {
switch (key) {
case 'activation':
if (isFeatureView && hasPermission(UPDATE_FEATURE)) {
return <UpdateStrategies featureToggle={featureToggle} features={features} history={history} />;
}
return (
<UpdateStrategies
featureToggle={featureToggle}
features={features}
history={history}
editable={false}
/>
);
case 'metrics':
return <MetricComponent featureToggle={featureToggle} />;
case 'variants':
return (
<EditVariants
featureToggle={featureToggle}
features={features}
history={history}
hasPermission={hasPermission}
/>
);
case 'log':
return <HistoryComponent toggleName={featureToggleName} />;
}
};
const getTabData = () => [
{
label: 'Activation',
component: getTabComponent('activation'),
name: 'strategies',
path: `/features/strategies/${featureToggleName}`,
},
{
label: 'Metrics',
component: getTabComponent('metrics'),
name: 'metrics',
path: `/features/metrics/${featureToggleName}`,
},
{
label: 'Variants',
component: getTabComponent('variants'),
name: 'variants',
path: `/features/variants/${featureToggleName}`,
},
{
label: 'Log',
component: getTabComponent('log'),
name: 'logs',
path: `/features/logs/${featureToggleName}`,
},
];
if (!featureToggle) {
if (features.length === 0) {
return <LinearProgress />;
}
return (
<span>
Could not find the toggle{' '}
<ConditionallyRender
condition={hasPermission(CREATE_FEATURE)}
show={
<Link
to={{
pathname: '/features/create',
query: { name: featureToggleName },
}}
>
{featureToggleName}
</Link>
}
elseShow={<span>featureToggleName</span>}
/>
</span>
);
}
const removeToggle = () => {
removeFeatureToggle(featureToggle.name);
history.push('/features');
};
const reviveToggle = () => {
revive(featureToggle.name);
history.push('/features');
};
const updateDescription = description => {
let feature = { ...featureToggle, description };
if (Array.isArray(feature.strategies)) {
feature.strategies.forEach(s => {
delete s.id;
});
}
editFeatureToggle(feature);
};
const updateType = evt => {
evt.preventDefault();
const type = evt.target.value;
let feature = { ...featureToggle, type };
if (Array.isArray(feature.strategies)) {
feature.strategies.forEach(s => {
delete s.id;
});
}
editFeatureToggle(feature);
};
const updateProject = evt => {
evt.preventDefault();
const project = evt.target.value;
let feature = { ...featureToggle, project };
if (Array.isArray(feature.strategies)) {
feature.strategies.forEach(s => {
delete s.id;
});
}
editFeatureToggle(feature);
};
const updateStale = stale => {
setStale(stale, featureToggleName);
};
const tabs = getTabData();
const findActiveTab = activeTab => tabs.findIndex(tab => tab.name === activeTab);
return (
<Paper className={commonStyles.fullwidth} style={{ overflow: 'visible' }}>
<div>
<div className={styles.header}>
<Typography variant="h1" className={styles.heading}>
{featureToggle.name}
</Typography>
<StatusComponent stale={featureToggle.stale} />
</div>
<div className={classnames(styles.featureInfoContainer, commonStyles.contentSpacingY)}>
<UpdateDescriptionComponent
isFeatureView={isFeatureView}
description={featureToggle.description}
update={updateDescription}
hasPermission={hasPermission}
/>
<div className={styles.selectContainer}>
<FeatureTypeSelect value={featureToggle.type} onChange={updateType} label="Feature type" />
&nbsp;
<ProjectSelect value={featureToggle.project} onChange={updateProject} label="Project" filled />
</div>
<FeatureTagComponent
featureToggleName={featureToggle.name}
tags={featureTags}
tagTypes={tagTypes}
untagFeature={untagFeature}
/>
</div>
</div>
<div className={styles.actions}>
<span style={{ paddingRight: '24px' }}>
<ConditionallyRender
condition={hasPermission(UPDATE_FEATURE)}
show={
<>
<Switch
disabled={!isFeatureView}
checked={featureToggle.enabled}
onChange={() => toggleFeature(!featureToggle.enabled, featureToggle.name)}
/>
<span>{featureToggle.enabled ? 'Enabled' : 'Disabled'}</span>
</>
}
elseShow={
<>
<Switch disabled checked={featureToggle.enabled} />
<span>{featureToggle.enabled ? 'Enabled' : 'Disabled'}</span>
</>
}
/>
</span>
<ConditionallyRender
condition={isFeatureView}
show={
<div>
<AddTagDialog featureToggleName={featureToggle.name} />
<StatusUpdateComponent stale={featureToggle.stale} updateStale={updateStale} />
<Button
title="Create new feature toggle by cloning configuration"
component={Link}
to={`/features/copy/${featureToggle.name}`}
>
Clone
</Button>
<Button
disabled={!hasPermission(DELETE_FEATURE)}
onClick={() => {
setDelDialog(true);
}}
title="Archive feature toggle"
style={{ flexShrink: 0 }}
>
Archive
</Button>
</div>
}
elseShow={
<Button
disabled={!hasPermission(UPDATE_FEATURE)}
onClick={reviveToggle}
style={{ flexShrink: 0 }}
>
Revive
</Button>
}
/>
</div>
<hr />
<TabNav tabData={tabs} className={styles.tabContentContainer} startingTab={findActiveTab(activeTab)} />
<ConfirmDialogue
open={delDialog}
title="Are you sure you want to archive this toggle"
onClick={() => {
setDelDialog(false);
removeToggle();
}}
/>
</Paper>
);
};
FeatureView.propTypes = {
activeTab: PropTypes.string.isRequired,
featureToggleName: PropTypes.string.isRequired,
features: PropTypes.array.isRequired,
toggleFeature: PropTypes.func,
setStale: PropTypes.func,
removeFeatureToggle: PropTypes.func,
revive: PropTypes.func,
fetchArchive: PropTypes.func,
fetchFeatureToggles: PropTypes.func,
fetchFeatureToggle: PropTypes.func,
editFeatureToggle: PropTypes.func,
featureToggle: PropTypes.object,
history: PropTypes.object.isRequired,
hasPermission: PropTypes.func.isRequired,
fetchTags: PropTypes.func,
untagFeature: PropTypes.func,
featureTags: PropTypes.array,
tagTypes: PropTypes.array,
};
export default FeatureView;

Some files were not shown because too many files have changed in this diff Show More