1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: UI for view, create and edit context fields (#204)

* feat: UI for view, create and edit context fields

* fix: lint
This commit is contained in:
Ivar Conradi Østhus 2020-02-27 21:36:07 +01:00 committed by GitHub
parent 03b4ec9751
commit d7f9b892a3
20 changed files with 510 additions and 42 deletions

View File

@ -74,9 +74,8 @@ export const FormButtons = ({ submitText = 'Create', onCancel }) => (
{submitText}
</Button>
&nbsp;
<Button type="cancel" ripple raised onClick={onCancel} style={{ float: 'right' }}>
<Icon name="cancel" />
&nbsp;&nbsp;&nbsp; Cancel
<Button type="cancel" onClick={onCancel}>
Cancel
</Button>
</div>
);

View File

@ -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;
}
};

View File

@ -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;

View File

@ -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;

View File

@ -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) => (
<Chip
key={`${value}:${index}`}
className="mdl-color--blue-grey-100"
style={{ marginRight: '4px' }}
onClose={() => this.removeLegalValue(index)}
>
{value}
</Chip>
);
render() {
const { contextField, errors } = this.state;
const { editMode } = this.props;
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>
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>
<form onSubmit={this.onSubmit}>
<section style={{ padding: '16px' }}>
<Textfield
floatingLabel
label="Name"
name="name"
value={contextField.name}
error={errors.name}
disabled={editMode}
onBlur={v => this.validateContextName(v.target.value)}
onChange={v => this.setValue('name', trim(v.target.value))}
/>
<Textfield
floatingLabel
style={{ width: '100%' }}
rows={1}
label="Description"
error={errors.description}
value={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
label="Value"
name="value"
style={{ width: '130px' }}
value={this.state.currentLegalValue}
error={errors.currentLegalValue}
onChange={this.updateCurrentLegalValue}
/>
<Button onClick={this.addLegalValue}>Add</Button>
<div>{contextField.legalValues.map(this.renderLegalValue)}</div>
</section>
</section>
<CardActions>
<FormButtons submitText={submitText} onCancel={this.onCancel} />
</CardActions>
</form>
</Card>
);
}
}
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;

View File

@ -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 (
<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

@ -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;

View File

@ -145,19 +145,9 @@ exports[`renders correctly with with variants 1`] = `
 
<react-mdl-Button
onClick={[MockFunction]}
raised={true}
ripple={true}
style={
Object {
"float": "right",
}
}
type="cancel"
>
<react-mdl-Icon
name="cancel"
/>
    Cancel
Cancel
</react-mdl-Button>
</div>
</form>
@ -225,19 +215,9 @@ exports[`renders correctly with without variants 1`] = `
 
<react-mdl-Button
onClick={[MockFunction]}
raised={true}
ripple={true}
style={
Object {
"float": "right",
}
}
type="cancel"
>
<react-mdl-Icon
name="cancel"
/>
    Cancel
Cancel
</react-mdl-Button>
</div>
</form>

View File

@ -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",

View File

@ -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();
});

View File

@ -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);

View File

@ -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,
};

View File

@ -0,0 +1,11 @@
import React from 'react';
import CreateContextField from '../../component/context/create-context-container';
import PropTypes from 'prop-types';
const render = ({ history }) => <CreateContextField title="Create context field" history={history} />;
render.propTypes = {
history: PropTypes.object.isRequired,
};
export default render;

View File

@ -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 }) => (
<CreateContextField contextFieldName={params.name} title="Edit context field" history={history} />
);
render.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
export default render;

View File

@ -0,0 +1,11 @@
import React from 'react';
import ContextFields from '../../component/context/list-container';
import PropTypes from 'prop-types';
const render = ({ history }) => <ContextFields history={history} />;
render.propTypes = {
history: PropTypes.object.isRequired,
};
export default render;

View File

@ -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 (

View File

@ -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 });
}

View File

@ -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;
}

View File

@ -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');

View File

@ -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 });