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"
},
"main": "./index.js",
"dependencies": {},
"devDependencies": {
"@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "4.0.0-alpha.57",
"classnames": "^2.2.6",
"date-fns": "^2.17.0",
"@babel/core": "^7.9.0",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-decorators": "^7.8.3",
@ -52,12 +47,17 @@
"@babel/plugin-transform-runtime": "^7.9.0",
"@babel/preset-env": "^7.9.5",
"@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",
"babel-eslint": "^10.1.0",
"babel-jest": "^25.3.0",
"babel-loader": "^8.1.0",
"classnames": "^2.2.6",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^2.1.1",
"date-fns": "^2.17.0",
"debounce": "^1.2.0",
"debug": "^4.1.1",
"enzyme": "^3.9.0",
@ -72,7 +72,8 @@
"identity-obj-proxy": "^3.0.0",
"immutable": "^3.8.1",
"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",
"node-fetch": "^2.6.1",
"node-sass": "^4.5.3",
@ -84,11 +85,8 @@
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^16.14.0",
"react-mdl": "^2.1.0",
"react-modal": "^3.1.13",
"react-redux": "^7.2.0",
"react-router-dom": "^5.1.2",
"react-select": "^3.1.0",
"react-test-renderer": "^16.14.0",
"react-timeago": "^4.4.0",
"redux": "^4.0.5",
@ -100,6 +98,7 @@
"toolbox-loader": "0.0.3",
"uglifyjs-webpack-plugin": "^2.2.0",
"webpack": "^4.17.1",
"webpack-bundle-analyzer": "^4.4.0",
"webpack-cli": "^3.1.0",
"webpack-dev-server": "^3.11.2",
"whatwg-fetch": "^3.4.1"
@ -121,5 +120,7 @@
"testPathIgnorePatterns": [
"/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; }
body { height: 100%; }
.skeleton {
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 {
/* FONT SIZE */
--h1-size: 1.25rem;
--p-size: 1.1rem;
--p-size: 1rem;
--caption-size: 0.9rem;
/* PADDING */
--card-padding: 2rem;
--card-padding-x: 2rem;
--card-padding-y: 2rem;
--card-header-padding: 1rem 2rem;
--drawer-padding: 1rem 1.5rem;
--page-padding: 2rem 0;
--list-header-padding: 1rem;
/* MARGIN */
--card-margin-y: 1rem;
@ -21,6 +78,25 @@ body { height: 100%; }
--success: #3bd86e;
--danger: #d95e5e;
--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,
@ -29,3 +105,22 @@ h2 {
margin: 0;
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 classnames from 'classnames';
import { Card } from 'react-mdl';
import { Paper } from '@material-ui/core';
import PropTypes from 'prop-types';
import CheckIcon from '@material-ui/icons/Check';
import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined';
import ConditionallyRender from '../common/conditionally-render';
import 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 getActiveToggles = () => {
@ -76,7 +76,7 @@ const ReportCard = ({ features }) => {
);
return (
<Card className={styles.card}>
<Paper className={styles.card}>
<div className={styles.reportCardContainer}>
<div className={styles.reportCardListContainer}>
<h2 className={styles.header}>Toggle report</h2>
@ -113,7 +113,7 @@ const ReportCard = ({ features }) => {
</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 ReportCard from './report-card';
import { filterByProject } from './utils';
import ReportCard from './ReportCard';
import { filterByProject } from '../utils';
const mapStateToProps = (state, ownProps) => {
const features = state.features.toJS();

View File

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

View File

@ -1,13 +1,13 @@
import React from 'react';
import { Checkbox } from 'react-mdl';
import { Checkbox } from '@material-ui/core';
import UnfoldMoreOutlinedIcon from '@material-ui/icons/UnfoldMoreOutlined';
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 handleSort = type => {
@ -24,7 +24,12 @@ const ReportToggleListHeader = ({ handleCheckAll, checkAll, setSortData, bulkAct
condition={bulkActionsOn}
show={
<th>
<Checkbox onChange={handleCheckAll} value={checkAll} checked={checkAll} />
<Checkbox
onChange={handleCheckAll}
value={checkAll}
checked={checkAll}
className={styles.checkbox}
/>
</th>
}
/>

View File

@ -3,15 +3,15 @@ import classnames from 'classnames';
import PropTypes from 'prop-types';
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 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 { KILLSWITCH, PERMISSION } from './constants';
import { pluralize, getDates, expired, toggleExpiryByTypeMap, getDiffInDays } from '../../utils';
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 nameMatches = feature => feature.name === name;
@ -107,7 +107,12 @@ const ReportToggleListItem = ({ name, stale, lastSeenAt, createdAt, type, checke
condition={bulkActionsOn}
show={
<td>
<Checkbox checked={checked} value={checked} onChange={handleChange} />
<Checkbox
checked={checked}
value={checked}
onChange={handleChange}
className={styles.checkbox}
/>
</td>
}
/>

View File

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

View File

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

View File

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

View File

@ -5,7 +5,8 @@ import { HashRouter } from 'react-router-dom';
import { createStore } from 'redux';
import { mount } from 'enzyme/build';
import Reporting from '../reporting';
import Reporting from '../Reporting';
import { REPORTING_SELECT_ID } from '../../../testIds';
import { testProjects, testFeatures } from '../testData';
@ -15,13 +16,6 @@ const mockStore = {
};
const mockReducer = state => state;
jest.mock('react-mdl', () => ({
Checkbox: jest.fn().mockImplementation(({ children }) => children),
Card: jest.fn().mockImplementation(({ children }) => children),
Menu: jest.fn().mockImplementation(({ children }) => children),
MenuItem: jest.fn().mockImplementation(({ children }) => children),
}));
test('changing projects renders only toggles from that project', () => {
const wrapper = mount(
<HashRouter>
@ -31,9 +25,7 @@ test('changing projects renders only toggles from that project', () => {
</HashRouter>
);
const select = wrapper.find('.mdl-textfield__input').first();
expect(select.contains(<option value="default">Default</option>)).toBe(true);
expect(select.contains(<option value="myProject">MyProject</option>)).toBe(true);
const select = wrapper.find(`input[data-test="${REPORTING_SELECT_ID}"][value="default"]`).first();
let list = wrapper.find('tr');
/* 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 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 { trim } from '../common/util';
import AddonParameters from './form-addon-parameters';
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 [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 : {};
return (
<Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}>
<CardTitle style={{ paddingTop: '24px', paddingBottom: '0', wordBreak: 'break-all' }}>
Configure {name}
</CardTitle>
<CardText>
<PageContent headerContent={`Configure ${name} addon`}>
<section className={styles.formSection}>
{description}&nbsp;
<a href={documentationUrl} target="_blank">
Read more
</a>
<p className={commonStyles.error}>{errors.general}</p>
</CardText>
</section>
<form onSubmit={onSubmit}>
<section style={{ padding: '16px' }}>
<Grid noSpacing>
<Cell col={4}>
<Textfield
floatingLabel
label="Provider"
name="provider"
value={config.provider}
disabled
/>
</Cell>
<Cell col={4} style={{ paddingTop: '14px' }}>
<Switch checked={config.enabled} onChange={onEnabled}>
{config.enabled ? 'Enabled' : 'Disabled'}
</Switch>
</Cell>
</Grid>
<Textfield
floatingLabel
<section className={styles.formSection}>
<TextField
size="small"
label="Provider"
name="provider"
value={config.provider}
disabled
variant="outlined"
className={styles.nameInput}
/>
<FormControlLabel
control={<Switch checked={config.enabled} onChange={onEnabled} />}
label={config.enabled ? 'Enabled' : 'Disabled'}
/>
</section>
<section className={styles.formSection}>
<TextField
size="small"
style={{ width: '80%' }}
rows={1}
rows={4}
multiline
label="Description"
name="description"
placeholder=""
value={config.description}
error={errors.description}
onChange={setFieldValue('description')}
variant="outlined"
/>
</section>
<section style={{ padding: '16px' }}>
<section className={styles.formSection}>
<AddonEvents
provider={provider}
checkedEvents={config.events}
@ -148,7 +148,7 @@ const AddonFormComponent = ({ editMode, provider, addon, fetch, cancel, submit }
error={errors.events}
/>
</section>
<section style={{ padding: '16px' }}>
<section className={styles.formSection}>
<AddonParameters
provider={provider}
config={config}
@ -157,11 +157,11 @@ const AddonFormComponent = ({ editMode, provider, addon, fetch, cancel, submit }
setParameterValue={setParameterValue}
/>
</section>
<CardActions>
<section className={styles.formSection}>
<FormButtons submitText={submitText} onCancel={cancel} />
</CardActions>
</section>
</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 FormComponent from './form-addon-component';
import { updateAddon, createAddon, fetchAddons } from '../../store/addons/actions';
import { cloneDeep } from 'lodash';
import cloneDeep from 'lodash.clonedeep';
// Required for to fill the initial form.
const DEFAULT_DATA = {

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { connect } from 'react-redux';
import AddonsListComponent from './list-component.jsx';
import AddonsListComponent from './AddonList';
import { fetchAddons, removeAddon, updateAddon } from '../../store/addons/actions';
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
exports[`renders correctly with empty version 1`] = `
<react-mdl-FooterSection
logo="Unleash "
type="bottom"
<section
title="API details"
>
<h4>
Unleash
</h4>
<small>
(test)
\`($
test
)\`
</small>
<br />
<small>
</small>
<br />
<br />
<small>
We are the best!
</small>
<br />
<small>
</small>
</react-mdl-FooterSection>
</section>
`;
exports[`renders correctly with ui-config 1`] = `
<react-mdl-FooterSection
logo="Unleash 1.1.0"
type="bottom"
<section
title="API details"
>
<h4>
Unleash 1.1.0
</h4>
<small>
(test)
\`($
test
)\`
</small>
<br />
<small>
</small>
<br />
<br />
<small>
We are the best!
</small>
<br />
<small>
</small>
</react-mdl-FooterSection>
</section>
`;
exports[`renders correctly without uiConfig 1`] = `
<react-mdl-FooterSection
logo="Unleash 1.1.0"
type="bottom"
<section
title="API details"
>
<small>
</small>
<h4>
Unleash 1.1.0
</h4>
<br />
<br />
<small>
</small>
<br />
<small />
<br />
<small>
</small>
</react-mdl-FooterSection>
</section>
`;

View File

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

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { FooterSection } from 'react-mdl';
import ConditionallyRender from '../common/ConditionallyRender/ConditionallyRender';
class ShowApiDetailsComponent extends Component {
static propTypes = {
@ -15,12 +15,12 @@ class ShowApiDetailsComponent extends Component {
if (versionInfo) {
if (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}`;
}
} else {
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}`;
}
}
@ -29,15 +29,17 @@ class ShowApiDetailsComponent extends Component {
versionStr = `${name} ${version}`;
}
return (
<FooterSection type="bottom" logo={`${versionStr}`}>
<small>{environment ? `(${environment})` : ''}</small>
<section title="API details">
<h4>{`${versionStr}`}</h4>
<ConditionallyRender condition={environment} show={<small>`(${environment})`</small>} />
<br />
<ConditionallyRender condition={updateNotification} show={<small>{updateNotification}`</small>} />
<br />
<small>{updateNotification ? `${updateNotification}` : ''}</small>
<br />
<small>{slogan}</small>
<br />
<small>{instanceId ? `${instanceId}` : ''}</small>
</FooterSection>
<ConditionallyRender condition={instanceId} show={<small>{`${instanceId}`}</small>} />
</section>
);
}
}

View File

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

View File

@ -5,409 +5,615 @@ exports[`renders correctly if no application 1`] = `
<p>
Loading...
</p>
<react-mdl-ProgressBar
indeterminate={true}
/>
<div
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>
`;
exports[`renders correctly with permissions 1`] = `
<react-mdl-Card
className="fullwidth"
shadow={0}
<div
className="MuiPaper-root MuiPaper-elevation1 MuiPaper-rounded"
>
<react-mdl-CardTitle
style={
Object {
"paddingRight": "64px",
"paddingTop": "24px",
"wordBreak": "break-all",
}
}
<div
className="makeStyles-headerContainer-1"
>
<react-mdl-Icon
name="apps"
/>
 
test-app
</react-mdl-CardTitle>
<react-mdl-CardText
style={
Object {
"paddingTop": "0",
}
}
>
<p>
app description
</p>
<p>
Created:
<strong>
Invalid Date
</strong>
</p>
</react-mdl-CardText>
<react-mdl-CardMenu>
<a
className="mdl-color-text--grey-600"
href="http://example.org"
rel="noopener"
target="_blank"
<div
className="makeStyles-headerTitleContainer-5"
>
<react-mdl-Icon
name="link"
/>
</a>
</react-mdl-CardMenu>
<div>
<react-mdl-CardActions
border={true}
style={
Object {
"alignItems": "center",
"display": "flex",
"justifyContent": "space-between",
}
}
>
<span />
<react-mdl-Button
accent={true}
onClick={[Function]}
title="Delete application"
<div
className=""
>
Delete
</react-mdl-Button>
</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>
Details
</react-mdl-Tab>
<react-mdl-Tab>
Edit
</react-mdl-Tab>
</react-mdl-Tabs>
<h2
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
>
<span
style={
Object {
"alignItems": "center",
"display": "flex",
}
}
>
<div
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
</span>
</h2>
</div>
<div
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>
<react-mdl-Grid
style={
Object {
"margin": 0,
}
}
<div
className="makeStyles-bodyContainer-2"
>
<react-mdl-Cell
col={6}
hidePhone={true}
phone={12}
tablet={4}
>
<h6>
Toggles
</h6>
<hr />
<react-mdl-List>
<react-mdl-ListItem
twoLine={true}
<div>
<p
className="MuiTypography-root MuiTypography-body1"
>
app description
</p>
<p
className="MuiTypography-root MuiTypography-body2"
>
Created:
<strong>
Invalid Date
</strong>
</p>
</div>
<div>
<div
className="MuiPaper-root makeStyles-tabNav-8 MuiPaper-elevation1 MuiPaper-rounded"
>
<div
className="MuiTabs-root"
>
<react-mdl-ListItemContent
icon={
<span>
<react-mdl-Switch
checked={true}
disabled={true}
<div
className="MuiTabs-scroller MuiTabs-fixed"
onScroll={[Function]}
style={
Object {
"marginBottom": null,
"overflow": "hidden",
}
}
>
<div
className="MuiTabs-flexContainer MuiTabs-centered"
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]}
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"
>
<span
className="MuiTab-wrapper"
>
Application overview
</span>
<span
className="PrivateTabIndicator-root-9 PrivateTabIndicator-colorPrimary-10 MuiTabs-indicator"
style={Object {}}
/>
</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}
</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>
</div>
</div>
<div>
<div
aria-labelledby="wrapped-tab-0"
hidden={false}
id="wrapped-tabpanel-0"
role="tabpanel"
>
<react-mdl-ListItemContent
icon="report"
subtitle="Missing, want to create?"
>
<a
href="/features/create?name=ToggleB"
onClick={[Function]}
>
ToggleB
</a>
</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, want to create?"
>
<a
href="/strategies/create?name=StrategyB"
onClick={[Function]}
>
StrategyB
</a>
</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>
<div
className="MuiGrid-root MuiGrid-container"
style={
Object {
"margin": 0,
}
}
>
instance-1
(4.0)
</react-mdl-ListItemContent>
</react-mdl-ListItem>
</react-mdl-List>
</react-mdl-Cell>
</react-mdl-Grid>
</react-mdl-Card>
<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",
}
}
>
Toggles
</h6>
<hr />
<ul
className="MuiList-root MuiList-padding"
>
<li
className="MuiListItem-root MuiListItem-gutters"
disabled={false}
>
<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}
onChange={[Function]}
type="checkbox"
value={true}
/>
<span
className="MuiSwitch-thumb"
/>
</span>
</span>
<span
className="MuiSwitch-track"
/>
</span>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
<a
href="/features/strategies/ToggleA"
onClick={[Function]}
>
ToggleA
</a>
</span>
<p
className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
>
this is A toggle
</p>
</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
href="/features/create?name=ToggleB"
onClick={[Function]}
>
ToggleB
</a>
</span>
<p
className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
>
Missing, want to create?
</p>
</div>
</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",
}
}
>
Implemented strategies
</h6>
<hr />
<ul
className="MuiList-root MuiList-padding"
>
<li
className="MuiListItem-root MuiListItem-gutters"
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
href="/strategies/view/StrategyA"
onClick={[Function]}
>
StrategyA
</a>
</span>
<p
className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
>
A description
</p>
</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
href="/strategies/create?name=StrategyB"
onClick={[Function]}
>
StrategyB
</a>
</span>
<p
className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
>
Missing, want to create?
</p>
</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",
}
}
>
1
Instances registered
</h6>
<hr />
<ul
className="MuiList-root MuiList-padding"
>
<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"
>
<span>
123.123.123.123
last seen at
<small>
02/23/2017, 03:56:49 PM
</small>
</span>
</p>
</div>
</li>
</ul>
</div>
</div>
</div>
<div
aria-labelledby="wrapped-tab-1"
hidden={true}
id="wrapped-tabpanel-1"
role="tabpanel"
/>
</div>
</div>
</div>
</div>
`;
exports[`renders correctly without permission 1`] = `
<react-mdl-Card
className="fullwidth"
shadow={0}
<div
className="MuiPaper-root MuiPaper-elevation1 MuiPaper-rounded"
>
<react-mdl-CardTitle
style={
Object {
"paddingRight": "64px",
"paddingTop": "24px",
"wordBreak": "break-all",
}
}
<div
className="makeStyles-headerContainer-1"
>
<react-mdl-Icon
name="apps"
/>
 
test-app
</react-mdl-CardTitle>
<react-mdl-CardText
style={
Object {
"paddingTop": "0",
}
}
>
<p>
app description
</p>
<p>
Created:
<strong>
Invalid Date
</strong>
</p>
</react-mdl-CardText>
<react-mdl-CardMenu>
<a
className="mdl-color-text--grey-600"
href="http://example.org"
rel="noopener"
target="_blank"
<div
className="makeStyles-headerTitleContainer-5"
>
<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}
<div
className=""
>
<h2
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
>
<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>
<span
style={
Object {
"alignItems": "center",
"display": "flex",
}
}
>
instance-1
(4.0)
</react-mdl-ListItemContent>
</react-mdl-ListItem>
</react-mdl-List>
</react-mdl-Cell>
</react-mdl-Grid>
</react-mdl-Card>
<div
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
</span>
</h2>
</div>
<div
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"
>
app description
</p>
<p
className="MuiTypography-root MuiTypography-body2"
>
Created:
<strong>
Invalid Date
</strong>
</p>
</div>
</div>
</div>
`;

View File

@ -1,11 +1,11 @@
import React from 'react';
import { ThemeProvider } from '@material-ui/core';
import ClientApplications from '../application-edit-component';
import renderer from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { CREATE_FEATURE, CREATE_STRATEGY, UPDATE_APPLICATION } from '../../../permissions';
jest.mock('react-mdl');
import theme from '../../../themes/main-theme';
test('renders correctly if no application', () => {
const tree = renderer
@ -27,51 +27,53 @@ test('renders correctly without permission', () => {
const tree = renderer
.create(
<MemoryRouter>
<ClientApplications
fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()}
deleteApplication={jest.fn()}
history={{}}
application={{
appName: 'test-app',
instances: [
{
instanceId: 'instance-1',
clientIp: '123.123.123.123',
lastSeen: '2017-02-23T15:56:49',
sdkVersion: '4.0',
},
],
strategies: [
{
name: 'StrategyA',
description: 'A description',
},
{
name: 'StrategyB',
description: 'B description',
notFound: true,
},
],
seenToggles: [
{
name: 'ToggleA',
description: 'this is A toggle',
enabled: true,
},
{
name: 'ToggleB',
description: 'this is B toggle',
enabled: false,
notFound: true,
},
],
url: 'http://example.org',
description: 'app description',
}}
location={{ locale: 'en-GB' }}
hasPermission={() => false}
/>
<ThemeProvider theme={theme}>
<ClientApplications
fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()}
deleteApplication={jest.fn()}
history={{}}
application={{
appName: 'test-app',
instances: [
{
instanceId: 'instance-1',
clientIp: '123.123.123.123',
lastSeen: '2017-02-23T15:56:49',
sdkVersion: '4.0',
},
],
strategies: [
{
name: 'StrategyA',
description: 'A description',
},
{
name: 'StrategyB',
description: 'B description',
notFound: true,
},
],
seenToggles: [
{
name: 'ToggleA',
description: 'this is A toggle',
enabled: true,
},
{
name: 'ToggleB',
description: 'this is B toggle',
enabled: false,
notFound: true,
},
],
url: 'http://example.org',
description: 'app description',
}}
location={{ locale: 'en-GB' }}
hasPermission={() => false}
/>
</ThemeProvider>
</MemoryRouter>
)
.toJSON();
@ -83,53 +85,55 @@ test('renders correctly with permissions', () => {
const tree = renderer
.create(
<MemoryRouter>
<ClientApplications
fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()}
history={{}}
deleteApplication={jest.fn()}
application={{
appName: 'test-app',
instances: [
{
instanceId: 'instance-1',
clientIp: '123.123.123.123',
lastSeen: '2017-02-23T15:56:49',
sdkVersion: '4.0',
},
],
strategies: [
{
name: 'StrategyA',
description: 'A description',
},
{
name: 'StrategyB',
description: 'B description',
notFound: true,
},
],
seenToggles: [
{
name: 'ToggleA',
description: 'this is A toggle',
enabled: true,
},
{
name: 'ToggleB',
description: 'this is B toggle',
enabled: false,
notFound: true,
},
],
url: 'http://example.org',
description: 'app description',
}}
location={{ locale: 'en-GB' }}
hasPermission={permission =>
[CREATE_FEATURE, CREATE_STRATEGY, UPDATE_APPLICATION].indexOf(permission) !== -1
}
/>
<ThemeProvider theme={theme}>
<ClientApplications
fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()}
history={{}}
deleteApplication={jest.fn()}
application={{
appName: 'test-app',
instances: [
{
instanceId: 'instance-1',
clientIp: '123.123.123.123',
lastSeen: '2017-02-23T15:56:49',
sdkVersion: '4.0',
},
],
strategies: [
{
name: 'StrategyA',
description: 'A description',
},
{
name: 'StrategyB',
description: 'B description',
notFound: true,
},
],
seenToggles: [
{
name: 'ToggleA',
description: 'this is A toggle',
enabled: true,
},
{
name: 'ToggleB',
description: 'this is B toggle',
enabled: false,
notFound: true,
},
],
url: 'http://example.org',
description: 'app description',
}}
location={{ locale: 'en-GB' }}
hasPermission={permission =>
[CREATE_FEATURE, CREATE_STRATEGY, UPDATE_APPLICATION].indexOf(permission) !== -1
}
/>
</ThemeProvider>
</MemoryRouter>
)
.toJSON();

View File

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

View File

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

View File

@ -1,13 +1,15 @@
import React, { Component } from 'react';
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 SearchField from '../common/search-field';
import SearchField from '../common/SearchField/SearchField';
import PageContent from '../common/PageContent/PageContent';
import HeaderTitle from '../common/HeaderTitle';
const Empty = () => (
<React.Fragment>
<CardText style={{ textAlign: 'center' }}>
<Icon name="warning" style={{ fontSize: '5em' }} /> <br />
<section style={{ textAlign: 'center' }}>
<Icon>warning</Icon> <br />
<br />
Oh snap, it does not seem like you have connected any applications. To connect your application to Unleash
you will require a Client SDK.
@ -15,7 +17,7 @@ const Empty = () => (
<br />
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>
</CardText>
</section>
</React.Fragment>
);
@ -35,20 +37,22 @@ class ClientStrategies extends Component {
const { applications } = this.props;
if (!applications) {
return <ProgressBar indeterminate />;
return <CircularProgress variant="indeterminate" />;
}
return (
<div>
<div className={commonStyles.toolbar}>
<>
<div className={commonStyles.searchField}>
<SearchField
value={this.props.settings.filter}
updateValue={this.props.updateSetting.bind(this, 'filter')}
/>
</div>
<Card shadow={0} className={commonStyles.fullwidth}>
{applications.length > 0 ? <AppsLinkList apps={applications} /> : <Empty />}
</Card>
</div>
<PageContent headerContent={<HeaderTitle title="Applications" />}>
<div className={commonStyles.fullwidth}>
{applications.length > 0 ? <AppsLinkList apps={applications} /> : <Empty />}
</div>
</PageContent>
</>
);
}
}

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { connect } from 'react-redux';
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 { 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 Component from './project-component';
import { fetchProjects } from './../../../store/project/actions';
import { P } from '../../common/flags';
import ProjectSelect from './ProjectSelect';
import { fetchProjects } from '../../../store/project/actions';
const mapStateToProps = (state, ownProps) => ({
enabled: !!state.uiConfig.toJS().flags[P],
projects: state.projects.toJS(),
currentProjectId: ownProps.settings.currentProjectId || '*',
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 {
white-space: nowrap;
overflow: hidden;
@ -42,27 +44,21 @@
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
.title {
padding: 20px 16px 20px 24px;
}
.titleText {
margin: 0;
font-size: 20px;
line-height: 24px
line-height: 24px;
}
.actions {
flex-shrink: 0;
padding: 20px 14px 20px 16px;
}
}
.switchWithLabel {
display: flex;
.label {
padding-right: 16px;
line-height: 24px;
@ -106,4 +102,30 @@
.error {
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 PropTypes from 'prop-types';
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 ConditionallyRender from './ConditionallyRender/ConditionallyRender';
export { styles };
export const shorten = (str, len = 50) => (str && str.length > len ? `${str.substring(0, len)}...` : str);
export const AppsLinkList = ({ apps }) => (
<List>
{apps.length > 0 &&
apps.map(({ appName, description, icon }) => (
<ListItem twoLine key={appName}>
<span className="mdl-list__item-primary-content" style={{ minWidth: 0 }}>
<Icon name={icon || 'apps'} className="mdl-list__item-avatar" />
<Link to={`/applications/${appName}`} className={[styles.listLink, styles.truncate].join(' ')}>
{appName}
<span className={['mdl-list__item-sub-title', styles.truncate].join(' ')}>
{description || 'No description'}
</span>
</Link>
</span>
<ConditionallyRender
condition={apps.length > 0}
show={apps.map(({ appName, description, icon }) => (
<ListItem key={appName} className={styles.listItem}>
<ListItemAvatar>
<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}
</Link>
}
secondary={description || 'No description'}
/>
</ListItem>
))}
/>
</List>
);
AppsLinkList.propTypes = {
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 }) => (
<div className={styles.dataTableHeader}>
<div className={styles.title}>
<h2 className={styles.titleText}>{title}</h2>
<Typography variant="h2" className={styles.titleText}>
{title}
</Typography>
</div>
{actions && <div className={styles.actions}>{actions}</div>}
</div>
@ -66,11 +69,15 @@ DataTableHeader.propTypes = {
actions: PropTypes.any,
};
export const FormButtons = ({ submitText = 'Create', onCancel }) => (
export const FormButtons = ({ submitText = 'Create', onCancel, primaryButtonTestId }) => (
<div>
<Button type="submit" ripple raised primary icon="add">
<Icon name="add" />
&nbsp;&nbsp;&nbsp;
<Button
data-test={primaryButtonTestId}
type="submit"
color="primary"
variant="contained"
startIcon={<Icon>add</Icon>}
>
{submitText}
</Button>
&nbsp;
@ -82,37 +89,7 @@ export const FormButtons = ({ submitText = 'Create', onCancel }) => (
FormButtons.propTypes = {
submitText: PropTypes.string,
onCancel: PropTypes.func.isRequired,
};
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,
primaryButtonTestId: PropTypes.string,
};
export function getIcon(type) {
@ -132,7 +109,7 @@ export function getIcon(type) {
export const IconLink = ({ url, icon }) => (
<a href={url} target="_blank" rel="noopener" className="mdl-color-text--grey-600">
<Icon name={icon} />
<Icon>{icon}</Icon>
</a>
);
IconLink.propTypes = {
@ -140,22 +117,41 @@ IconLink.propTypes = {
icon: PropTypes.string,
};
export const DropdownButton = ({ label, id, className = styles.dropdownButton, title, style }) => (
<Button id={id} className={className} title={title} style={style}>
export const DropdownButton = ({
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}
<Icon name="arrow_drop_down" className="mdl-color-text--grey-600" />
</Button>
);
DropdownButton.propTypes = {
label: PropTypes.string,
style: PropTypes.object,
id: PropTypes.string,
title: PropTypes.string,
icon: PropTypes.string,
startIcon: PropTypes.string,
};
export const MenuItemWithIcon = ({ icon, label, disabled, ...menuItemProps }) => (
<MenuItem disabled={disabled} style={{ display: 'flex', alignItems: 'center' }} {...menuItemProps}>
<Icon name={icon} style={{ paddingRight: '16px' }} />
<Icon style={{ paddingRight: '16px' }}>{icon}</Icon>
{label}
</MenuItem>
);

View File

@ -1,6 +1,6 @@
import React from 'react';
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 = () => {} }) {
const handleChange = evt => {
@ -21,10 +21,10 @@ function InputListField({ label, values = [], error, name, updateValues, placeho
};
return (
<Textfield
<TextField
name={name}
floatingLabel
error={error}
error={error !== undefined}
helperText={error}
placeholder={placeholder}
value={values ? values.join(', ') : ''}
onKeyDown={handleKeyDown}
@ -32,6 +32,8 @@ function InputListField({ label, values = [], error, name, updateValues, placeho
onBlur={onBlur}
label={label}
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 classnames from 'classnames';
import { Select, FormControl, MenuItem, InputLabel } from '@material-ui/core';
import PropTypes from 'prop-types';
const Select = ({ name, value, label, options, style, onChange, disabled = false, filled, className }) => {
const wrapper = Object.assign({ width: 'auto' }, style);
const SelectMenu = ({ name, value, label, options, onChange, id, disabled = false, className, ...rest }) => {
const renderSelectItems = () =>
options.map(option => (
<MenuItem key={option.key} value={option.key} title={option.title}>
{option.label}
</MenuItem>
));
return (
<div
className={classnames(
'mdl-textfield mdl-js-textfield mdl-textfield--floating-label is-dirty is-upgraded',
className
)}
style={wrapper}
>
<select
className="mdl-textfield__input"
<FormControl variant="outlined" size="small">
<InputLabel htmlFor={id} id={id}>
{label}
</InputLabel>
<Select
name={name}
disabled={disabled}
onChange={onChange}
className={className}
label={label}
id={id}
size="small"
value={value}
style={{
width: 'auto',
background: filled ? '#f5f5f5' : 'none',
}}
{...rest}
>
{options.map(o => (
<option key={o.key} value={o.key} title={o.title}>
{o.label}
</option>
))}
</select>
<label className="mdl-textfield__label" htmlFor="textfield-contextName">
{label}
</label>
</div>
{renderSelectItems()}
</Select>
</FormControl>
);
};
Select.propTypes = {
SelectMenu.propTypes = {
name: PropTypes.string,
id: PropTypes.string,
value: PropTypes.string,
label: PropTypes.string,
options: PropTypes.array,
style: PropTypes.object,
onChange: PropTypes.func.isRequired,
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 ContextFieldListComponent from './list-component.jsx';
import { fetchContext, removeContextField } from './../../store/context/actions';
import { hasPermission } from '../../permissions';
import ContextList from './ContextList';
import { fetchContext, removeContextField } from '../../../store/context/actions';
import { hasPermission } from '../../../permissions';
const mapStateToProps = state => {
const list = state.context.toJS();
@ -14,14 +14,11 @@ const mapStateToProps = state => {
const mapDispatchToProps = dispatch => ({
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),
});
const ContextFieldListContainer = connect(mapStateToProps, mapDispatchToProps)(ContextFieldListComponent);
const ContextFieldListContainer = connect(mapStateToProps, mapDispatchToProps)(ContextList);
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 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 { trim } from '../common/util';
import PageContent from '../common/PageContent/PageContent';
const sortIgnoreCase = (a, b) => {
a = a.toLowerCase();
@ -99,12 +101,10 @@ class AddContextComponent extends Component {
renderLegalValue = (value, index) => (
<Chip
key={`${value}:${index}`}
className="mdl-color--blue-grey-100"
style={{ marginRight: '4px' }}
onClose={() => this.removeLegalValue(index)}
>
{value}
</Chip>
className={styles.chip}
onDelete={() => this.removeLegalValue(index)}
label={value}
/>
);
render() {
@ -113,85 +113,96 @@ class AddContextComponent extends Component {
const submitText = editMode ? 'Update' : 'Create';
return (
<Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}>
<CardTitle style={{ paddingTop: '24px', paddingBottom: '0', wordBreak: 'break-all' }}>
Create context field
</CardTitle>
<CardText>
<PageContent headerContent="Create context field">
<div className={styles.supporting}>
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.
</CardText>
</div>
<form onSubmit={this.onSubmit}>
<section style={{ padding: '16px' }}>
<Textfield
floatingLabel
<section className={styles.formContainer}>
<TextField
className={commonStyles.fullwidth}
label="Name"
name="name"
value={contextField.name}
defaultValue={contextField.name}
error={errors.name}
helperText={errors.name}
disabled={editMode}
variant="outlined"
size="small"
onBlur={v => this.validateContextName(v.target.value)}
onChange={v => this.setValue('name', trim(v.target.value))}
/>
<Textfield
floatingLabel
style={{ width: '100%' }}
rows={1}
<TextField
className={commonStyles.fullwidth}
rowsMax={1}
label="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)}
/>
<br />
<br />
<section style={{ padding: '16px', background: '#fafafa' }}>
<h6 style={{ marginTop: '0' }}>Legal values</h6>
<p style={{ color: 'rgba(0,0,0,.54)' }}>
By defining the legal values the Unleash Admin UI will validate the user input. A
concrete example would be that we know all values for our environment (local,
development, stage, production).
</p>
<Textfield
floatingLabel
</section>
<section className={styles.inset}>
<h6 className={styles.h6}>Legal values</h6>
<p className={styles.alpha}>
By defining the legal values the Unleash Admin UI will validate the user input. A concrete
example would be that we know all values for our environment (local, development, stage,
production).
</p>
<div>
<TextField
label="Value"
name="value"
style={{ width: '130px' }}
className={styles.valueField}
value={this.state.currentLegalValue}
error={errors.currentLegalValue}
error={!!errors.currentLegalValue}
helperText={errors.currentLegalValue}
variant="outlined"
size="small"
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
</Button>
<div>{contextField.legalValues.map(this.renderLegalValue)}</div>
</section>
<br />
<section style={{ padding: '16px' }}>
<h6 style={{ marginTop: '0' }}>Custom stickiness (beta)</h6>
<p style={{ color: 'rgba(0,0,0,.54)' }}>
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
of this context field. PS! Not all client SDK's support this feature yet!{' '}
<a
href="https://unleash.github.io/docs/activation_strategy#flexiblerollout"
target="_blank"
>
Read more
</a>
</p>
<Checkbox
label="Allow stickiness"
ripple
checked={contextField.stickiness}
onChange={() => this.setValue('stickiness', !contextField.stickiness)}
/>
</section>
</div>
<div>{contextField.legalValues.map(this.renderLegalValue)}</div>
</section>
<CardActions>
<br />
<section>
<Typography variant="subtitle1">Custom stickiness (beta)</Typography>
<p className={classnames(styles.alpha, styles.formContainer)}>
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 of
this context field. PS! Not all client SDK's support this feature yet!{' '}
<a
href="https://unleash.github.io/docs/activation_strategy#flexiblerollout"
target="_blank"
>
Read more
</a>
</p>
<Switch
label="Allow stickiness"
value={contextField.stickiness}
onChange={() => this.setValue('stickiness', !contextField.stickiness)}
/>
</section>
<div className={styles.formButtons}>
<FormButtons submitText={submitText} onCancel={this.onCancel} />
</CardActions>
</div>
</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 PropTypes from 'prop-types';
import { Snackbar, Icon } from 'react-mdl';
import { Snackbar, Icon, IconButton } from '@material-ui/core';
const ErrorComponent = ({ errors, ...props }) => {
const showError = errors.length > 0;
const error = showError ? errors[0] : undefined;
const muteError = () => props.muteError(error);
return (
<Snackbar action="Dismiss" active={showError} onActionClick={muteError} onTimeout={muteError} timeout={10000}>
<Icon name="question_answer" /> {error}
</Snackbar>
<Snackbar
action={
<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 Component from './feature-type-component';
import Component from './FeatureToggleListItemChip';
const mapStateToProps = state => ({
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 { 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 { UPDATE_FEATURE } from '../../../../permissions';
jest.mock('react-mdl');
jest.mock('../feature-type-container');
import theme from '../../../../themes/main-theme';
jest.mock('../FeatureToggleListItem/FeatureToggleListItemChip');
test('renders correctly with one feature', () => {
const feature = {
@ -28,15 +30,17 @@ test('renders correctly with one feature', () => {
const settings = { sort: 'name' };
const tree = renderer.create(
<MemoryRouter>
<Feature
key={0}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
toggleFeature={jest.fn()}
hasPermission={permission => permission === UPDATE_FEATURE}
/>
<ThemeProvider theme={theme}>
<FeatureToggleListItem
key={0}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
toggleFeature={jest.fn()}
hasPermission={permission => permission === UPDATE_FEATURE}
/>
</ThemeProvider>
</MemoryRouter>
);
@ -63,15 +67,17 @@ test('renders correctly with one feature without permission', () => {
const settings = { sort: 'name' };
const tree = renderer.create(
<MemoryRouter>
<Feature
key={0}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
toggleFeature={jest.fn()}
hasPermission={() => false}
/>
<ThemeProvider theme={theme}>
<FeatureToggleListItem
key={0}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
toggleFeature={jest.fn()}
hasPermission={() => false}
/>
</ThemeProvider>
</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 { toggleFeature, fetchFeatureToggles } from '../../../store/feature-toggle/actions';
import { updateSettingForGroup } from '../../../store/settings/actions';
import FeatureToggleList from './FeatureToggleList';
import FeatureListComponent from './list-component';
import { hasPermission } from '../../../permissions';
function checkConstraints(strategy, regex) {
@ -99,15 +99,16 @@ export const mapStateToPropsConfigurable = isFeature => state => {
featureMetrics,
settings,
hasPermission: hasPermission.bind(null, state.user.get('profile')),
loading: state.apiCalls.fetchTogglesState.loading,
};
};
const mapStateToProps = mapStateToPropsConfigurable(true);
const mapDispatchToProps = {
toggleFeature,
fetchFeatureToggles,
fetcher: () => fetchFeatureToggles(),
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