2
\$\begingroup\$

Exercise assignment:

Build a calculator with React which can add, subtract, multiply and divide.

  1. See image below for reference
  2. Give it whatever design and colors you want
  3. Each text field should have a placeholder
  4. The result field should be able to be copied but not edited
  5. Make sure your math is correct and calculator is well tested
  6. Post final Github solution and link to the forums Example

  7. Remember, the better you make this the better potential you have of using it in the future.

Here's my uncompiled JSX and SASS:

// Main.jsx as starting point of the app.
var React = require('react');
var ReactDOM = require('react-dom');
var Calculator = require('./components/Calculator.jsx');

var NAV_BUTTONS = [
  { value: '+ Add',
    operation: 'add'
  },
  { value: '- Subtract',
    operation: 'subtract'
  },
  { value: 'x Multiply',
    operation: 'multiply'
  },
  { value: '/ Divide',
    operation: 'divide'
  }
];

ReactDOM.render(<Calculator navButtons={ NAV_BUTTONS } />, document.getElementById('app'));

// Calculator.jsx - Main app-component
var React = require('react');
var TextBox = require('./TextBox.jsx');
var Button = require('./Button.jsx');

var Calculator = React.createClass({
  INIT_STATE: { a: 0,
                b: 0,
                placeholderText: 'Enter a number ...',
                resultBox: '',
                aClass: 'input-box',
                bClass: 'input-box',
                aDisabled: false,
                bDisabled: false,
                buttonsDisabled: 'disabled' },
  operations: {
    'add': function() {
      return this.state.a + this.state.b;
    },
    'subtract': function() {
      return this.state.a - this.state.b;
    },
    'multiply': function() {
      return this.state.a * this.state.b;
    },
    'divide': function() {
      return this.state.a / this.state.b;
    }
  },
  getInitialState: function() {
    return this.INIT_STATE; 
  },
  updateNumbers: function(variable, reference) { 
    var val = parseFloat(reference.value);
    var varClass = [variable + 'Class'];
    
    if (typeof val === 'number' && !isNaN(val)) {
      if (this.state[variable + 'Class'].indexOf('invalid-input') > -1) {
        this.setState({
          [varClass]: 'input-box' 
        })
      }
      
      this.setState({
        [variable]: val,
        buttonsDisabled: ''
      });
    } else {
      this.setState({
        [varClass]: [varClass] + ' invalid-input',
        buttonsDisabled: 'disabled'
      });
    }
  },
  triggerOperation: function(operation) {
    var result = this.operations[operation].call(this);
    
    this.setState({
        aDisabled: 'disabled',
        bDisabled: 'disabled',
        buttonsDisabled: 'disabled'
      });

    this.refs.resultBox.refs.inputElement.value = result;
  },
  resetForm: function() {
    function resetElement(itemName, placeholder, disabled) {
      this.refs[itemName].refs.inputElement.value = ''; // Value must be empty f. placeholder to appear.
      this.refs[itemName].refs.inputElement.disabled = disabled;
      this.refs[itemName].refs.inputElement.placeholder = placeholder;
    }
    
    resetElement.call(this, 'a', this.INIT_STATE.placeholderText);
    resetElement.call(this, 'b', this.INIT_STATE.placeholderText);
    resetElement.call(this, 'resultBox', this.INIT_STATE.resultBox, 'disabled');
   
    this.setState({
      a: 0,
      b: 0,
      aClass: 'input-box',
      bClass: 'input-box',
      buttonsDisabled: 'disabled'
    });
  },
  render: function() {
    var that = this;
    
    var navButtons = this.props.navButtons.map(function(button) {
      return (
        <div>
          <Button value={ button.value } classDiv="large-3 medium-6 column"
                  classButton="calculation-method nav-button"
                  handler={ that.triggerOperation } operation={ button.operation } disabled={ that.state.buttonsDisabled }/>
        </div>
      );
    });
    
    return (
      <div className="calculator">
        
        <div className="row">
          <h1>Simple calculator</h1>
        </div>
        
        <div className="row">
          <TextBox divClass="large-6 columns"
                   placeholder={ this.state.placeholderText }
                   id="a" textBoxClass={ this.state.aClass }
                   ref="a"
                   value={ this.state.a }
                   changeHandler={ this.updateNumbers }
                   variable="a"
                   disabled={ this.state.aDisabled }
                   />
          <TextBox divClass="large-6 columns"
                   placeholder={ this.state.placeholderText }
                   id="b" textBoxClass={ this.state.bClass }
                   ref="b"
                   value={ this.state.b }
                   changeHandler={ this.updateNumbers }
                   variable="b"
                   disabled={ this.state.bDisabled }
                   />
        </div>
        
        <div className="row">
          { navButtons }
        </div>
        
        <div className="row">
          <TextBox divClass="medium-9 columns"
                   placeholder={ this.INIT_STATE.resultBox }
                   ref="resultBox" textBoxClass="input-box"
                   disabled="disabled" />
          <Button value="Clear" classDiv="medium-3 columns"
                  classButton="attention nav-button"
                  handler={ this.resetForm } />
        </div>     
      </div>
    );
  }
});

