joyent-portal/packages/cp-frontend/src/containers/manifest/edit-or-create.js

490 lines
12 KiB
JavaScript

import React, { Component } from 'react';
import { reduxForm } from 'redux-form';
import { compose, graphql } from 'react-apollo';
import { withRouter } from 'react-router';
import { Redirect } from 'react-router-dom';
import intercept from 'apr-intercept';
import paramCase from 'param-case';
import remove from 'lodash.remove';
import flatten from 'lodash.flatten';
import uniq from 'lodash.uniq';
import find from 'lodash.find';
import { safeLoad } from 'js-yaml';
import uuid from 'uuid/v4';
import DeploymentGroupBySlugQuery from '@graphql/DeploymentGroupBySlug.gql';
import DeploymentGroupCreateMutation from '@graphql/DeploymentGroupCreate.gql';
import DeploymentGroupProvisionMutation from '@graphql/DeploymentGroupProvision.gql';
import DeploymentGroupConfigQuery from '@graphql/DeploymentGroupConfig.gql';
import { client } from '@state/store';
import { ErrorMessage } from '@components/messaging';
import {
Name,
Manifest,
Environment,
Review
} from '@components/manifest/edit-or-create';
const INTERPOLATE_REGEX = /\$([_a-z][_a-z0-9]*)/gi;
// TODO: move state to redux. why: because in redux we can cache transactional
// state between refreshes
class DeploymentGroupEditOrCreate extends Component {
constructor(props) {
super(props);
const { create, files = [], manifest } = props;
const type = create ? 'create' : 'edit';
const NameForm =
create &&
reduxForm({
form: `${type}-deployment-group`,
destroyOnUnmount: true,
forceUnregisterOnUnmount: true,
asyncValidate: async ({ name = '' }) => {
const [err, res] = await intercept(
client.query({
fetchPolicy: 'network-only',
query: DeploymentGroupBySlugQuery,
variables: {
slug: paramCase(name.trim())
}
})
);
if (err) {
return;
}
if (!res.data.deploymentGroups.length) {
return;
}
// eslint-disable-next-line no-throw-literal
throw { name: `"${name}" already exists!` };
}
})(Name);
const ManifestForm = reduxForm({
form: `${type}-deployment-group`
})(Manifest);
const ReviewForm = reduxForm({
form: `${type}-deployment-group`
})(Review);
this.state = {
type,
defaultStage: create ? 'name' : 'edit',
manifestStage: create ? 'manifest' : 'edit',
name: '',
manifest: '',
environment: '',
files: this.resolveManifestFiles(files, manifest),
services: [],
environmentToggles: {},
loading: false,
error: null,
NameForm,
ReviewForm,
ManifestForm
};
this.state.EnvironmentForm = this.getEnvironmentForm(
this.state.files,
manifest
);
this.stages = {
name: create && this.renderNameForm.bind(this),
[create ? 'manifest' : 'edit']: this.renderManifestEditor.bind(this),
environment: this.renderEnvironmentEditor.bind(this),
review: this.renderReview.bind(this)
};
this.handleNameSubmit =
type === 'create' && this.handleNameSubmit.bind(this);
this.handleManifestSubmit = this.handleManifestSubmit.bind(this);
this.handleEnvironmentSubmit = this.handleEnvironmentSubmit.bind(this);
this.handleReviewSubmit = this.handleReviewSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleFileAdd = this.handleFileAdd.bind(this);
this.handleRemoveFile = this.handleRemoveFile.bind(this);
this.handleEnvironmentToggle = this.handleEnvironmentToggle.bind(this);
}
resolveManifestFiles(currentFiles = [], manifestStr = '') {
if (!manifestStr.length) {
return [];
}
let manifest = {};
try {
manifest = safeLoad(manifestStr);
} catch (err) {
console.error(err);
return [];
}
const services = manifest.services ? manifest.services : manifest;
const filenames = uniq(
// eslint-disable-next-line camelcase
flatten(Object.values(services).map(({ env_file }) => env_file))
);
return filenames
.filter(filename => !find(currentFiles, ['name', filename]))
.map(this.getDefaultFile)
.concat(currentFiles);
}
getEnvironmentForm(files = [], manifest = '') {
const { type } = this.state;
const initialValues = files.reduce(
(acc, { id, name, value }) =>
Object.assign(acc, {
[`file-name-${id}`]: name,
[`file-value-${id}`]: value
}),
{}
);
return reduxForm({
form: `${type}-deployment-group`,
initialValues
})(Environment);
}
getEnvironmentDefaultValue() {
const { environment = '' } = this.props;
const { manifest = '' } = this.state;
if (environment.length) {
return environment;
}
const names = manifest
.match(INTERPOLATE_REGEX)
.map(name => name.replace(/^\$/, ''));
const vars = uniq(names).map(name => `\n${name}=`).join('');
return `# define your interpolatable variables here\n${vars}`;
}
getDefaultFile(name = '') {
return {
id: uuid(),
name,
value: '# define your environment variables here\n'
};
}
createDeploymentGroup = async () => {
const { createDeploymentGroup, deploymentGroup, edit } = this.props;
if (edit && (!deploymentGroup || !deploymentGroup.id)) {
this.setState({
error: 'Unexpected Error: Inexistent DeploymentGroup!'
});
return {};
}
if (deploymentGroup && deploymentGroup.id) {
return deploymentGroup;
}
const { name } = this.state;
const [err, res] = await intercept(createDeploymentGroup({ name }));
if (err) {
this.setState({
error: err.message
});
}
return err ? {} : res.data.createDeploymentGroup;
};
provision = async deploymentGroupId => {
const { manifest, environment, files } = this.state;
const { provisionManifest } = this.props;
const [err] = await intercept(
provisionManifest({
deploymentGroupId,
type: 'COMPOSE',
format: 'YAML',
environment: environment || '',
files,
raw: manifest
})
);
if (err) {
this.setState({
error: err.message
});
}
return err ? null : true;
};
handleNameSubmit({ name = '' }) {
this.setState({ name }, () =>
this.redirect({ stage: 'manifest', prog: true })
);
}
handleManifestSubmit({ manifest = '' }) {
const { files } = this.state;
const _manifest = manifest || this.props.manifest;
const _files = this.resolveManifestFiles(files, _manifest);
const EnvironmentForm = this.getEnvironmentForm(_files, _manifest);
this.setState(
{ manifest: _manifest, EnvironmentForm, files: _files },
() => {
this.redirect({ stage: 'environment', prog: true });
}
);
}
handleEnvironmentSubmit(change) {
const { environment = '' } = change;
const { name, manifest } = this.state;
const files = Object.values(
Object.keys(change).reduce((acc, key) => {
const match = key.match(/file-(name|value)-(.*)/);
if (!match) {
return acc;
}
const [_, type, id] = match;
if (!acc[id]) {
acc[id] = {
id
};
}
acc[id][type] = change[key];
return acc;
}, {})
);
const getConfig = async () => {
const { environment } = this.state;
const [err, conf] = await intercept(
client.query({
query: DeploymentGroupConfigQuery,
fetchPolicy: 'network-only',
variables: {
deploymentGroupName: name,
type: 'COMPOSE',
format: 'YAML',
environment: environment || '',
files,
raw: manifest
}
})
);
if (err) {
return this.setState({
error: err.message
});
}
const { data } = conf;
const { config: services } = data;
this.setState({ loading: false, services, files }, () => {
this.redirect({ stage: 'review', prog: true });
});
};
this.setState(
{ environment: environment || this.props.environment, loading: true },
getConfig
);
}
handleReviewSubmit() {
const { history } = this.props;
const submit = async () => {
const { id, slug } = await this.createDeploymentGroup();
if (!id) {
return;
}
const manifest = await this.provision(id);
if (!manifest) {
return;
}
history.push(`/deployment-groups/${slug}`);
};
this.setState({ loading: true }, submit);
}
handleCancel() {
const { history, create, deploymentGroup } = this.props;
history.push(create ? '/' : `/deployment-groups/${deploymentGroup.slug}`);
return false;
}
handleFileAdd() {
const { files = [] } = this.state;
this.setState({
files: files.concat([this.getDefaultFile()])
});
}
handleRemoveFile(fileId) {
const { files = [] } = this.state;
this.setState({
files: remove(files, ({ id }) => id !== fileId)
});
}
handleEnvironmentToggle(serviceName) {
const { environmentToggles } = this.state;
this.setState({
environmentToggles: Object.assign({}, environmentToggles, {
[serviceName]: !environmentToggles[serviceName]
})
});
}
redirect({ stage = 'name', prog = false }) {
const { match, history, create } = this.props;
const regex = create ? /\/~create(.*)/ : /\/manifest(.*)/;
const to = match.url.replace(
regex,
create ? `/~create/${stage}` : `/manifest/${stage}`
);
if (!prog) {
return <Redirect to={to} />;
}
history.push(to);
}
renderNameForm() {
const { NameForm } = this.state;
return (
<NameForm onSubmit={this.handleNameSubmit} onCancel={this.handleCancel} />
);
}
renderManifestEditor() {
const { ManifestForm } = this.state;
return (
<ManifestForm
defaultValue={this.props.manifest}
onSubmit={this.handleManifestSubmit}
onCancel={this.handleCancel}
/>
);
}
renderEnvironmentEditor() {
const { EnvironmentForm, files, loading } = this.state;
return (
<EnvironmentForm
defaultValue={this.getEnvironmentDefaultValue()}
files={files}
onSubmit={this.handleEnvironmentSubmit}
onCancel={this.handleCancel}
onAddFile={this.handleFileAdd}
onRemoveFile={this.handleRemoveFile}
loading={loading}
/>
);
}
renderReview() {
const { ReviewForm, environmentToggles } = this.state;
return (
<ReviewForm
onSubmit={this.handleReviewSubmit}
onCancel={this.handleCancel}
onEnvironmentToggle={this.handleEnvironmentToggle}
environmentToggles={environmentToggles}
{...this.state}
/>
);
}
render() {
const { error, defaultStage, manifestStage, manifest, name } = this.state;
if (error) {
return <ErrorMessage title="Ooops!" message={error} />;
}
const { match, create } = this.props;
const stage = match.params.stage;
if (!stage) {
return this.redirect({ stage: defaultStage });
}
if (!this.stages[stage]) {
return this.redirect({ stage: defaultStage });
}
if (create && stage !== 'name' && !name) {
return this.redirect({ stage: defaultStage });
}
if (stage === 'environment' && !manifest) {
return this.redirect({ stage: manifestStage });
}
return this.stages[stage]();
}
}
export default compose(
graphql(DeploymentGroupCreateMutation, {
props: ({ mutate }) => ({
createDeploymentGroup: variables => mutate({ variables })
})
}),
graphql(DeploymentGroupProvisionMutation, {
props: ({ mutate }) => ({
provisionManifest: variables => mutate({ variables })
})
})
)(withRouter(DeploymentGroupEditOrCreate));