diff --git a/frontend/src/component/common/index.js b/frontend/src/component/common/index.js index 8e6fbbe53f..8493bc2567 100644 --- a/frontend/src/component/common/index.js +++ b/frontend/src/component/common/index.js @@ -74,9 +74,8 @@ export const FormButtons = ({ submitText = 'Create', onCancel }) => ( {submitText}   - ); diff --git a/frontend/src/component/common/util.js b/frontend/src/component/common/util.js index 0439f99dfb..429e267869 100644 --- a/frontend/src/component/common/util.js +++ b/frontend/src/component/common/util.js @@ -12,3 +12,11 @@ export const formatFullDateTimeWithLocale = (v, locale, tz) => { } return new Date(v).toLocaleString(locale, dateTimeOptions); }; + +export const trim = value => { + if (value && value.trim) { + return value.trim(); + } else { + return value; + } +}; diff --git a/frontend/src/component/context/create-context-container.js b/frontend/src/component/context/create-context-container.js new file mode 100644 index 0000000000..97fe830620 --- /dev/null +++ b/frontend/src/component/context/create-context-container.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import ContextComponent from './form-context-component'; +import { createContextField, validateName } from './../../store/context/actions'; + +const mapStateToProps = (state, props) => { + let contextField = { name: '', description: '', legalValues: [] }; + if (props.contextFieldName) { + contextField = state.context.toJS().find(n => n.name === props.contextFieldName); + } + return { + contextField, + }; +}; + +const mapDispatchToProps = dispatch => ({ + validateName, + submit: contextField => createContextField(contextField)(dispatch), +}); + +const FormAddContainer = connect( + mapStateToProps, + mapDispatchToProps +)(ContextComponent); + +export default FormAddContainer; diff --git a/frontend/src/component/context/edit-context-container.js b/frontend/src/component/context/edit-context-container.js new file mode 100644 index 0000000000..0cd6796984 --- /dev/null +++ b/frontend/src/component/context/edit-context-container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import ContextComponent from './form-context-component'; +import { updateContextField, validateName } from './../../store/context/actions'; + +const mapStateToProps = (state, props) => { + const contextFieldBase = { name: '', description: '', legalValues: [] }; + const field = state.context.toJS().find(n => n.name === props.contextFieldName); + const contextField = Object.assign(contextFieldBase, field); + + return { + contextField, + }; +}; + +const mapDispatchToProps = dispatch => ({ + validateName, + submit: contextField => updateContextField(contextField)(dispatch), + editMode: true, +}); + +const FormAddContainer = connect( + mapStateToProps, + mapDispatchToProps +)(ContextComponent); + +export default FormAddContainer; diff --git a/frontend/src/component/context/form-context-component.jsx b/frontend/src/component/context/form-context-component.jsx new file mode 100644 index 0000000000..a0b858e72f --- /dev/null +++ b/frontend/src/component/context/form-context-component.jsx @@ -0,0 +1,171 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Chip, Textfield, Card, CardTitle, CardText, CardActions } from 'react-mdl'; + +import { FormButtons, styles as commonStyles } from '../common'; +import { trim } from '../common/util'; + +class AddContextComponent extends Component { + constructor(props) { + super(props); + this.state = { + contextField: props.contextField, + errors: {}, + currentLegalValue: '', + dirty: false, + }; + } + + static getDerivedStateFromProps(props, state) { + if (!state.contextField.name && props.contextField.name) { + return { contextField: props.contextField }; + } + } + + setValue = (field, value) => { + const { contextField } = this.state; + contextField[field] = value; + this.setState({ contextField, dirty: true }); + }; + + validateContextName = async name => { + const { errors } = this.state; + const { validateName } = this.props; + try { + await validateName(name); + errors.name = undefined; + } catch (err) { + errors.name = err.message; + } + + this.setState({ errors }); + }; + + onCancel = evt => { + evt.preventDefault(); + this.props.history.push('/context'); + }; + + onSubmit = evt => { + evt.preventDefault(); + const { contextField } = this.state; + this.props.submit(contextField).then(() => this.props.history.push('/context')); + }; + + updateCurrentLegalValue = evt => { + this.setState({ currentLegalValue: trim(evt.target.value) }); + }; + + addLegalValue = evt => { + evt.preventDefault(); + const { contextField, currentLegalValue, errors } = this.state; + + if (contextField.legalValues.indexOf(currentLegalValue) !== -1) { + errors.currentLegalValue = 'Duplicate legal value'; + this.setState({ errors }); + return; + } + + const legalValues = contextField.legalValues.concat(trim(currentLegalValue)); + contextField.legalValues = legalValues; + this.setState({ + contextField, + currentLegalValue: '', + errors: {}, + }); + }; + + removeLegalValue = index => { + const { contextField } = this.state; + const legalValues = contextField.legalValues.filter((_, i) => i !== index); + contextField.legalValues = legalValues; + this.setState({ contextField }); + }; + + renderLegalValue = (value, index) => ( + this.removeLegalValue(index)} + > + {value} + + ); + + render() { + const { contextField, errors } = this.state; + const { editMode } = this.props; + const submitText = editMode ? 'Update' : 'Create'; + + return ( + + + Create context field + + + 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. + +
+
+ this.validateContextName(v.target.value)} + onChange={v => this.setValue('name', trim(v.target.value))} + /> + this.setValue('description', v.target.value)} + /> +
+
+
+
Legal values
+

