fuel-ui/static/views/wizard.js

852 lines
27 KiB
JavaScript

/*
* Copyright 2015 Mirantis, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
**/
import $ from 'jquery';
import _ from 'underscore';
import i18n from 'i18n';
import React from 'react';
import ReactDOM from 'react-dom';
import models from 'models';
import utils from 'utils';
import {dialogMixin} from 'views/dialogs';
import {Input, ProgressBar, ProgressButton} from 'views/controls';
var AVAILABILITY_STATUS_ICONS = {
compatible: 'glyphicon-ok-sign',
available: 'glyphicon-info-sign',
incompatible: 'glyphicon-warning-sign'
};
var ComponentCheckboxGroup = React.createClass({
hasEnabledComponents() {
return _.some(this.props.components, (component) => component.get('enabled'));
},
render() {
return (
<div>
{
_.map(this.props.components, (component) => {
var icon = AVAILABILITY_STATUS_ICONS[component.get('availability')];
return (
<Input
key={component.id}
type='checkbox'
name={component.id}
label={component.get('label')}
description={component.get('description')}
value={component.id}
checked={component.get('enabled')}
disabled={component.get('disabled')}
tooltipIcon={icon}
tooltipText={component.get('warnings')}
tooltipPlacement='top'
onChange={this.props.onChange}
/>
);
})
}
</div>
);
}
});
var ComponentRadioGroup = React.createClass({
getInitialState() {
var activeComponent = _.find(this.props.components, (component) => component.get('enabled'));
return {
value: activeComponent && activeComponent.id
};
},
hasEnabledComponents() {
return _.some(this.props.components, (component) => component.get('enabled'));
},
onChange(name, value) {
_.each(this.props.components, (component) => {
this.props.onChange(component.id, component.id === value);
});
this.setState({value: value});
},
render() {
return (
<div>
{
_.map(this.props.components, (component) => {
var icon = AVAILABILITY_STATUS_ICONS[component.get('availability')];
return (
<Input
key={component.id}
type='radio'
name={this.props.groupName}
label={component.get('label')}
description={component.get('description')}
value={component.id}
checked={this.state.value === component.id}
disabled={component.get('disabled')}
tooltipPlacement='top'
tooltipIcon={icon}
tooltipText={component.get('warnings')}
onChange={this.onChange}
/>
);
})
}
</div>
);
}
});
var ClusterWizardPanesMixin = {
statics: {
checkErrors(wizard, type) {
var allComponents = wizard.get('components');
allComponents.validate(type);
return !_.isNull(allComponents.validationError);
}
},
componentWillMount() {
if (this.props.allComponents) {
this.components = this.props.allComponents.getComponentsByType(
this.constructor.componentType,
{sorted: true}
);
}
},
componentDidMount() {
$(ReactDOM.findDOMNode(this)).find('input:enabled').first().focus();
},
areComponentsMutuallyExclusive(components) {
if (components.length <= 1) {
return false;
}
var allComponentsExclusive = _.every(components, (component) => {
var peerIds = _.map(_.reject(components, {id: component.id}), 'id');
var incompatibleIds = _.map(_.map(component.get('incompatible'), 'component'), 'id');
// peerIds should be subset of incompatibleIds to have exclusiveness property
return peerIds.length === _.intersection(peerIds, incompatibleIds).length;
});
return allComponentsExclusive;
},
processRestrictions(paneComponents, types, stopList = []) {
this.processIncompatible(paneComponents, types, stopList);
this.props.allComponents.processPaneRequires(this.constructor.componentType);
},
processCompatible(allComponents, paneComponents, types, stopList = []) {
// all previously enabled components
// should be compatible with the current component
_.each(paneComponents, (component) => {
// skip already disabled
if (component.get('disabled')) {
return;
}
// index of compatible elements
var compatibleComponents = {};
_.each(component.get('compatible'), (compatible) => {
compatibleComponents[compatible.component.id] = compatible;
});
// scan all components to find enabled
// and not present in the index
var isCompatible = true;
var warnings = [];
allComponents.each((testedComponent) => {
var type = testedComponent.get('type');
var isInStopList = _.find(stopList, (component) => component.id === testedComponent.id);
if (component.id === testedComponent.id || !_.includes(types, type) || isInStopList) {
// ignore self or forward compatibilities
return;
}
if (testedComponent.get('enabled') && !compatibleComponents[testedComponent.id]) {
warnings.push(testedComponent.get('label'));
isCompatible = false;
}
});
component.set({
isCompatible: isCompatible,
warnings: isCompatible ? i18n('dialog.create_cluster_wizard.compatible') :
i18n('dialog.create_cluster_wizard.incompatible_list') + warnings.join(', '),
availability: (isCompatible ? 'compatible' : 'available')
});
});
},
processIncompatible(paneComponents, types, stopList) {
// disable components that have
// incompatible components already enabled
_.each(paneComponents, (component) => {
var incompatibles = component.get('incompatible') || [];
var isDisabled = false;
var warnings = [];
_.each(incompatibles, (incompatible) => {
var type = incompatible.component.get('type');
var isInStopList = _.find(
stopList,
(component) => component.id === incompatible.component.id
);
if (!_.includes(types, type) || isInStopList) {
// ignore forward incompatibilities
return;
}
if (incompatible.component.get('enabled')) {
isDisabled = true;
warnings.push(incompatible.message);
}
});
component.set({
disabled: isDisabled,
warnings: warnings.join(' '),
enabled: isDisabled ? false : component.get('enabled'),
availability: 'incompatible'
});
});
},
selectActiveComponent(components) {
var active = _.find(components, (component) => component.get('enabled'));
if (active && !active.get('disabled')) {
return;
}
var newActive = _.find(components, (component) => !component.get('disabled'));
if (newActive) {
newActive.set({enabled: true});
}
if (active) {
active.set({enabled: false});
}
},
renderWarnings() {
return _.map(this.props.allComponents.validationError,
(warning, index) => <div key={index} className='alert alert-warning'>{warning}</div>);
}
};
var NameAndRelease = React.createClass({
mixins: [ClusterWizardPanesMixin],
statics: {
paneName: 'NameAndRelease',
title: i18n('dialog.create_cluster_wizard.name_release.title'),
hasErrors(wizard) {
return !!wizard.get('name_error');
}
},
isValid() {
var wizard = this.props.wizard;
var [name, cluster, clusters] = [
wizard.get('name'),
wizard.get('cluster'),
wizard.get('clusters')
];
// test cluster name is already taken
if (clusters.find({name: _.trim(name)})) {
wizard.set({
name_error: i18n('dialog.create_cluster_wizard.name_release.existing_environment')
});
return false;
}
// validate cluster fields
cluster.isValid();
if (cluster.validationError && cluster.validationError.name) {
wizard.set({name_error: cluster.validationError.name});
return false;
}
return true;
},
render() {
if (this.props.loading) return null;
var {wizard, releases, onChange} = this.props;
var ns = 'dialog.create_cluster_wizard.name_release.';
var paneClassName = 'create-cluster-form name-and-release';
var availableReleases = releases.filter({is_deployable: true});
if (!availableReleases.length) {
return (
<div className={paneClassName}>
<div className='alert alert-danger'>{i18n(ns + 'no_releases_message')}</div>
</div>
);
}
var release = wizard.get('release');
return (
<div className={paneClassName}>
<Input
type='text'
name='name'
autoComplete='off'
label={i18n(ns + 'name')}
value={wizard.get('name')}
error={wizard.get('name_error')}
onChange={onChange}
maxLength={100}
/>
<Input
type='select'
name='release'
label={i18n(ns + 'release_label')}
value={release.id}
onChange={onChange}
>
{_.map(availableReleases,
(release) => <option key={release.id} value={release.id}>{release.get('name')}</option>
)}
</Input>
<div className='help-block'>
<div className='alert alert-warning'>
{i18n(ns + release.get('operating_system') + '_connectivity_alert')}
</div>
<div className='release-description'>{release.get('description')}</div>
</div>
</div>
);
}
});
var Compute = React.createClass({
mixins: [ClusterWizardPanesMixin],
statics: {
paneName: 'Compute',
componentType: 'hypervisor',
title: i18n('dialog.create_cluster_wizard.compute.title'),
hasErrors(wizard) {
if (this.checkErrors(wizard, this.componentType)) {
return true;
}
var allComponents = wizard.get('components');
var components = allComponents.getComponentsByType(this.componentType, {sorted: true});
return !_.some(components, (component) => component.get('enabled'));
}
},
componentWillMount() {
this.updateRestrictions();
},
updateRestrictions() {
this.processRestrictions(this.components, ['hypervisor']);
},
render() {
return (
<div className='wizard-compute-pane'>
<ComponentCheckboxGroup
groupName='hypervisor'
components={this.components}
onChange={this.props.onChange}
/>
{this.constructor.hasErrors(this.props.wizard) &&
<div className='alert alert-warning empty-choice'>
{i18n('dialog.create_cluster_wizard.compute.empty_choice')}
</div>
}
{this.renderWarnings()}
</div>
);
}
});
var Network = React.createClass({
mixins: [ClusterWizardPanesMixin],
statics: {
paneName: 'Network',
panesForRestrictions: ['hypervisor', 'network'],
componentType: 'network',
title: i18n('dialog.create_cluster_wizard.network.title'),
ml2CorePath: 'network:neutron:core:ml2',
hasErrors(wizard) {
return this.checkErrors(wizard, this.componentType);
}
},
componentWillMount() {
var groups = _.groupBy(this.components,
(component) => component.isML2Driver() ? 'ml2' : 'monolithic');
this.monolithic = groups.monolithic;
this.ml2 = groups.ml2;
this.updateRestrictions();
},
updateRestrictions() {
this.processRestrictions(this.monolithic, this.constructor.panesForRestrictions);
this.processCompatible(this.props.allComponents, this.monolithic,
this.constructor.panesForRestrictions, this.monolithic);
this.selectActiveComponent(this.monolithic);
this.processRestrictions(this.ml2, this.constructor.panesForRestrictions);
this.processCompatible(this.props.allComponents, this.ml2,
this.constructor.panesForRestrictions);
},
onChange(name, value) {
this.props.onChange(name, value);
// reset all ml2 drivers if ml2 core unselected
var component = _.find(this.components, (component) => component.id === name);
if (!component.isML2Driver() && component.id !== this.constructor.ml2CorePath) {
_.each(this.components, (component) => {
if (component.isML2Driver()) {
component.set({enabled: false});
}
});
}
},
renderMonolithicDriverControls() {
return (
<ComponentRadioGroup
groupName='network'
components={this.monolithic}
onChange={this.onChange}
/>
);
},
renderML2DriverControls() {
return (
<ComponentCheckboxGroup
groupName='ml2'
components={this.ml2}
onChange={this.props.onChange}
/>
);
},
render() {
return (
<div className='wizard-network-pane'>
{this.renderMonolithicDriverControls()}
<div className='ml2'>
{this.renderML2DriverControls()}
</div>
{this.renderWarnings()}
</div>
);
}
});
var Storage = React.createClass({
mixins: [ClusterWizardPanesMixin],
statics: {
paneName: 'Storage',
panesForRestrictions: ['hypervisor', 'network', 'storage'],
componentType: 'storage',
title: i18n('dialog.create_cluster_wizard.storage.title'),
hasErrors(wizard) {
return this.checkErrors(wizard, this.componentType);
}
},
componentWillMount() {
this.updateRestrictions();
},
updateRestrictions() {
var components = this.components;
_.each(['block', 'object', 'image', 'ephemeral'], (subtype) => {
var sectionComponents = _.filter(components,
(component) => component.get('subtype') === subtype);
var isRadio = this.areComponentsMutuallyExclusive(sectionComponents);
this.processRestrictions(sectionComponents,
this.constructor.panesForRestrictions, isRadio ? sectionComponents : []);
this.processCompatible(this.props.allComponents, sectionComponents,
this.constructor.panesForRestrictions, isRadio ? sectionComponents : []);
});
},
renderSection(components, type) {
var sectionComponents = _.filter(components, (component) => component.get('subtype') === type);
var isRadio = this.areComponentsMutuallyExclusive(sectionComponents);
return (
React.createElement((isRadio ? ComponentRadioGroup : ComponentCheckboxGroup), {
groupName: type,
components: sectionComponents,
onChange: this.props.onChange
})
);
},
render() {
return (
<div className='wizard-storage-pane'>
<div className='row'>
<div className='col-xs-6'>
<h4>{i18n('dialog.create_cluster_wizard.storage.block')}</h4>
{this.renderSection(this.components, 'block', this.props.onChange)}
</div>
<div className='col-xs-6'>
<h4>{i18n('dialog.create_cluster_wizard.storage.object')}</h4>
{this.renderSection(this.components, 'object', this.props.onChange)}
</div>
</div>
<div className='row'>
<div className='col-xs-6'>
<h4>{i18n('dialog.create_cluster_wizard.storage.image')}</h4>
{this.renderSection(this.components, 'image', this.props.onChange)}
</div>
<div className='col-xs-6'>
<h4>{i18n('dialog.create_cluster_wizard.storage.ephemeral')}</h4>
{this.renderSection(this.components, 'ephemeral', this.props.onChange)}
</div>
</div>
{this.renderWarnings()}
</div>
);
}
});
var AdditionalServices = React.createClass({
mixins: [ClusterWizardPanesMixin],
statics: {
paneName: 'AdditionalServices',
panesForRestrictions: ['hypervisor', 'network', 'storage', 'additional_service'],
componentType: 'additional_service',
title: i18n('dialog.create_cluster_wizard.additional.title'),
hasErrors(wizard) {
return this.checkErrors(wizard, this.componentType);
}
},
componentWillMount() {
this.updateRestrictions();
},
updateRestrictions() {
this.processRestrictions(this.components, this.constructor.panesForRestrictions);
this.processCompatible(
this.props.allComponents,
this.components,
this.constructor.panesForRestrictions
);
},
render() {
return (
<div className='wizard-compute-pane'>
<ComponentCheckboxGroup
groupName='additionalComponents'
components={this.components}
onChange={this.props.onChange}
/>
{this.renderWarnings()}
</div>
);
}
});
var Finish = React.createClass({
statics: {
paneName: 'Finish',
title: i18n('dialog.create_cluster_wizard.ready.title')
},
render() {
return (
<p>
<span>{i18n('dialog.create_cluster_wizard.ready.env_select_deploy')} </span>
<b>{i18n('dialog.create_cluster_wizard.ready.deploy')} </b>
<span>{i18n('dialog.create_cluster_wizard.ready.or_make_config_choice')} </span>
<b>{i18n('dialog.create_cluster_wizard.ready.env')} </b>
<span>{i18n('dialog.create_cluster_wizard.ready.console')}</span>
</p>
);
}
});
var clusterWizardPanes = [
NameAndRelease,
Compute,
Network,
Storage,
AdditionalServices,
Finish
];
var CreateClusterWizard = React.createClass({
mixins: [dialogMixin],
getInitialState() {
return {
title: i18n('dialog.create_cluster_wizard.title'),
loading: true,
activePaneIndex: 0,
maxAvailablePaneIndex: 0,
panes: clusterWizardPanes,
paneHasErrors: false,
previousAvailable: true,
nextAvailable: true,
createEnabled: false
};
},
componentWillMount() {
this.stopHandlingKeys = false;
this.wizard = new models.BaseModel();
this.settings = new models.Settings();
this.releases = new models.Releases();
this.cluster = new models.Cluster();
this.wizard.set({cluster: this.cluster, clusters: this.props.clusters});
},
componentDidMount() {
this.releases.fetch()
.then(
() => {
var defaultReleaseId = (this.releases.find({is_deployable: true}) || {}).id || null;
this.selectRelease(defaultReleaseId);
this.setState({loading: false});
},
this.showError
);
this.updateState({activePaneIndex: 0});
},
getListOfTypesToRestore(currentIndex, maxIndex) {
var panesTypes = [];
_.each(clusterWizardPanes, (pane, paneIndex) => {
if ((paneIndex <= maxIndex) && (paneIndex > currentIndex) && pane.componentType) {
panesTypes.push(pane.componentType);
}
});
return panesTypes;
},
updateState(nextState) {
if (this.refs.pane && this.refs.pane.updateRestrictions) {
this.refs.pane.updateRestrictions();
}
var numberOfPanes = this.getEnabledPanes().length;
var nextActivePaneIndex = _.isNumber(nextState.activePaneIndex) ? nextState.activePaneIndex :
this.state.activePaneIndex;
var pane = clusterWizardPanes[nextActivePaneIndex];
var paneHasErrors = _.isFunction(pane.hasErrors) ? pane.hasErrors(this.wizard) : false;
var newState = _.merge(nextState, {
activePaneIndex: nextActivePaneIndex,
previousEnabled: nextActivePaneIndex > 0,
nextEnabled: !paneHasErrors,
nextVisible: (nextActivePaneIndex < numberOfPanes - 1),
createVisible: nextActivePaneIndex === numberOfPanes - 1,
paneHasErrors: paneHasErrors
});
this.setState(newState);
},
getEnabledPanes() {
return _.reject(this.state.panes, 'hidden');
},
getActivePane() {
var panes = this.getEnabledPanes();
return panes[this.state.activePaneIndex];
},
isCurrentPaneInvalid() {
var {pane} = this.refs;
if (!pane) return null;
// FIXME(jkirnosova): pane validation should be provided by one method
var hasErrors = _.isFunction(pane.isValid) && !pane.isValid() ||
_.isFunction(pane.constructor.hasErrors) && pane.constructor.hasErrors(this.wizard);
if (hasErrors) this.updateState({paneHasErrors: true});
return hasErrors;
},
prevPane() {
this.updateState({activePaneIndex: this.state.activePaneIndex - 1});
},
nextPane() {
if (this.isCurrentPaneInvalid()) return;
var nextIndex = this.state.activePaneIndex + 1;
this.updateState({
activePaneIndex: nextIndex,
maxAvailablePaneIndex: _.max([nextIndex, this.state.maxAvailablePaneIndex])
});
},
goToPane(index) {
if (index > this.state.maxAvailablePaneIndex) return;
if (index > this.state.activePaneIndex && this.isCurrentPaneInvalid()) return;
this.updateState({activePaneIndex: index});
},
saveCluster() {
if (this.stopHandlingKeys) return;
this.stopHandlingKeys = true;
this.setState({actionInProgress: true});
var cluster = this.cluster;
cluster.set({components: this.components});
var promise = cluster.save();
if (promise) {
this.updateState({disabled: true});
promise
.then(
() => {
this.props.clusters.add(cluster);
this.close();
app.navigate('/cluster/' + this.cluster.id);
},
(response) => {
this.stopHandlingKeys = false;
this.setState({actionInProgress: false});
if (response.status === 409) {
this.updateState({disabled: false, activePaneIndex: 0});
cluster.trigger('invalid', cluster, {name: utils.getResponseText(response)});
} else {
this.close();
utils.showErrorDialog({
response: response,
title: i18n('dialog.create_cluster_wizard.create_cluster_error.title')
});
}
}
);
}
},
selectRelease(releaseId) {
var release = this.releases.get(releaseId) || null;
this.wizard.set({release});
this.cluster.set({release: releaseId});
this.components = new models.ComponentsCollection([], {releaseId});
this.wizard.set({components: this.components});
// fetch components based on releaseId
if (releaseId) {
this.setState({loading: true});
this.components.fetch()
.then(
() => {
this.components.invokeMap('expandWildcards', this.components);
this.components.invokeMap('restoreDefaultValue', this.components);
this.components.invokeMap('preprocessRequires', this.components);
this.setState({loading: false});
},
this.showError
);
}
},
onChange(name, value) {
var maxAvailablePaneIndex = this.state.maxAvailablePaneIndex;
switch (name) {
case 'name':
this.wizard.set('name', value);
this.cluster.set('name', value);
this.wizard.unset('name_error');
break;
case 'release':
this.selectRelease(parseInt(value, 10));
break;
default:
maxAvailablePaneIndex = this.state.activePaneIndex;
var panesToRestore = this.getListOfTypesToRestore(
this.state.activePaneIndex,
this.state.maxAvailablePaneIndex
);
if (panesToRestore.length > 0) {
this.components.restoreDefaultValues(panesToRestore);
}
var component = this.components.get(name);
component.set({enabled: value});
break;
}
this.updateState({maxAvailablePaneIndex: maxAvailablePaneIndex});
},
onKeyDown(e) {
if (this.state.actionInProgress) {
return;
}
if (e.key === 'Enter') {
e.preventDefault();
if (this.getActivePane().paneName === 'Finish') {
this.saveCluster();
} else {
this.nextPane();
}
}
},
renderBody() {
var activeIndex = this.state.activePaneIndex;
var Pane = this.getActivePane();
return (
<div className='wizard-body'>
<div className='wizard-steps-box'>
<div className='wizard-steps-nav col-xs-3'>
<ul className='wizard-step-nav-item nav nav-pills nav-stacked'>
{
this.state.panes.map((pane, index) => {
var classes = utils.classNames('wizard-step', {
disabled: index > this.state.maxAvailablePaneIndex,
available: index <= this.state.maxAvailablePaneIndex && index !== activeIndex,
active: index === activeIndex
});
return (
<li key={pane.title} role='wizard-step'
className={classes}>
<a onClick={_.partial(this.goToPane, index)}>{pane.title}</a>
</li>
);
})
}
</ul>
</div>
{!this.components &&
<div className='pane-content col-xs-9 pane-progress-bar'>
<ProgressBar />
</div>
}
{this.components &&
<div className='pane-content col-xs-9 forms-box access'>
<Pane
ref='pane'
actionInProgress={this.state.actionInProgress}
loading={this.state.loading}
onChange={this.onChange}
releases={this.releases}
wizard={this.wizard}
allComponents={this.components}
/>
</div>
}
<div className='clearfix'></div>
</div>
</div>
);
},
renderFooter() {
var {actionInProgress, previousEnabled, nextVisible, nextEnabled, createVisible} = this.state;
var clusterCreationBlocked = !this.releases.some({is_deployable: true});
return (
<div className='wizard-footer'>
<button
className='btn btn-default pull-left'
data-dismiss='modal'
disabled={actionInProgress}
>
{i18n('common.cancel_button')}
</button>
{!clusterCreationBlocked && [
<button
key='prev'
className='btn btn-default prev-pane-btn'
disabled={!previousEnabled || actionInProgress}
onClick={this.prevPane}
>
<i className='glyphicon glyphicon-arrow-left' aria-hidden='true' />
&nbsp;
<span>{i18n('dialog.create_cluster_wizard.prev')}</span>
</button>,
nextVisible &&
<button
key='next'
className='btn btn-default btn-success next-pane-btn'
disabled={!nextEnabled || actionInProgress}
onClick={this.nextPane}
>
<span>{i18n('dialog.create_cluster_wizard.next')}</span>
&nbsp;
<i className='glyphicon glyphicon-arrow-right-white' aria-hidden='true' />
</button>,
createVisible &&
<ProgressButton
key='finish'
className='btn btn-default btn-success finish-btn'
onClick={this.saveCluster}
disabled={actionInProgress}
autoFocus
progress={actionInProgress}
>
{i18n('dialog.create_cluster_wizard.create')}
</ProgressButton>
]}
</div>
);
}
});
export default CreateClusterWizard;