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 forceArray from 'force-array'; 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 { Environment, Name, Review, Manifest } from '@components/manifest'; 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(Boolean) .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 = forceArray(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 = '' }) { console.log(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 ; } history.push(to); } renderNameForm() { const { NameForm } = this.state; const { dataCenter } = this.props; return ( ); } renderManifestEditor() { const { ManifestForm } = this.state; return ( ); } renderEnvironmentEditor() { const { EnvironmentForm, files, loading } = this.state; return ( ); } renderReview() { const { ReviewForm, environmentToggles } = this.state; return ( ); } render() { const { error, defaultStage, manifestStage, manifest, name } = this.state; if (error) { return ; } 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));