+ 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). +

+ + +
{contextField.legalValues.map(this.renderLegalValue)}
+
+
+ + + +
+
+ ); + } +} + +AddContextComponent.propTypes = { + contextField: PropTypes.object.isRequired, + validateName: PropTypes.func.isRequired, + fetchContext: PropTypes.func.isRequired, + submit: PropTypes.func.isRequired, + history: PropTypes.object.isRequired, + editMode: PropTypes.bool.isRequired, +}; + +export default AddContextComponent; diff --git a/frontend/src/component/context/list-component.jsx b/frontend/src/component/context/list-component.jsx new file mode 100644 index 0000000000..da79527a59 --- /dev/null +++ b/frontend/src/component/context/list-component.jsx @@ -0,0 +1,80 @@ +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 ( + + this.props.history.push('/context/create')} + title="Add new context field" + /> + ) : ( + '' + ) + } + /> + + {contextFields.length > 0 ? ( + contextFields.map((field, i) => ( + + + + {field.name} + + + + {hasPermission(DELETE_CONTEXT_FIELD) ? ( + + ) : ( + '' + )} + + + )) + ) : ( + No context fields defined + )} + + + ); + } +} + +export default ContextFieldListComponent; diff --git a/frontend/src/component/context/list-container.jsx b/frontend/src/component/context/list-container.jsx new file mode 100644 index 0000000000..09a7d63339 --- /dev/null +++ b/frontend/src/component/context/list-container.jsx @@ -0,0 +1,30 @@ +import { connect } from 'react-redux'; +import ContextFieldListComponent from './list-component.jsx'; +import { fetchContext, removeContextField } from './../../store/context/actions'; +import { hasPermission } from '../../permissions'; + +const mapStateToProps = state => { + const list = state.context.toJS(); + + return { + contextFields: list, + hasPermission: hasPermission.bind(null, state.user.get('profile')), + }; +}; + +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); + } + }, + fetchContext: () => fetchContext()(dispatch), +}); + +const ContextFieldListContainer = connect( + mapStateToProps, + mapDispatchToProps +)(ContextFieldListComponent); + +export default ContextFieldListContainer; diff --git a/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap index c784850174..bc47c361fb 100644 --- a/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap +++ b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap @@ -145,19 +145,9 @@ exports[`renders correctly with with variants 1`] = `   - -     Cancel + Cancel @@ -225,19 +215,9 @@ exports[`renders correctly with without variants 1`] = `   - -     Cancel + Cancel diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap index e70444c3f3..00b92ac2bb 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap @@ -121,6 +121,25 @@ Array [ "path": "/applications", "title": "Applications", }, + Object { + "component": [Function], + "parent": "/context", + "path": "/context/create", + "title": "Create", + }, + Object { + "component": [Function], + "parent": "/context", + "path": "/context/edit/:name", + "title": ":name", + }, + Object { + "component": [Function], + "hidden": true, + "icon": "apps", + "path": "/context", + "title": "Context Fields", + }, Object { "component": [Function], "icon": "exit_to_app", diff --git a/frontend/src/component/menu/__tests__/routes-test.jsx b/frontend/src/component/menu/__tests__/routes-test.jsx index 165744b01a..950f0ee2d8 100644 --- a/frontend/src/component/menu/__tests__/routes-test.jsx +++ b/frontend/src/component/menu/__tests__/routes-test.jsx @@ -1,7 +1,7 @@ import { routes, baseRoutes, getRoute } from '../routes'; test('returns all defined routes', () => { - expect(routes.length).toEqual(14); + expect(routes.length).toEqual(17); expect(routes).toMatchSnapshot(); }); diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index 5fcd08cb0d..54294137df 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -11,6 +11,9 @@ import ShowArchive from '../../page/archive/show'; import Archive from '../../page/archive'; import Applications from '../../page/applications'; import ApplicationView from '../../page/applications/view'; +import ContextFields from '../../page/context'; +import CreateContextField from '../../page/context/create'; +import EditContextField from '../../page/context/edit'; import LogoutFeatures from '../../page/user/logout'; export const routes = [ @@ -42,9 +45,14 @@ export const routes = [ { path: '/applications/:name', title: ':name', parent: '/applications', component: ApplicationView }, { path: '/applications', title: 'Applications', icon: 'apps', component: Applications }, + // Context + { path: '/context/create', parent: '/context', title: 'Create', component: CreateContextField }, + { path: '/context/edit/:name', parent: '/context', title: ':name', component: EditContextField }, + { path: '/context', title: 'Context Fields', icon: 'apps', component: ContextFields, hidden: true }, + { path: '/logout', title: 'Sign out', icon: 'exit_to_app', component: LogoutFeatures }, ]; export const getRoute = path => routes.find(route => route.path === path); -export const baseRoutes = routes.filter(route => !route.parent); +export const baseRoutes = routes.filter(route => !route.hidden).filter(route => !route.parent); diff --git a/frontend/src/data/context-api.js b/frontend/src/data/context-api.js index 5c949d43b2..a06b9b16a4 100644 --- a/frontend/src/data/context-api.js +++ b/frontend/src/data/context-api.js @@ -1,13 +1,52 @@ -import { throwIfNotSuccess } from './helper'; +import { throwIfNotSuccess, headers } from './helper'; const URI = 'api/admin/context'; -function fetchContext() { +function fetchAll() { return fetch(URI, { credentials: 'include' }) .then(throwIfNotSuccess) .then(response => response.json()); } +function create(contextField) { + return fetch(URI, { + method: 'POST', + headers, + body: JSON.stringify(contextField), + credentials: 'include', + }).then(throwIfNotSuccess); +} + +function update(contextField) { + return fetch(`${URI}/${contextField.name}`, { + method: 'PUT', + headers, + body: JSON.stringify(contextField), + credentials: 'include', + }).then(throwIfNotSuccess); +} + +function remove(contextField) { + return fetch(`${URI}/${contextField.name}`, { + method: 'DELETE', + headers, + credentials: 'include', + }).then(throwIfNotSuccess); +} + +function validate(name) { + return fetch(`${URI}/validate`, { + method: 'POST', + headers, + credentials: 'include', + body: JSON.stringify(name), + }).then(throwIfNotSuccess); +} + export default { - fetchContext, + fetchAll, + create, + update, + remove, + validate, }; diff --git a/frontend/src/page/context/create.js b/frontend/src/page/context/create.js new file mode 100644 index 0000000000..678a977b83 --- /dev/null +++ b/frontend/src/page/context/create.js @@ -0,0 +1,11 @@ +import React from 'react'; +import CreateContextField from '../../component/context/create-context-container'; +import PropTypes from 'prop-types'; + +const render = ({ history }) => ; + +render.propTypes = { + history: PropTypes.object.isRequired, +}; + +export default render; diff --git a/frontend/src/page/context/edit.js b/frontend/src/page/context/edit.js new file mode 100644 index 0000000000..bf3a02ed06 --- /dev/null +++ b/frontend/src/page/context/edit.js @@ -0,0 +1,14 @@ +import React from 'react'; +import CreateContextField from '../../component/context/edit-context-container'; +import PropTypes from 'prop-types'; + +const render = ({ match: { params }, history }) => ( + +); + +render.propTypes = { + match: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, +}; + +export default render; diff --git a/frontend/src/page/context/index.js b/frontend/src/page/context/index.js new file mode 100644 index 0000000000..e5a40a767d --- /dev/null +++ b/frontend/src/page/context/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ContextFields from '../../component/context/list-container'; +import PropTypes from 'prop-types'; + +const render = ({ history }) => ; + +render.propTypes = { + history: PropTypes.object.isRequired, +}; + +export default render; diff --git a/frontend/src/permissions.js b/frontend/src/permissions.js index 70c4b64872..c94bced5dd 100644 --- a/frontend/src/permissions.js +++ b/frontend/src/permissions.js @@ -6,6 +6,9 @@ export const CREATE_STRATEGY = 'CREATE_STRATEGY'; export const UPDATE_STRATEGY = 'UPDATE_STRATEGY'; export const DELETE_STRATEGY = 'DELETE_STRATEGY'; export const UPDATE_APPLICATION = 'UPDATE_APPLICATION'; +export const CREATE_CONTEXT_FIELD = 'CREATE_CONTEXT_FIELD'; +export const UPDATE_CONTEXT_FIELD = 'UPDATE_CONTEXT_FIELD'; +export const DELETE_CONTEXT_FIELD = 'DELETE_CONTEXT_FIELD'; export function hasPermission(user, permission) { return ( diff --git a/frontend/src/store/context/actions.js b/frontend/src/store/context/actions.js index 6dab9d4073..a8a0dcbd61 100644 --- a/frontend/src/store/context/actions.js +++ b/frontend/src/store/context/actions.js @@ -3,16 +3,50 @@ import { dispatchAndThrow } from '../util'; export const RECEIVE_CONTEXT = 'RECEIVE_CONTEXT'; export const ERROR_RECEIVE_CONTEXT = 'ERROR_RECEIVE_CONTEXT'; +export const REMOVE_CONTEXT = 'REMOVE_CONTEXT'; +export const ERROR_REMOVING_CONTEXT = 'ERROR_REMOVING_CONTEXT'; +export const ADD_CONTEXT_FIELD = 'ADD_CONTEXT_FIELD'; +export const ERROR_ADD_CONTEXT_FIELD = 'ERROR_ADD_CONTEXT_FIELD'; +export const UPDATE_CONTEXT_FIELD = 'UPDATE_CONTEXT_FIELD'; +export const ERROR_UPDATE_CONTEXT_FIELD = 'ERROR_UPDATE_CONTEXT_FIELD'; -export const receiveContext = json => ({ - type: RECEIVE_CONTEXT, - value: json, -}); +const receiveContext = value => ({ type: RECEIVE_CONTEXT, value }); +const addContextField = context => ({ type: ADD_CONTEXT_FIELD, context }); +const upContextField = context => ({ type: UPDATE_CONTEXT_FIELD, context }); +const createRemoveContext = context => ({ type: REMOVE_CONTEXT, context }); export function fetchContext() { return dispatch => api - .fetchContext() + .fetchAll() .then(json => dispatch(receiveContext(json))) .catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_CONTEXT)); } + +export function removeContextField(context) { + return dispatch => + api + .remove(context) + .then(() => dispatch(createRemoveContext(context))) + .catch(dispatchAndThrow(dispatch, ERROR_REMOVING_CONTEXT)); +} + +export function createContextField(context) { + return dispatch => + api + .create(context) + .then(() => dispatch(addContextField(context))) + .catch(dispatchAndThrow(dispatch, ERROR_ADD_CONTEXT_FIELD)); +} + +export function updateContextField(context) { + return dispatch => + api + .update(context) + .then(() => dispatch(upContextField(context))) + .catch(dispatchAndThrow(dispatch, ERROR_UPDATE_CONTEXT_FIELD)); +} + +export function validateName(name) { + return api.validate({ name }); +} diff --git a/frontend/src/store/context/index.js b/frontend/src/store/context/index.js index 279adbf873..ac39e3e5c4 100644 --- a/frontend/src/store/context/index.js +++ b/frontend/src/store/context/index.js @@ -1,15 +1,24 @@ -import { RECEIVE_CONTEXT } from './actions'; +import { List } from 'immutable'; +import { RECEIVE_CONTEXT, REMOVE_CONTEXT, ADD_CONTEXT_FIELD, UPDATE_CONTEXT_FIELD } from './actions'; const DEFAULT_CONTEXT_FIELDS = [{ name: 'environment' }, { name: 'userId' }, { name: 'appName' }]; function getInitState() { - return DEFAULT_CONTEXT_FIELDS; + return new List(DEFAULT_CONTEXT_FIELDS); } const strategies = (state = getInitState(), action) => { switch (action.type) { case RECEIVE_CONTEXT: - return action.value; + return new List(action.value); + case REMOVE_CONTEXT: + return state.remove(state.indexOf(action.context)); + case ADD_CONTEXT_FIELD: + return state.push(action.context); + case UPDATE_CONTEXT_FIELD: { + const index = state.findIndex(item => item.name === action.context.name); + return state.set(index, action.context); + } default: return state; } diff --git a/frontend/src/store/error-store.js b/frontend/src/store/error-store.js index 9a48f17c27..19f61dc77c 100644 --- a/frontend/src/store/error-store.js +++ b/frontend/src/store/error-store.js @@ -10,6 +10,8 @@ import { import { ERROR_UPDATING_STRATEGY, ERROR_CREATING_STRATEGY, ERROR_RECEIVE_STRATEGIES } from './strategy/actions'; +import { ERROR_ADD_CONTEXT_FIELD, ERROR_UPDATE_CONTEXT_FIELD } from './context/actions'; + import { FORBIDDEN } from './util'; const debug = require('debug')('unleash:error-store'); @@ -37,6 +39,8 @@ const strategies = (state = getInitState(), action) => { case ERROR_UPDATING_STRATEGY: case ERROR_CREATING_STRATEGY: case ERROR_RECEIVE_STRATEGIES: + case ERROR_ADD_CONTEXT_FIELD: + case ERROR_UPDATE_CONTEXT_FIELD: return addErrorIfNotAlreadyInList(state, action.error.message); case FORBIDDEN: return addErrorIfNotAlreadyInList(state, action.error.message || '403 Forbidden'); diff --git a/frontend/src/store/strategy/actions.js b/frontend/src/store/strategy/actions.js index cc7a97e5f2..26e06d18d4 100644 --- a/frontend/src/store/strategy/actions.js +++ b/frontend/src/store/strategy/actions.js @@ -20,10 +20,7 @@ const updatedStrategy = strategy => ({ type: UPDATE_STRATEGY, strategy }); const startRequest = () => ({ type: REQUEST_STRATEGIES }); -const receiveStrategies = json => ({ - type: RECEIVE_STRATEGIES, - value: json.strategies, -}); +const receiveStrategies = json => ({ type: RECEIVE_STRATEGIES, value: json.strategies }); const startCreate = () => ({ type: START_CREATE_STRATEGY });