module.exports = Calculator;

// Button component 
var React = require('react');

var Button = React.createClass({
  render: function() {
    function notify(e) {
      this.props.handler(e.target.dataset.operation);
    }
    
    return (
      <div className={ this.props.classDiv }>
        <button href='#' className={ this.props.classButton } 
                    onClick={ notify.bind(this) }
                    data-operation={ this.props.operation }
                    disabled={ this.props.disabled } >
          { this.props.value } 
        </button>
      </div>
    );
  }
});

module.exports = Button;

// TextBox component
var React = require('react');

var TextBox = React.createClass({
  notify: function() {
    let item = this.refs.inputElement;

    this.props.changeHandler(item.dataset.variable, item);
  },
  render: function() {   
    return (
      <div className={ this.props.divClass }
           ref={ this.props.id }>
          <input type="text"
                 placeholder={ this.props.placeholder} 
                 ref="inputElement" 
                 className={ this.props.textBoxClass }
                 disabled={ this.props.disabled } 
                 onChange={ this.notify }
                 data-variable={ this.props.variable } 
                 />
      </div>
    );
  }
});

module.exports = TextBox;
$lightChange: 25%;
$borderRadius: 6px;

@mixin addPseudoClasses($selector, $color) {
  #{$selector}:visited, #{$selector}:hover {
    color: white;
  }

  #{$selector}:hover {
    background: linear-gradient(lighten($color, $lightChange), $color); 
    color: white;
    cursor: pointer;
  }

    #{$selector}:active {
    opacity: 0.6;
    box-shadow: 1px 1px 0 black;
  }
}

html, body {
	height: 100%;
}

