From b1d30b045e4febdad1f00bbe7c1f64dcdf85ca46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivar=20Conradi=20=C3=98sthus?= Date: Wed, 23 Sep 2020 23:18:53 +0200 Subject: [PATCH 1/4] feat: Should be possible to remove applications https://github.com/Unleash/unleash/issues/634 --- .../application-edit-component-test.js.snap | 66 ++++++++++++------- .../application/application-edit-component.js | 56 +++++++++++----- .../application/application-edit-container.js | 3 +- frontend/src/data/applications-api.js | 9 +++ frontend/src/page/applications/view.js | 3 +- frontend/src/store/application/actions.js | 10 +++ frontend/src/store/application/index.js | 7 +- 7 files changed, 112 insertions(+), 42 deletions(-) diff --git a/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap b/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap index 43f89b92f1..ae68f436be 100644 --- a/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap +++ b/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap @@ -23,7 +23,7 @@ exports[`renders correctly with permissions 1`] = ` - +   test-app @@ -41,27 +41,48 @@ exports[`renders correctly with permissions 1`] = ` /> -
- + - - Details - - - Edit - - + > + + + Delete + + +
+ + + Details + + + Edit + + + - +   test-app @@ -232,7 +253,6 @@ exports[`renders correctly without permission 1`] = ` /> -
{ + evt.preventDefault(); + const { deleteApplication, appName } = this.props; + await deleteApplication(appName); + this.props.history.push('/applications'); + }; + render() { if (!this.props.application) { return ; @@ -173,9 +185,6 @@ class ClientApplications extends PureComponent { ) : ( - -
Edit app meta data
-
({ name: v, label: v }))} + options={icons.map(v => ({ key: v, label: v }))} value={icon} onChange={e => storeApplicationMetaData(appName, 'icon', e.target.value)} filled @@ -211,7 +220,7 @@ class ClientApplications extends PureComponent { return ( - +  {appName} {description && {description}} @@ -220,18 +229,33 @@ class ClientApplications extends PureComponent { )} -
{hasPermission(UPDATE_APPLICATION) ? ( - this.setState({ activeTab: tabId })} - ripple - tabBarProps={{ style: { width: '100%' } }} - className="mdl-color--grey-100" - > - Details - Edit - +
+ + + + +
+ this.setState({ activeTab: tabId })} + ripple + tabBarProps={{ style: { width: '100%' } }} + className="mdl-color--grey-100" + > + Details + Edit + +
) : ( '' )} diff --git a/frontend/src/component/application/application-edit-container.js b/frontend/src/component/application/application-edit-container.js index a3d89b54ba..7d46fb674b 100644 --- a/frontend/src/component/application/application-edit-container.js +++ b/frontend/src/component/application/application-edit-container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import ApplicationEdit from './application-edit-component'; -import { fetchApplication, storeApplicationMetaData } from './../../store/application/actions'; +import { fetchApplication, storeApplicationMetaData, deleteApplication } from './../../store/application/actions'; import { hasPermission } from '../../permissions'; const mapStateToProps = (state, props) => { @@ -19,6 +19,7 @@ const mapStateToProps = (state, props) => { const Constainer = connect(mapStateToProps, { fetchApplication, storeApplicationMetaData, + deleteApplication, })(ApplicationEdit); export default Constainer; diff --git a/frontend/src/data/applications-api.js b/frontend/src/data/applications-api.js index 919e3df790..5a4e3e1554 100644 --- a/frontend/src/data/applications-api.js +++ b/frontend/src/data/applications-api.js @@ -34,9 +34,18 @@ function storeApplicationMetaData(appName, key, value) { }).then(throwIfNotSuccess); } +function deleteApplication(appName) { + return fetch(`${URI}/${appName}`, { + method: 'DELETE', + headers, + credentials: 'include', + }).then(throwIfNotSuccess); +} + export default { fetchApplication, fetchAll, fetchApplicationsWithStrategyName, storeApplicationMetaData, + deleteApplication, }; diff --git a/frontend/src/page/applications/view.js b/frontend/src/page/applications/view.js index 13e72c32a6..95d163ca64 100644 --- a/frontend/src/page/applications/view.js +++ b/frontend/src/page/applications/view.js @@ -2,10 +2,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import ApplicationEditComponent from '../../component/application/application-edit-container'; -const render = ({ match: { params } }) => ; +const render = ({ match: { params }, history }) => ; render.propTypes = { match: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, }; export default render; diff --git a/frontend/src/store/application/actions.js b/frontend/src/store/application/actions.js index 71e2900f8d..7a29837583 100644 --- a/frontend/src/store/application/actions.js +++ b/frontend/src/store/application/actions.js @@ -7,6 +7,8 @@ export const ERROR_UPDATING_APPLICATION_DATA = 'ERROR_UPDATING_APPLICATION_DATA' export const RECEIVE_APPLICATION = 'RECEIVE_APPLICATION'; export const UPDATE_APPLICATION_FIELD = 'UPDATE_APPLICATION_FIELD'; +export const DELETE_APPLICATION = 'DELETE_APPLICATION'; +export const ERROR_DELETE_APPLICATION = 'ERROR_DELETE_APPLICATION'; const recieveAllApplications = json => ({ type: RECEIVE_ALL_APPLICATIONS, @@ -41,3 +43,11 @@ export function fetchApplication(appName) { .then(json => dispatch(recieveApplication(json))) .catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_ALL_APPLICATIONS)); } + +export function deleteApplication(appName) { + return dispatch => + api + .deleteApplication(appName) + .then(() => dispatch({ type: DELETE_APPLICATION, appName })) + .catch(dispatchAndThrow(dispatch, ERROR_DELETE_APPLICATION)); +} diff --git a/frontend/src/store/application/index.js b/frontend/src/store/application/index.js index 8e3dc36689..06da890586 100644 --- a/frontend/src/store/application/index.js +++ b/frontend/src/store/application/index.js @@ -1,5 +1,5 @@ import { fromJS, List, Map } from 'immutable'; -import { RECEIVE_ALL_APPLICATIONS, RECEIVE_APPLICATION, UPDATE_APPLICATION_FIELD } from './actions'; +import { RECEIVE_ALL_APPLICATIONS, RECEIVE_APPLICATION, UPDATE_APPLICATION_FIELD, DELETE_APPLICATION } from './actions'; import { USER_LOGOUT, USER_LOGIN } from '../user/actions'; function getInitState() { @@ -14,6 +14,11 @@ const store = (state = getInitState(), action) => { return state.set('list', new List(action.value.applications)); case UPDATE_APPLICATION_FIELD: return state.setIn(['apps', action.appName, action.key], action.value); + case DELETE_APPLICATION: { + const index = state.get('list').findIndex(item => item.appName === action.appName); + const result = state.removeIn(['list', index]); + return result.removeIn(['apps', action.appName]); + } case USER_LOGOUT: case USER_LOGIN: return getInitState(); From d88435f0c57aaf766e4698efe0b083ddf31db7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivar=20Conradi=20=C3=98sthus?= Date: Thu, 24 Sep 2020 19:30:27 +0200 Subject: [PATCH 2/4] fix: use https url for local->heroku proxy --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 439c4734b2..b7dcb3e8d7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,7 @@ "build:ico": "cp public/*.ico dist/.", "build:img": "cp public/*.png dist/public/.", "start": "NODE_ENV=development webpack-dev-server --progress --colors", - "start:heroku": "UNLEASH_API=http://unleash.herokuapp.com npm run start", + "start:heroku": "UNLEASH_API=https://unleash.herokuapp.com npm run start", "lint": "eslint . --ext js,jsx", "lint:fix": "eslint . --ext js,jsx --fix", "test": "jest", From 130110f5a4347213f957a5e0fb40f7285cec3bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivar=20Conradi=20=C3=98sthus?= Date: Thu, 24 Sep 2020 19:31:49 +0200 Subject: [PATCH 3/4] feat: add search for applications --- .../application/application-list-component.js | 17 ++++- .../application/application-list-container.js | 16 ++++- frontend/src/component/common/common.scss | 13 ++++ frontend/src/component/common/index.js | 4 +- .../src/component/common/search-field.jsx | 50 +++++++++++++++ .../list-component-test.jsx.snap | 62 +++++++++++++------ frontend/src/component/feature/feature.scss | 12 ---- .../src/component/feature/list-component.jsx | 18 +++--- 8 files changed, 144 insertions(+), 48 deletions(-) create mode 100644 frontend/src/component/common/search-field.jsx diff --git a/frontend/src/component/application/application-list-component.js b/frontend/src/component/application/application-list-component.js index 81be64eb43..976d5de7a4 100644 --- a/frontend/src/component/application/application-list-component.js +++ b/frontend/src/component/application/application-list-component.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { ProgressBar, Card, CardText, Icon } from 'react-mdl'; import { AppsLinkList, styles as commonStyles } from '../common'; +import SearchField from '../common/search-field'; const Empty = () => ( @@ -22,6 +23,8 @@ class ClientStrategies extends Component { static propTypes = { applications: PropTypes.array, fetchAll: PropTypes.func.isRequired, + settings: PropTypes.object.isRequired, + updateSetting: PropTypes.func.isRequired, }; componentDidMount() { @@ -35,9 +38,17 @@ class ClientStrategies extends Component { return ; } return ( - - {applications.length > 0 ? : } - +
+
+ +
+ + {applications.length > 0 ? : } + +
); } } diff --git a/frontend/src/component/application/application-list-container.js b/frontend/src/component/application/application-list-container.js index eb3809c078..e8d3ff6f88 100644 --- a/frontend/src/component/application/application-list-container.js +++ b/frontend/src/component/application/application-list-container.js @@ -1,9 +1,21 @@ import { connect } from 'react-redux'; import ApplicationList from './application-list-component'; import { fetchAll } from './../../store/application/actions'; +import { updateSettingForGroup } from '../../store/settings/actions'; -const mapStateToProps = state => ({ applications: state.applications.get('list').toJS() }); +const mapStateToProps = state => { + const applications = state.applications.get('list').toJS(); + const settings = state.settings.toJS().application || {}; -const Container = connect(mapStateToProps, { fetchAll })(ApplicationList); + const regex = new RegExp(settings.filter, 'i'); + + return { + applications: settings.filter ? applications.filter(a => regex.test(a.appName)) : applications, + settings, + }; +}; +const mapDispatchToProps = { fetchAll, updateSetting: updateSettingForGroup('application') }; + +const Container = connect(mapStateToProps, mapDispatchToProps)(ApplicationList); export default Container; diff --git a/frontend/src/component/common/common.scss b/frontend/src/component/common/common.scss index 487a3a0a14..6b55a7665d 100644 --- a/frontend/src/component/common/common.scss +++ b/frontend/src/component/common/common.scss @@ -85,4 +85,17 @@ .toggleName { color: #37474f !important; font-weight: 500; +} + +.toolbar { + position: relative; + padding: 0 24px 16px 24px; + text-align: center; +} + +.toolbarButton { + position: absolute; + top: 56px; + right: 24px; + z-index: 2; } \ No newline at end of file diff --git a/frontend/src/component/common/index.js b/frontend/src/component/common/index.js index 1daa5a6bfc..0a9247c6ea 100644 --- a/frontend/src/component/common/index.js +++ b/frontend/src/component/common/index.js @@ -11,14 +11,14 @@ export const shorten = (str, len = 50) => (str && str.length > len ? `${str.subs export const AppsLinkList = ({ apps }) => ( {apps.length > 0 && - apps.map(({ appName, description = '-', icon }) => ( + apps.map(({ appName, description, icon }) => ( {appName} - {description} + {description || 'No descriptionn'} diff --git a/frontend/src/component/common/search-field.jsx b/frontend/src/component/common/search-field.jsx new file mode 100644 index 0000000000..e6130a0c53 --- /dev/null +++ b/frontend/src/component/common/search-field.jsx @@ -0,0 +1,50 @@ +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 ( +
+ + + + +
+ ); +} + +SearchField.propTypes = { + value: PropTypes.string.isRequired, + updateValue: PropTypes.func.isRequired, +}; + +export default SearchField; diff --git a/frontend/src/component/feature/__tests__/__snapshots__/list-component-test.jsx.snap b/frontend/src/component/feature/__tests__/__snapshots__/list-component-test.jsx.snap index e8573c6320..c18023ed3f 100644 --- a/frontend/src/component/feature/__tests__/__snapshots__/list-component-test.jsx.snap +++ b/frontend/src/component/feature/__tests__/__snapshots__/list-component-test.jsx.snap @@ -5,16 +5,29 @@ exports[`renders correctly with one feature 1`] = `
- + + /> + + + +
- + + /> + + + + -
- { - this.setFilter(e.target.value); - }} - label="Search" - style={{ width: '100%' }} +
+ {hasPermission(CREATE_FEATURE) ? ( - + From 987fce309cef5a97fe94507cc91f10b231bc01d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivar=20Conradi=20=C3=98sthus?= Date: Thu, 24 Sep 2020 20:32:38 +0200 Subject: [PATCH 4/4] fix: cleanup edit application a bit --- .../application-edit-component-test.js.snap | 8 +- .../application/application-edit-component.js | 195 ++---------------- .../application/application-update.jsx | 46 +++++ .../application/application-view.jsx | 104 ++++++++++ .../application/stateful-textfield.js | 35 ++++ .../src/component/feature/list-component.jsx | 1 - 6 files changed, 207 insertions(+), 182 deletions(-) create mode 100644 frontend/src/component/application/application-update.jsx create mode 100644 frontend/src/component/application/application-view.jsx create mode 100644 frontend/src/component/application/stateful-textfield.js diff --git a/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap b/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap index ae68f436be..5c43b94965 100644 --- a/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap +++ b/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap @@ -92,6 +92,7 @@ exports[`renders correctly with permissions 1`] = ` > @@ -199,8 +200,7 @@ exports[`renders correctly with permissions 1`] = ` subtitle={ 123.123.123.123 - last seen at - + last seen at 02/23/2017, 03:56:49 PM @@ -263,6 +263,7 @@ exports[`renders correctly without permission 1`] = ` > @@ -360,8 +361,7 @@ exports[`renders correctly without permission 1`] = ` subtitle={ 123.123.123.123 - last seen at - + last seen at 02/23/2017, 03:56:49 PM diff --git a/frontend/src/component/application/application-edit-component.js b/frontend/src/component/application/application-edit-component.js index 46a6290406..648593967a 100644 --- a/frontend/src/component/application/application-edit-component.js +++ b/frontend/src/component/application/application-edit-component.js @@ -1,63 +1,13 @@ /* eslint react/no-multi-comp:off */ -import React, { Component, PureComponent } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { Link } from 'react-router-dom'; -import { - Button, - Grid, - Cell, - Card, - CardActions, - CardTitle, - CardText, - CardMenu, - List, - ListItem, - ListItemContent, - Textfield, - Icon, - ProgressBar, - Tabs, - Tab, - Switch, -} from 'react-mdl'; -import { IconLink, shorten, styles as commonStyles } from '../common'; +import { Button, Card, CardActions, CardTitle, CardText, CardMenu, Icon, ProgressBar, Tabs, Tab } from 'react-mdl'; +import { IconLink, styles as commonStyles } from '../common'; import { formatFullDateTimeWithLocale } from '../common/util'; -import { CREATE_FEATURE, CREATE_STRATEGY, UPDATE_APPLICATION } from '../../permissions'; -import icons from './icon-names'; -import MySelect from '../common/select'; - -class StatefulTextfield extends Component { - static propTypes = { - value: PropTypes.string, - label: PropTypes.string, - rows: PropTypes.number, - onBlur: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - this.state = { value: props.value }; - this.setValue = function setValue(e) { - this.setState({ value: e.target.value }); - }.bind(this); - } - - render() { - return ( - - ); - } -} +import { UPDATE_APPLICATION } from '../../permissions'; +import ApplicationView from './application-view'; +import ApplicationUpdate from './application-update'; class ClientApplications extends PureComponent { static propTypes = { @@ -71,17 +21,15 @@ class ClientApplications extends PureComponent { history: PropTypes.object.isRequired, }; - constructor(props) { - super(props); + constructor() { + super(); this.state = { activeTab: 0 }; } componentDidMount() { this.props.fetchApplication(this.props.appName); } - formatFullDateTime(v) { - return formatFullDateTimeWithLocale(v, this.props.location.locale); - } + formatFullDateTime = v => formatFullDateTimeWithLocale(v, this.props.location.locale); deleteApplication = async evt => { evt.preventDefault(); @@ -95,126 +43,19 @@ class ClientApplications extends PureComponent { return ; } const { application, storeApplicationMetaData, hasPermission } = this.props; - const { appName, instances, strategies, seenToggles, url, description, icon = 'apps', color } = application; + const { appName, instances, strategies, seenToggles, url, description, icon = 'apps' } = application; const content = this.state.activeTab === 0 ? ( - - -
Toggles
-
- - {seenToggles.map(({ name, description, enabled, notFound }, i) => - notFound ? ( - - {hasPermission(CREATE_FEATURE) ? ( - - {name} - - ) : ( - - {name} - - )} - - ) : ( - - - -
- } - subtitle={shorten(description, 60)} - > - {shorten(name, 50)} - - - ) - )} - - - -
Implemented strategies
-
- - {strategies.map(({ name, description, notFound }, i) => - notFound ? ( - - {hasPermission(CREATE_STRATEGY) ? ( - - {name} - - ) : ( - - {name} - - )} - - ) : ( - - - {shorten(name, 50)} - - - ) - )} - -
- -
{instances.length} Instances registered
-
- - {instances.map(({ instanceId, clientIp, lastSeen, sdkVersion }, i) => ( - - - {clientIp} last seen at{' '} - {this.formatFullDateTime(lastSeen)} -
- } - > - {instanceId} {sdkVersion ? `(${sdkVersion})` : ''} - - - ))} - - - + ) : ( - - - storeApplicationMetaData(appName, 'url', e.target.value)} - /> -
- storeApplicationMetaData(appName, 'description', e.target.value)} - /> -
- - ({ key: v, label: v }))} - value={icon} - onChange={e => storeApplicationMetaData(appName, 'icon', e.target.value)} - filled - /> - storeApplicationMetaData(appName, 'color', e.target.value)} - /> - -
+ ); return ( diff --git a/frontend/src/component/application/application-update.jsx b/frontend/src/component/application/application-update.jsx new file mode 100644 index 0000000000..9ab8ef7c69 --- /dev/null +++ b/frontend/src/component/application/application-update.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Grid, Cell } from 'react-mdl'; +import StatefulTextfield from './stateful-textfield'; +import icons from './icon-names'; +import MySelect from '../common/select'; + +function ApplicationUpdate({ application, storeApplicationMetaData }) { + const { appName, icon, url, description } = application; + + return ( + + + ({ key: v, label: v }))} + value={icon || 'apps'} + onChange={e => storeApplicationMetaData(appName, 'icon', e.target.value)} + filled + /> + storeApplicationMetaData(appName, 'url', e.target.value)} + /> + +
+ storeApplicationMetaData(appName, 'description', e.target.value)} + /> +
+
+ ); +} + +ApplicationUpdate.propTypes = { + application: PropTypes.object.isRequired, + storeApplicationMetaData: PropTypes.func.isRequired, +}; + +export default ApplicationUpdate; diff --git a/frontend/src/component/application/application-view.jsx b/frontend/src/component/application/application-view.jsx new file mode 100644 index 0000000000..e04e8fe18c --- /dev/null +++ b/frontend/src/component/application/application-view.jsx @@ -0,0 +1,104 @@ +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 { shorten } from '../common'; +import { CREATE_FEATURE, CREATE_STRATEGY } from '../../permissions'; + +function ApplicationView({ seenToggles, hasPermission, strategies, instances, formatFullDateTime }) { + return ( + + +
Toggles
+
+ + {seenToggles.map(({ name, description, enabled, notFound }, i) => + notFound ? ( + + {hasPermission(CREATE_FEATURE) ? ( + + {name} + + ) : ( + + {name} + + )} + + ) : ( + + + + + } + subtitle={shorten(description, 60)} + > + {shorten(name, 50)} + + + ) + )} + +
+ +
Implemented strategies
+
+ + {strategies.map(({ name, description, notFound }, i) => + notFound ? ( + + {hasPermission(CREATE_STRATEGY) ? ( + + {name} + + ) : ( + + {name} + + )} + + ) : ( + + + {shorten(name, 50)} + + + ) + )} + +
+ +
{instances.length} Instances registered
+
+ + {instances.map(({ instanceId, clientIp, lastSeen, sdkVersion }, i) => ( + + + {clientIp} last seen at {formatFullDateTime(lastSeen)} + + } + > + {instanceId} {sdkVersion ? `(${sdkVersion})` : ''} + + + ))} + +
+
+ ); +} + +ApplicationView.propTypes = { + instances: PropTypes.array.isRequired, + seenToggles: PropTypes.array.isRequired, + strategies: PropTypes.array.isRequired, + hasPermission: PropTypes.func.isRequired, + formatFullDateTime: PropTypes.func.isRequired, +}; + +export default ApplicationView; diff --git a/frontend/src/component/application/stateful-textfield.js b/frontend/src/component/application/stateful-textfield.js new file mode 100644 index 0000000000..ae4918fcc6 --- /dev/null +++ b/frontend/src/component/application/stateful-textfield.js @@ -0,0 +1,35 @@ +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 ( + + ); +} + +StatefulTextfield.propTypes = { + value: PropTypes.string, + label: PropTypes.string, + placeholder: PropTypes.string, + rows: PropTypes.number, + onBlur: PropTypes.func.isRequired, +}; + +export default StatefulTextfield; diff --git a/frontend/src/component/feature/list-component.jsx b/frontend/src/component/feature/list-component.jsx index ac62aa44d3..41b7ae871c 100644 --- a/frontend/src/component/feature/list-component.jsx +++ b/frontend/src/component/feature/list-component.jsx @@ -6,7 +6,6 @@ import { Icon, FABButton, Menu, MenuItem, Card, CardActions, List } from 'react- import Feature from './feature-list-item-component'; import { MenuItemWithIcon, DropdownButton, styles as commonStyles } from '../common'; import SearchField from '../common/search-field'; -import styles from './feature.scss'; import { CREATE_FEATURE } from '../../permissions'; export default class FeatureListComponent extends React.Component {