diff --git a/.therapist.yml b/.therapist.yml new file mode 100644 index 00000000..5e8a26cb --- /dev/null +++ b/.therapist.yml @@ -0,0 +1,8 @@ +actions: + flake8: + run: flake8 {files} + include: "*.py" + exclude: "docs/" + eslint: + run: ./node_modules/.bin/eslint {files} + include: "*.js" diff --git a/Dockerfile b/Dockerfile index 8ab52af0..e86e45ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,8 +23,7 @@ RUN ./node_modules/.bin/webpack && \ mkdir -p __version__ && \ mkdir -p /test_artifacts && \ chmod 777 /test_artifacts && \ - git rev-parse HEAD > __version__/commit && \ - rm -rf .git + git rev-parse HEAD > __version__/commit USER app ENV DJANGO_SETTINGS_MODULE=normandy.settings \ diff --git a/README.md b/README.md index 4a75f938..aa263559 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Normandy +# Normandy [![CircleCI](https://img.shields.io/circleci/project/mozilla/normandy.svg)](https://circleci.com/gh/mozilla/normandy/tree/master) Normandy is the server-side implementation of the [Recipe Server][]. It serves bundles of JavaScript to various clients (Firefox browsers) based on certain diff --git a/bin/ci/contract-tests.sh b/bin/ci/contract-tests.sh new file mode 100755 index 00000000..ba5b7179 --- /dev/null +++ b/bin/ci/contract-tests.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -u +BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +echo $BASE_DIR +cd $BASE_DIR + +echo "Setting up Normandy" +createdb normandy +./docker-run.sh -i -t ./manage.py migrate +./docker-run.sh -i -t ./manage.py update_actions +echo "Starting Normandy server" +SERVER_ID=$(./docker-run.sh -e DJANGO_CONFIGURATION=ProductionInsecure -d) + +docker run --net host -e CHECK_PORT=8000 -e CHECK_HOST=localhost giorgos/takis +echo "Running acceptance tests" +./docker-run.sh py.test contract-tests/ \ + --server http://localhost:8000 \ + --junitxml=/test_artifacts/pytest.xml +STATUS=$? + +docker kill $SERVER_ID +exit $STATUS diff --git a/bin/deploy/dockerhub.sh b/bin/ci/deploy-dockerhub.sh similarity index 100% rename from bin/deploy/dockerhub.sh rename to bin/ci/deploy-dockerhub.sh diff --git a/bin/ci/docker-run.sh b/bin/ci/docker-run.sh new file mode 100755 index 00000000..c18d91c6 --- /dev/null +++ b/bin/ci/docker-run.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +set -x + +DOCKER_ARGS=( ) + +if [[ -v CIRCLE_TEST_REPORTS ]]; then + DOCKER_ARGS+=(--volume $CIRCLE_TEST_REPORTS:/test_artifacts) +fi + +if [[ -f ~/cache/GeoLite2-Country.mmdb ]]; then + DOCKER_ARGS+=(--volume ~/cache/GeoLite2-Country.mmdb:/app/GeoLite2-Country.mmdb) +fi + +# Parse out known command flags to give to `docker run` instead of the command +# After hitting the first unrecognized argument, assume everything else it the +# command to run +while [ $# -ge 1 ]; do + case $1 in + --) + shift + break + ;; + -d|-i|-t) + DOCKER_ARGS+=($1) + shift + ;; + -p|-e) + DOCKER_ARGS+=($1 $2) + shift + shift + ;; + *) + break + ;; + esac +done + +docker run \ + --env DJANGO_CONFIGURATION=Test \ + --net host \ + "${DOCKER_ARGS[@]}" \ + normandy:build \ + "$@" diff --git a/bin/karma-ci.js b/bin/ci/karma-ci.js similarity index 79% rename from bin/karma-ci.js rename to bin/ci/karma-ci.js index 2ab10741..57d76085 100644 --- a/bin/karma-ci.js +++ b/bin/ci/karma-ci.js @@ -1,7 +1,8 @@ +/* eslint-disable no-var, prefer-template */ var karma = require('karma'); var karmaConfig = require('karma/lib/config'); -var config = karmaConfig.parseConfig(__dirname + '/../karma.conf.js', { +var config = karmaConfig.parseConfig(__dirname + '/../../karma.conf.js', { browsers: [], oneShot: true, reporters: ['spec', 'junit'], diff --git a/bin/runsslserver.sh b/bin/runsslserver.sh index 75030f63..d75ae7d6 100755 --- a/bin/runsslserver.sh +++ b/bin/runsslserver.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # This script starts an SSL development server so that you don't have to # pass annoying arguments to manage.py all the time. BASE_DIR="$(dirname "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )")" diff --git a/circle.yml b/circle.yml index 9d15cd7e..1f7aa83a 100644 --- a/circle.yml +++ b/circle.yml @@ -13,6 +13,8 @@ dependencies: - if [ -e ~/cache/docker/image.tar ]; then echo "Loading image.tar"; docker load -i ~/cache/docker/image.tar || rm ~/cache/docker/image.tar; fi # build image - docker build -t normandy:build . + # write the sha256 sum to an artifact to make image verification easier + - docker inspect -f '{{.Config.Image}}' normandy:build | tee $CIRCLE_ARTIFACTS/docker-image-shasum256.txt # Get MaxMind GeoIP database - cd ~/cache/ && ~/normandy/bin/download_geolite2.sh @@ -20,39 +22,29 @@ test: pre: - chmod -R 777 $CIRCLE_TEST_REPORTS override: - # Run Python lint checks - - > - docker run --net host - -v $CIRCLE_TEST_REPORTS:/test_artifacts - normandy:build flake8 --output-file /test_artifacts/flake8.txt normandy - # Run JS lint checks - - > - docker run --net host - -v $CIRCLE_TEST_REPORTS:/test_artifacts - normandy:build node_modules/.bin/eslint normandy --format junit --output-file /test_artifacts/eslint.xml + # Run lint checks + - bin/ci/docker-run.sh therapist run --use-tracked-files # Run Python tests - - > - docker run --net host -e DJANGO_CONFIGURATION=Test - -v $CIRCLE_TEST_REPORTS:/test_artifacts - -v ~/cache/GeoLite2-Country.mmdb:/app/GeoLite2-Country.mmdb - normandy:build py.test --junitxml=/test_artifacts/pytest.xml + - bin/ci/docker-run.sh py.test --junitxml=/test_artifacts/pytest.xml normandy/ # Start Karma test server, and run them in Firefox - > ( + echo Waiting for Karma server to start; docker run --net host -e CHECK_PORT=9876 -e CHECK_HOST=localhost giorgos/takis; echo Starting Firefox; firefox localhost:9876 ) & - docker run --net host -p 9876:9876 - -v $CIRCLE_TEST_REPORTS:/test_artifacts - normandy:build node bin/karma-ci.js + bin/ci/docker-run.sh -p 9876:9876 node bin/ci/karma-ci.js + # Start the app, and run acceptance tests + - bin/ci/contract-tests.sh + post: - # Save test artifacts for Python and Lint tests - - > - docker run -v $CIRCLE_TEST_REPORTS:/test_artifacts - normandy:build flake8_junit /test_artifacts/flake8.txt /test_artifacts/flake8.xml # Clean up old image and save the new one - - mkdir -p ~/cache/docker; test '!' -e ~/cache/docker/image.tar || rm ~/cache/docker/image.tar; docker save normandy:build > ~/cache/docker/image.tar; ls -l ~/cache/docker + - > + mkdir -p ~/cache/docker; + rm -f ~/cache/docker/image.tar; + docker save normandy:build > ~/cache/docker/image.tar; + ls -l ~/cache/docker # appropriately tag and push the container to dockerhub deployment: @@ -61,7 +53,7 @@ deployment: commands: # set DOCKER_DEPLOY=true in Circle UI to do deploys - "${DOCKER_DEPLOY:-false}" - - bin/deploy/dockerhub.sh latest + - bin/ci/deploy-dockerhub.sh latest tags: # push all tags @@ -69,4 +61,4 @@ deployment: commands: # set DOCKER_DEPLOY=true in Circle UI to do deploys - "${DOCKER_DEPLOY:-false}" - - bin/deploy/dockerhub.sh "$CIRCLE_TAG" + - bin/ci/deploy-dockerhub.sh "$CIRCLE_TAG" diff --git a/normandy/recipes/static/actions/console-log/index.js b/client/actions/console-log/index.js similarity index 100% rename from normandy/recipes/static/actions/console-log/index.js rename to client/actions/console-log/index.js diff --git a/normandy/recipes/static/actions/console-log/package.json b/client/actions/console-log/package.json similarity index 100% rename from normandy/recipes/static/actions/console-log/package.json rename to client/actions/console-log/package.json diff --git a/normandy/recipes/static/actions/show-heartbeat/index.js b/client/actions/show-heartbeat/index.js similarity index 100% rename from normandy/recipes/static/actions/show-heartbeat/index.js rename to client/actions/show-heartbeat/index.js diff --git a/normandy/recipes/static/actions/show-heartbeat/package.json b/client/actions/show-heartbeat/package.json similarity index 100% rename from normandy/recipes/static/actions/show-heartbeat/package.json rename to client/actions/show-heartbeat/package.json diff --git a/normandy/control/tests/.eslintrc b/client/actions/tests/.eslintrc similarity index 100% rename from normandy/control/tests/.eslintrc rename to client/actions/tests/.eslintrc diff --git a/normandy/recipes/tests/actions/console-log.js b/client/actions/tests/console-log.js similarity index 85% rename from normandy/recipes/tests/actions/console-log.js rename to client/actions/tests/console-log.js index 725b2c29..4848c6dc 100644 --- a/normandy/recipes/tests/actions/console-log.js +++ b/client/actions/tests/console-log.js @@ -1,5 +1,5 @@ import { mockNormandy } from './utils'; -import ConsoleLogAction from '../../static/actions/console-log/index'; +import ConsoleLogAction from '../console-log/'; describe('ConsoleLogAction', () => { diff --git a/normandy/recipes/tests/actions/index.js b/client/actions/tests/index.js similarity index 100% rename from normandy/recipes/tests/actions/index.js rename to client/actions/tests/index.js diff --git a/normandy/recipes/tests/actions/show-heartbeat.js b/client/actions/tests/show-heartbeat.js similarity index 99% rename from normandy/recipes/tests/actions/show-heartbeat.js rename to client/actions/tests/show-heartbeat.js index fa70f60b..9edadf51 100644 --- a/normandy/recipes/tests/actions/show-heartbeat.js +++ b/client/actions/tests/show-heartbeat.js @@ -1,5 +1,5 @@ import { mockNormandy, pluginFactory } from './utils'; -import ShowHeartbeatAction from '../../static/actions/show-heartbeat/index'; +import ShowHeartbeatAction from '../show-heartbeat/'; function surveyFactory(props = {}) { diff --git a/normandy/recipes/tests/actions/utils.js b/client/actions/tests/utils.js similarity index 100% rename from normandy/recipes/tests/actions/utils.js rename to client/actions/tests/utils.js diff --git a/normandy/recipes/static/actions/utils.js b/client/actions/utils.js similarity index 100% rename from normandy/recipes/static/actions/utils.js rename to client/actions/utils.js diff --git a/normandy/control/static/control/js/actions/ControlActions.js b/client/control/actions/ControlActions.js similarity index 98% rename from normandy/control/static/control/js/actions/ControlActions.js rename to client/control/actions/ControlActions.js index f093c52b..544b7e86 100644 --- a/normandy/control/static/control/js/actions/ControlActions.js +++ b/client/control/actions/ControlActions.js @@ -95,6 +95,8 @@ const apiRequestMap = { settings: { method: 'DELETE', }, + successNotification: 'Recipe deleted.', + errorNotification: 'Error deleting recipe.', }; }, }; diff --git a/normandy/control/static/control/js/app.js b/client/control/app.js similarity index 100% rename from normandy/control/static/control/js/app.js rename to client/control/app.js diff --git a/normandy/control/static/control/js/components/ActionForm.js b/client/control/components/ActionForm.js similarity index 94% rename from normandy/control/static/control/js/components/ActionForm.js rename to client/control/components/ActionForm.js index ee31b0bf..acf04094 100644 --- a/normandy/control/static/control/js/components/ActionForm.js +++ b/client/control/components/ActionForm.js @@ -35,7 +35,7 @@ export default reduxForm({ break; default: - throw new Error(`Unexpected action name: "${name}"`); + throw new Error(`Unexpected action name: "${props.name}"`); } if (props.recipe && props.recipe.action === props.name) { diff --git a/client/control/components/ControlApp.js b/client/control/components/ControlApp.js new file mode 100644 index 00000000..c05367e4 --- /dev/null +++ b/client/control/components/ControlApp.js @@ -0,0 +1,28 @@ +import React, { PropTypes as pt } from 'react'; +import Header from './Header.js'; +import Notifications from './Notifications.js'; + +export default function ControlApp({ children, location, routes, params }) { + return ( +
+ +
+
+ { + React.Children.map(children, child => React.cloneElement(child)) + } +
+
+ ); +} +ControlApp.propTypes = { + children: pt.object.isRequired, + location: pt.object.isRequired, + routes: pt.object.isRequired, + params: pt.object.isRequired, +}; diff --git a/normandy/control/static/control/js/components/DeleteRecipe.js b/client/control/components/DeleteRecipe.js similarity index 100% rename from normandy/control/static/control/js/components/DeleteRecipe.js rename to client/control/components/DeleteRecipe.js diff --git a/normandy/control/static/control/js/components/Header.js b/client/control/components/Header.js similarity index 100% rename from normandy/control/static/control/js/components/Header.js rename to client/control/components/Header.js diff --git a/normandy/control/static/control/js/components/NoMatch.js b/client/control/components/NoMatch.js similarity index 100% rename from normandy/control/static/control/js/components/NoMatch.js rename to client/control/components/NoMatch.js diff --git a/normandy/control/static/control/js/components/Notifications.js b/client/control/components/Notifications.js similarity index 100% rename from normandy/control/static/control/js/components/Notifications.js rename to client/control/components/Notifications.js diff --git a/normandy/control/static/control/js/components/RecipeContainer.js b/client/control/components/RecipeContainer.js similarity index 100% rename from normandy/control/static/control/js/components/RecipeContainer.js rename to client/control/components/RecipeContainer.js diff --git a/normandy/control/static/control/js/components/RecipeForm.js b/client/control/components/RecipeForm.js similarity index 73% rename from normandy/control/static/control/js/components/RecipeForm.js rename to client/control/components/RecipeForm.js index 0a60e5fc..72801c71 100644 --- a/normandy/control/static/control/js/components/RecipeForm.js +++ b/client/control/components/RecipeForm.js @@ -1,18 +1,21 @@ import React, { PropTypes as pt } from 'react'; import { Link } from 'react-router'; import { push } from 'react-router-redux'; -import { destroy, reduxForm, getValues } from 'redux-form'; -import jexl from 'jexl'; +import { destroy, stopSubmit, reduxForm, getValues } from 'redux-form'; +import { _ } from 'underscore'; -import { makeApiRequest, recipeUpdated, recipeAdded } from '../actions/ControlActions.js'; +import { makeApiRequest, recipeUpdated, recipeAdded, showNotification } + from '../actions/ControlActions.js'; import composeRecipeContainer from './RecipeContainer.js'; import ActionForm from './ActionForm.js'; import CheckboxField from './form_fields/CheckboxField.js'; import FormField from './form_fields/FormFieldWrapper.js'; +import JexlEnvironment from '../../selfrepair/JexlEnvironment.js'; export class RecipeForm extends React.Component { - propTypes = { + static propTypes = { dispatch: pt.func.isRequired, + fields: pt.object.isRequired, formState: pt.object.isRequired, recipeId: pt.number.isRequired, submitting: pt.bool.isRequired, @@ -52,7 +55,8 @@ export class RecipeForm extends React.Component { validateForm(formValues) { const jexlExpression = formValues.filter_expression; - return jexl.eval(jexlExpression, {}); + const jexlEnv = new JexlEnvironment({}); + return jexlEnv.eval(jexlExpression, {}); } submitForm() { @@ -65,6 +69,10 @@ export class RecipeForm extends React.Component { return this.validateForm(combinedFormValues) .catch(() => { + dispatch(showNotification({ + messageType: 'error', + message: 'Recipe cannot be saved. Please correct any errors listed in the form below.', + })); throw { filter_expression: 'Invalid Expression', }; @@ -136,7 +144,6 @@ export class RecipeForm extends React.Component { } } RecipeForm.propTypes = { - fields: React.PropTypes.object.isRequired, }; export default composeRecipeContainer(reduxForm({ @@ -147,10 +154,43 @@ export default composeRecipeContainer(reduxForm({ ? props.location.state.selectedRevision : null; + const formatErrors = payload => { + let errors = payload; + + /* If our payload is an object, process each error in the object + Otherwise, it is a string and will be returned immediately */ + if (_.isObject(payload)) { + const invalidFields = Object.keys(payload); + if (invalidFields.length > 0) { + /* If our error keys are integers, it means they correspond + to an array field and we want to present errors as an array + e.g. { surveys: {0: {title: 'err'}}, {2: {weight: 'err'}} } + => { surveys: [{title: 'err'}, null, {weight: 'err'}] } */ + errors = isNaN(invalidFields[0]) ? {} : []; + + invalidFields.forEach(fieldName => { + errors[fieldName] = formatErrors(payload[fieldName]); + }); + } + } + + return errors; + }; + + const onSubmitFail = errors => { + const { dispatch } = props; + const actionFormErrors = errors.arguments; + + if (actionFormErrors) { + dispatch(stopSubmit('action', formatErrors(actionFormErrors))); + } + }; + return { fields, initialValues: selectedRecipeRevision || props.recipe, viewingRevision: selectedRecipeRevision || props.location.query.revisionId, formState: state.form, + onSubmitFail, }; })(RecipeForm)); diff --git a/normandy/control/static/control/js/components/RecipeHistory.js b/client/control/components/RecipeHistory.js similarity index 56% rename from normandy/control/static/control/js/components/RecipeHistory.js rename to client/control/components/RecipeHistory.js index e2c54a75..c7b0cdd6 100644 --- a/normandy/control/static/control/js/components/RecipeHistory.js +++ b/client/control/components/RecipeHistory.js @@ -4,8 +4,8 @@ import moment from 'moment'; import composeRecipeContainer from './RecipeContainer.js'; import { makeApiRequest } from '../actions/ControlActions.js'; -class RecipeHistory extends React.Component { - propTypes = { +export class RecipeHistory extends React.Component { + static propTypes = { dispatch: pt.func.isRequired, recipe: pt.object.isRequired, recipeId: pt.number.isRequired, @@ -14,7 +14,7 @@ class RecipeHistory extends React.Component { constructor(props) { super(props); this.state = { - revisionLog: [], + revisions: [], }; } @@ -29,32 +29,44 @@ class RecipeHistory extends React.Component { dispatch(makeApiRequest('fetchRecipeHistory', { recipeId })) .then(history => { this.setState({ - revisionLog: history, + revisions: history, }); }); } render() { const { recipe, dispatch } = this.props; - return ( -
-

Viewing revision log for: {recipe ? recipe.name : ''}

- -
- ); + const { revisions } = this.state; + return ; } } -class HistoryItem extends React.Component { +export function HistoryList({ recipe, revisions, dispatch }) { + return ( +
+

Viewing revision log for: {recipe ? recipe.name : ''}

+ + + {revisions.map(revision => + + )} + +
+
+ ); +} +HistoryList.propTypes = { + dispatch: pt.func.isRequired, + recipe: pt.object.isRequired, + revisions: pt.arrayOf(pt.object).isRequired, +}; + +export class HistoryItem extends React.Component { static propTypes = { dispatch: pt.func.isRequired, revision: pt.shape({ @@ -62,6 +74,7 @@ class HistoryItem extends React.Component { revision_id: pt.number.isRequired, }).isRequired, date_created: pt.string.isRequired, + comment: pt.string.isRequired, }).isRequired, recipe: pt.shape({ revision_id: pt.number.isRequired, @@ -93,19 +106,25 @@ class HistoryItem extends React.Component { const isCurrent = revision.recipe.revision_id === recipe.revision_id; return ( -
  • -

    #{revision.recipe.revision_id}

    -

    + + #{revision.recipe.revision_id} + Created On: {moment(revision.date_created).format('MMM Do YYYY - h:mmA')} -

    - {isCurrent && ( -
    - - Current Revision -
    - )} -
  • + + + Comment: + {revision.comment || '--'} + + + {isCurrent && ( +
    + + Current Revision +
    + )} + + ); } } diff --git a/normandy/control/static/control/js/components/RecipeList.js b/client/control/components/RecipeList.js similarity index 100% rename from normandy/control/static/control/js/components/RecipeList.js rename to client/control/components/RecipeList.js diff --git a/normandy/control/static/control/js/components/RecipePreview.js b/client/control/components/RecipePreview.js similarity index 87% rename from normandy/control/static/control/js/components/RecipePreview.js rename to client/control/components/RecipePreview.js index 3302d14b..3abde8b7 100644 --- a/normandy/control/static/control/js/components/RecipePreview.js +++ b/client/control/components/RecipePreview.js @@ -1,7 +1,8 @@ import React, { PropTypes as pt } from 'react'; import classNames from 'classnames'; import composeRecipeContainer from './RecipeContainer.js'; -import { runRecipe } from '../../../../../selfrepair/static/js/self_repair_runner.js'; +import { runRecipe } from '../../selfrepair/self_repair_runner.js'; +import NormandyDriver from '../../selfrepair/normandy_driver.js'; class RecipePreview extends React.Component { propTypes = { @@ -29,13 +30,15 @@ class RecipePreview extends React.Component { attemptPreview() { const { recipe } = this.props; const { recipeAttempted } = this.state; + const driver = new NormandyDriver(); + driver.registerCallbacks(); if (recipe && !recipeAttempted) { this.setState({ recipeAttempted: true, }); - runRecipe(recipe, { testing: true }).then(() => { + runRecipe(recipe, driver, { testing: true }).then(() => { this.setState({ recipeExecuted: true, }); diff --git a/normandy/control/static/control/js/components/action_forms/ConsoleLogForm.js b/client/control/components/action_forms/ConsoleLogForm.js similarity index 100% rename from normandy/control/static/control/js/components/action_forms/ConsoleLogForm.js rename to client/control/components/action_forms/ConsoleLogForm.js diff --git a/normandy/control/static/control/js/components/action_forms/HeartbeatForm.js b/client/control/components/action_forms/HeartbeatForm.js similarity index 77% rename from normandy/control/static/control/js/components/action_forms/HeartbeatForm.js rename to client/control/components/action_forms/HeartbeatForm.js index 485d9b86..e4fd1ea6 100644 --- a/normandy/control/static/control/js/components/action_forms/HeartbeatForm.js +++ b/client/control/components/action_forms/HeartbeatForm.js @@ -21,12 +21,11 @@ export const HeartbeatFormFields = [ 'surveys[].weight', ]; -const formatLabel = labelName => labelName.replace(/([A-Z])/g, ' $1').toLowerCase(); - const SurveyListItem = props => { - const { survey, surveyIndex, isSelected, deleteSurvey, onClick } = props; + const { survey, surveyIndex, isSelected, hasErrors, deleteSurvey, onClick } = props; + return ( -
  • +
  • {survey.title.value || 'Untitled Survey'} { const { selectedSurvey, fields, showDefaults } = props; const surveyObject = selectedSurvey || fields.defaults; let headerText = 'Default Survey Values'; + let showAdditionalSurveyFields = false; let containerClasses = classNames('fluid-8', { active: selectedSurvey }); if (selectedSurvey) { + showAdditionalSurveyFields = true; headerText = selectedSurvey.title.initialValue || 'New survey'; } @@ -65,16 +67,22 @@ const SurveyForm = props => { }

    {headerText}

    - { - Object.keys(surveyObject).map(fieldName => - - ) + + {showAdditionalSurveyFields && + } + + + + + + + + + {showAdditionalSurveyFields && + + } + ); }; @@ -84,8 +92,8 @@ SurveyForm.propTypes = { showDefaults: pt.func, }; -class HeartbeatForm extends React.Component { - propTypes = { +export default class HeartbeatForm extends React.Component { + static propTypes = { fields: pt.object.isRequired, } @@ -141,6 +149,7 @@ class HeartbeatForm extends React.Component { survey={survey} surveyIndex={index} isSelected={_.isEqual(survey, selectedSurvey)} + hasErrors={_.some(survey, field => field.invalid)} onClick={() => ::this.setSelectedSurvey(survey)} deleteSurvey={::this.deleteSurvey} /> @@ -161,5 +170,3 @@ class HeartbeatForm extends React.Component { ); } } - -export default HeartbeatForm; diff --git a/normandy/control/static/control/js/components/form_fields/CheckboxField.js b/client/control/components/form_fields/CheckboxField.js similarity index 100% rename from normandy/control/static/control/js/components/form_fields/CheckboxField.js rename to client/control/components/form_fields/CheckboxField.js diff --git a/normandy/control/static/control/js/components/form_fields/FormFieldWrapper.js b/client/control/components/form_fields/FormFieldWrapper.js similarity index 88% rename from normandy/control/static/control/js/components/form_fields/FormFieldWrapper.js rename to client/control/components/form_fields/FormFieldWrapper.js index 0440ba05..d4ee08de 100644 --- a/normandy/control/static/control/js/components/form_fields/FormFieldWrapper.js +++ b/client/control/components/form_fields/FormFieldWrapper.js @@ -1,4 +1,5 @@ import React, { PropTypes as pt } from 'react'; +import NumberField from './NumberField.js'; const SelectMenu = props => { const { options, onChange, field } = props; @@ -26,6 +27,9 @@ const FormField = props => { case 'text': fieldType = (); break; + case 'number': + fieldType = (); + break; case 'textarea': fieldType = (