body {
  background: linear-gradient(to top, #403B4A , #E7E9BB);
}

.nav-button {
  text-decoration: none;
  color: green;
  padding: 10px 20px;
  text-align: center;
  font-weight: 900;
  font-size: 1.2rem;
  margin-bottom: 16px;
  display: inline-block;
  width: 100%;
  border-radius: $borderRadius;
  letter-spacing: 1px;
  box-shadow: 2px 2px 0 black;
}

.nav-button[disabled] {
  color: crimson;
}

.calculation-method { 
  background: linear-gradient(to top, #abbaab ,#ffffff); 
}

@include addPseudoClasses('.calculation-method', #344334);

h1 {
  text-align: center;
  margin: 20px 0 30px;
  letter-spacing: 2px;
}

.attention {
  background: linear-gradient(to top, darken(#ED4264, $lightChange), #FFEDBC);
  text-transform: uppercase;
  color: white;
}

@include addPseudoClasses('.attention', red);

.invalid-input {
  border-color: red !important;
  background-color: pink !important;
}

input[type=text] {
  border-radius: $borderRadius !important;
  box-shadow: inset 1px 1px 0 black;
  padding-left: 20px;
  font-weight: 900;
}

The full project-code can be found on GitHub. Here's a live demo.

Have I fulfilled the assignment demands? Moreover, the calculator works, but I'm sure the programming could be improved.

\$\endgroup\$
2
  • \$\begingroup\$ Requirement 4 is not fulfilled. And why does selecting an operation disable the inputs? => Remember which operation is currently active, and continue reacting! \$\endgroup\$
    – Ext3h
    Commented Sep 25, 2016 at 11:22
  • \$\begingroup\$ I'm not with you. I've tried it again and I would say 4 is fulfilled. \$\endgroup\$ Commented Sep 25, 2016 at 11:56

1 Answer 1

2
\$\begingroup\$
INIT_STATE: { a: 0,
              b: 0,
              placeholderText: 'Enter a number ...',
              resultBox: '',
              aClass: 'input-box',
              bClass: 'input-box',
              aDisabled: false,
              bDisabled: false,
              buttonsDisabled: 'disabled' },

Half of that stuff doesn't belong into the state or is redundant.

E.g. all the "disabled" properties. They are only used in conjunction, and could have been derived all from a single boolean during the render() method.

Neither does the placeholderText belong into the state. It's a constant.

aClass: 'input-box',
bClass: 'input-box',

These two are tricky. While they do represent something which does belong in the state (the information whether the input is malformed or not!), the actual list of classes should have been computed during the render() method, and this information should have been a simple boolean.


Your handling of invalid inputs is plain wrong.

While you did right to disable the calculation as soon as an input becomes invalid, you totally messed up the logic to re-enable the calculations.

Test it yourself, enter something invalid into both input fields, fix up only one, and the buttons become re-enabled despite the other field still containing garbage.

The problem is wrong use of the state. Whether the buttons are disabled or not, is actually a function of whether input a and input b are valid.

Whether they are valid or not individually belongs into the state - however the computed result of that combination does not! If you had computed the state of the buttons from the state of the inputs in the render() method, this problem wouldn't have occurred.


Aside from messing up the state tracking on invalid inputs, your input validation in general is sloppy.

Currently, your calculator happily confirms that 3e2 Elephants + 4k Mice = 302.

Say you only checked whether the input can in any form be parsed as a numerical, but not whether the field didn't contain additional garbage with potentially ambiguous meaning.


  triggerOperation: function(operation) {
    var result = this.operations[operation].call(this);

    this.setState({
        aDisabled: 'disabled',
        bDisabled: 'disabled',
        buttonsDisabled: 'disabled'
      });

    this.refs.resultBox.refs.inputElement.value = result;
  },

Performing calculations directly on input events goes against the fundamental principle of the react framework. You should have stored operation in the state instead, and only perform the actual calculation during the render() method.

Is goes without saying that updating the DOM inside this callback is completely off limits.


Disabling the inputs without any good reason is providing a bad user experience. The point about using the react framework is, that you can recompute all results (in this case the result field) from the state gathered.

So why force the user to discard that state?

Just continue updating the state as the user continues providing input.

What you should have added, but didn't, is a visual feedback which operation is currently active. Trivial, if you had recorded the chosen operation in the state.


Speaking of disabling fields. Why did you use the disabled attribute? The requirement only speaks of making the result field read only, and the attribute which does that is named readonly.

Disabling the input fields prevents, according to specification and in most browsers, all interaction with the input fields, including the ability to select or copy text.


Not that your input disabling logic would work properly either way. It only disables the inputs the first time an operation is performed, but once the inputs are re-enabled by a form reset, they stay enabled.

Your resetForm() method is literally breaking react, because it once again touches the DOM directly and outside the render() method.

In case you haven't realized or don't know it yet:

Any DOM manipulation your perform outside of render() is not properly tracked by react. React will assume that the DOM is still in the state interfered from state, and will not update it.


<Button value={ button.value } classDiv="large-3 medium-6 column"/>

Providing different layouts for different screen sizes? Good. Even though a "medium" sized screen hardly requires to switch to an effective 1 column layout yet. That would only been appropriate for "small".

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.