Mori
Mori

Reputation: 6780

JavaScript: Get number of edited/updated inputs

Scenario

Every semester my students need to take at least one science, one physics and one history test. The following form gives the right average grades as well as the final grade of a student:

document.getElementById('calcBtn').addEventListener('click', function() {
  var scienceTest1 = document.getElementById('scienceTest1').value;
  var scienceTest2 = document.getElementById('scienceTest2').value;
  var scienceTest3 = document.getElementById('scienceTest3').value;
  var physicsTest1 = document.getElementById('physicsTest1').value;
  var physicsTest2 = document.getElementById('physicsTest2').value;
  var physicsTest3 = document.getElementById('physicsTest3').value;
  var historyTest1 = document.getElementById('historyTest1').value;
  var historyTest2 = document.getElementById('historyTest2').value;
  var historyTest3 = document.getElementById('historyTest3').value;
  var scienceAverage = document.getElementById('scienceAverage');
  var physicsAverage = document.getElementById('physicsAverage');
  var historyAverage = document.getElementById('historyAverage');
  var finalGrade = document.getElementById('finalGrade');
  scienceAverage.value = (Number(scienceTest1) + Number(scienceTest2) + Number(scienceTest3)) / 3;
  physicsAverage.value = (Number(physicsTest1) + Number(physicsTest2) + Number(physicsTest3)) / 3;
  historyAverage.value = (Number(historyTest1) + Number(historyTest2) + Number(historyTest3)) / 3;
  finalGrade.value = (scienceAverage.value * 5 + physicsAverage.value * 3 + historyAverage.value * 2) / 10;
});
<form>
  Science: <input type="number" id="scienceTest1">
  <input type="number" id="scienceTest2">
  <input type="number" id="scienceTest3">
  <output id="scienceAverage"></output>
  <br> Physics: <input type="number" id="physicsTest1">
  <input type="number" id="physicsTest2">
  <input type="number" id="physicsTest3">
  <output id="physicsAverage"></output>
  <br> History: <input type="number" id="historyTest1">
  <input type="number" id="historyTest2">
  <input type="number" id="historyTest3">
  <output id="historyAverage"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
  <output id="finalGrade"></output>
</form>

The problem is it only works if all the fields are edited. If the student doesn't take some tests, the average grades won't show the correct values. I know it's because of dividing by the fixed number 3 when it calculates the average grades:

scienceAverage.value = (Number(scienceTest1) + Number(scienceTest2) + Number(scienceTest3)) / 3;
physicsAverage.value = (Number(physicsTest1) + Number(physicsTest2) + Number(physicsTest3)) / 3;
historyAverage.value = (Number(historyTest1) + Number(historyTest2) + Number(historyTest3)) / 3;

Question

What is a simple approach to get the number of changed input fields in the following single row? I'll try to understand your method and then develop my form to multiple rows.

document.getElementById('calcBtn').addEventListener('click', function() {
  var test1 = document.getElementById('test1').value;
  var test2 = document.getElementById('test2').value;
  var test3 = document.getElementById('test3').value;
  var average = document.getElementById('average');
  average.value = (Number(test1) + Number(test2) + Number(test3)) / 3;
});
<form>
  <input type="number" id="test1">
  <input type="number" id="test2">
  <input type="number" id="test3">
  <output id="average"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
</form>

Upvotes: 23

Views: 1459

Answers (14)

Mori
Mori

Reputation: 6780

What is a simple approach to get the number of changed input fields

We can assign each input a custom data attribute equal to the input value and then exclude the empty ones when counting them. The following generic script can be used for any number of courses:

var form = document.querySelector('form');

function calculateAverage(fieldset) {
  var total = 0;
  var inputs = fieldset.querySelectorAll('input');
  for (var input of inputs) {
    total += Number(input.value);
    input.dataset.value = input.value;
  }
  return total / fieldset.querySelectorAll('input:not([data-value=""])').length;
}

function displayAverages() {
  var fieldsets = form.querySelectorAll('fieldset');
  for (var fieldset of fieldsets) {
    var avg = calculateAverage(fieldset);
    var output = fieldset.querySelector('output');
    if (isNaN(avg)) {
      output.value = 'Please enter a grade.';
    } else {
      output.value = 'Average: ' + avg.toFixed(1);
    }
  }
}

form.querySelector('button').addEventListener('click', displayAverages);
body {
  display: flex;
}

fieldset {
  margin: 0 0 16px;
}

input {
  width: 4em;
}

output {
  display: block;
  height: 1em;
  margin: 8px 0 0 2px;
}
<form>
  <fieldset>
    <legend>Physics</legend>
    <input type="number">
    <input type="number">
    <input type="number">
    <output></output>
  </fieldset>
  <fieldset>
    <legend>History</legend>
    <input type="number">
    <input type="number">
    <input type="number">
    <output></output>
  </fieldset>
  <button type="button">Calculate</button>
</form>

Upvotes: 1

bhansa
bhansa

Reputation: 7504

You can create an object called tests = {} and keep your tests as an array inside it, to give an example I have written some code for only one subject below which you can modify later.

Also, I have used classes instead of id's to reduce the complexity of code and repetitiveness.

Update: Updated the below code to be more dynamic and flexible to multiple subjects.

document.getElementById('calcBtn').addEventListener('click', function() {

  // create a tests object and have subject specific test inside it
  var tests = {
    "Science": [],
    "Physics": [],
    "History": []
  };

  //looping over all subjects 
  for (var key in tests) {

    // this can be looped as well if you have multiple subjects
    var test = document.getElementsByClassName(key);
    for (i = 0; i < test.length; i++) {
      if (test[i].value != null || test[i].value != "") {
        tests[key].push(Number(test[i].value));
      }
    }

    // populate average by using reduce function 
    document.getElementById(key).value = tests[key].reduce((prev, curr) => prev + curr) / tests[key].length;
  }
});
<form>
  <fieldset>
    <legend>Science</legend>
    <input type="number" class="Science">
    <input type="number" class="Science">
    <input type="number" class="Science">
    <output id="Science"></output>
  </fieldset>

  <fieldset>
    <legend>Physics</legend>
    <input type="number" class="Physics">
    <input type="number" class="Physics">
    <input type="number" class="Physics">
    <output id="Physics"></output>
  </fieldset>

  <fieldset>
    <legend>History</legend>
    <input type="number" class="History">
    <input type="number" class="History">
    <input type="number" class="History">
    <output id="History"></output>
  </fieldset>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
</form>

Upvotes: 2

joernneumeyer
joernneumeyer

Reputation: 46

My recommendation is, to avoid multiple input fields in order to read in multiple inputs. In my case, I am using semicolons to separate the individual values inside the input field. By doing so I am able to enter as many values as I want (at least one value). Therefore my form looks like the following:

<!-- form.html -->
<form>
  Science: <input type="text" id="scienceTest">
  <output id="scienceAverage"></output>
  <br> Physics: <input type="text" id="physicsTest">
  <output id="physicsAverage"></output>
  <br> History: <input type="text" id="historyTest">
  <output id="historyAverage"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
  <output id="finalGrade"></output>
</form>
<script src="script.js"></script>

And my JavaScript looks like this:

// script.js
(function() {
  var scienceTest = document.getElementById('scienceTest');
  var physicsTest = document.getElementById('physicsTest');
  var historyTest = document.getElementById('historyTest');
  var scienceAverage = document.getElementById('scienceAverage');
  var physicsAverage = document.getElementById('physicsAverage');
  var historyAverage = document.getElementById('historyAverage');
  var finalGrade = document.getElementById('finalGrade');

  function sumArray(sum, item) {
    return sum + item;
  }

  document.getElementById('calcBtn').addEventListener('click', function() {
    // fetch the string of the input and split into its separate numbers
    var scienceGradeStrings = scienceTest.value.split(";");
    var physicsGradeStrings = physicsTest.value.split(";");
    var historyGradeStrings = historyTest.value.split(";");

    // calculate the averages
    scienceAverage.value = scienceGradeStrings
      // convert the grades from strings to numbers
      .map(Number)
      // sum all grades together
      .reduce(sumArray, 0)
      // calculate the average grade
      / scienceGradeStrings.length;
    physicsAverage.value = physicsGradeStrings.map(Number).reduce(sumArray, 0) / physicsGradeStrings.length;
    historyAverage.value = historyGradeStrings.map(Number).reduce(sumArray, 0) / historyGradeStrings.length;
    finalGrade.value = (scienceAverage.value * 5 + physicsAverage.value * 3 + historyAverage.value * 2) / 10;
  });
})();

Upvotes: 1

Maksims Kitajevs
Maksims Kitajevs

Reputation: 225

var tests = [
    document.getElementById('test1').value || false,
    document.getElementById('test2').value || false,
    document.getElementById('test3').value || false
];

var average = 0,
    length = 0;

for (var i = 0; i < tests.length; i++) {
    if (tests[i] !== false) {
        average += Number( tests[i] );
        length ++;
    }
}

average = average / length;

This is ES5 solution. You can do shorter but this in my opinion is intuitive.

Upvotes: 4

Dacre Denny
Dacre Denny

Reputation: 30360

It looks like you need to check the values of inputs are valid numbers before using them in the arithmetic that calculates the per-course averages. One way to do this would be via the following check:

if (!Number.isNaN(Number.parseFloat(input.value))) {
  /* Use input.value in average calculation */
}

You might also consider adjusting your script and HTML as shown below, which would allow you to generalize and re-use the average calculation for each of the three classes as detailed below:

document.getElementById('calcBtn').addEventListener('click', function() {

  /* Generalise the calculation of updates for specified course type */
  const calculateForCourse = (cls) => {

    let total = 0
    let count = 0

    /* Select inputs with supplied cls selector and iterate each element */
    for (const input of document.querySelectorAll(`input.${cls}`)) {

      if (!Number.isNaN(Number.parseFloat(input.value))) {
      
        /* If input value is non-empty, increment total and count for
        subsequent average calculation */
        total += Number.parseFloat(input.value);
        count += 1;
      }
    }

    /* Cacluate average and return result */
    return { count, average : count > 0 ? (total / count) : 0 }
  }

  /* Calculate averages using shared function for each class type */
  const calcsScience = calculateForCourse('science')
  const calcsPhysics = calculateForCourse('physics')
  const calcsHistory = calculateForCourse('history')
  
  /* Update course averages */
  document.querySelector('output.science').value = calcsScience.average
  document.querySelector('output.physics').value = calcsPhysics.average
  document.querySelector('output.history').value = calcsHistory.average
  
  /* Update course counts */
  document.querySelector('span.science').innerText = `changed:${calcsScience.count}`
  document.querySelector('span.physics').innerText = `changed:${calcsPhysics.count}`
  document.querySelector('span.history').innerText = `changed:${calcsHistory.count}`

  /* Update final grade */
  var finalGrade = document.getElementById('finalGrade');

  finalGrade.value = (calcsScience.average * 5 + calcsPhysics.average * 3 + calcsHistory.average * 2) / 10;
});
<!-- Add class to each of the course types to allow script to distinguish
     between related input and output fields -->
<form>
  Science:
  <input type="number" class="science" id="scienceTest1">
  <input type="number" class="science" id="scienceTest2">
  <input type="number" class="science" id="scienceTest3">
  <output id="scienceAverage" class="science"></output>
  <span class="science"></span>
  <br> Physics:
  <input type="number" class="physics" id="physicsTest1">
  <input type="number" class="physics" id="physicsTest2">
  <input type="number" class="physics" id="physicsTest3">
  <output id="physicsAverage" class="physics"></output>
  <span class="physics"></span>
  <br> History:
  <input type="number" class="history" id="historyTest1">
  <input type="number" class="history" id="historyTest2">
  <input type="number" class="history" id="historyTest3">
  <output id="historyAverage" class="history"></output>
  <span class="history"></span>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
  <output id="finalGrade"></output>
</form>

Update

To extend on the first answer, please see the documentation in the snippet below responding to your question's update:

document.getElementById('calcBtn').addEventListener('click', function() {
  var test1 = document.getElementById('test1').value;
  var test2 = document.getElementById('test2').value;
  var test3 = document.getElementById('test3').value;
  var average = document.getElementById('average');
  
  /* This variable counts the number of inputs that have changed */
  var changesDetected = 0;
  
  /* If value of test1 field "not equals" the empty string, then 
  we consider this a "changed" field, so we'll increment our 
  counter variable accordinly */
  if(test1 != '') {
    changesDetected = changesDetected + 1;
  }
  /* Apply the same increment as above for test2 field */
  if(test2 != '') {
    changesDetected = changesDetected + 1;
  }
  /* Apply the same increment as above for test3 field */
  if(test3 != '') {
    changesDetected = changesDetected + 1;
  }
  
  /* Calculate average from changesDetected counter.
  We need to account for the case where no changes
  have been detected to prevent a "divide by zero" */
  if(changesDetected != 0) {
    average.value = (Number(test1) + Number(test2) + Number(test3)) / changesDetected;
  }
  else {
    average.value = 'Cannot calculate average'
  }
  
  /* Show a dialog to box to display the number of fields changed */
  alert("Detected that " + changesDetected + " inputs have been changed")
});
<form>
  <input type="number" id="test1">
  <input type="number" id="test2">
  <input type="number" id="test3">
  <output id="average"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
</form>

Update 2

The prior Update can be simplified with a loop like so:

document.getElementById('calcBtn').addEventListener('click', function() {
  
  let changesDetected = 0;
  let total = 0;
  const ids = ['test1', 'test2', 'test3'];
  
  for(const id of ids) {
    const value = document.getElementById(id).value;
    if(value != '') {
      changesDetected += 1;
      total += Number(value);
    }
  }
  
  var average = document.getElementById('average');
  
  if(changesDetected != 0) {
    average.value = total / changesDetected;
  }
  else {
    average.value = 'Cannot calculate average'
  }
    
  alert("Detected that " + changesDetected + " inputs have been changed")
});
<form>
  <input type="number" id="test1">
  <input type="number" id="test2">
  <input type="number" id="test3">
  <output id="average"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
</form>

Update 3

Another concise approach based on your JSFiddle would be the following:

document.getElementById('calculator').addEventListener('click', function() {
  var physicsAverage = document.getElementById('physicsAverage'),
    historyAverage = document.getElementById('historyAverage');

  physicsAverage.value = calculateAverageById('physics')
  historyAverage.value = calculateAverageById('history');
});

function calculateAverageById(id) {
  /* Get all input descendants of element with id */
  const inputs = document.querySelectorAll(`#${id} input`);

  /* Get all valid grade values from selected input elements */
  const grades = Array.from(inputs)
    .map(input => Number.parseFloat(input.value))
    .filter(value => !Number.isNaN(value));

  /* Return average of all grades, or fallback message if no valid grades present */
  return grades.length ? (grades.reduce((sum, grade) => (sum + grade), 0) / grades.length) : 'No assessment made!'
}
<form>
  <p id="physics">
    Physics:
    <input type="number">
    <input type="number">
    <input type="number">
    <output id="physicsAverage"></output>
  </p>
  <p id="history">
    History:
    <input type="number">
    <input type="number">
    <input type="number">
    <output id="historyAverage"></output>
  </p>
  <button type="button" id="calculator">Calculate</button>
</form>

The main differences here are:

  • the use of document.querySelectorAll(#${id} input); with a template literal to extract the input elements of a element with id
  • the use of Array.from(inputs) for a more readable means of converting the result of the query to an array
  • the use of Number.parseFloat and Number.isNaN when transforming and filtering input elements to valid numeric values for the subsequent average calculation

Hope that helps!

Upvotes: 17

Asav Vora
Asav Vora

Reputation: 71

document.getElementById('calcBtn').addEventListener('click', function() {
var testcount = [];
var count = 0;
testcount = Array.prototype.slice.call(document.getElementsByClassName('test1'))
for(var i=0;i<testcount.length;i++)
{
 if(Number(testcount[i].value) > 0)
 {
  count=count+1;
  }
 
}
  var test1 = document.getElementById('test1').value;
  var test2 = document.getElementById('test2').value;
  var test3 = document.getElementById('test3').value;
  var average = document.getElementById('average');
  average.value = (Number(test1) + Number(test2) + Number(test3)) / count;
});
<form>
  <input type="number" class="test1" id="test1">
  <input type="number" class="test1" id="test2">
  <input type="number" class="test1" id="test3">
  <output id="average"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
</form>

in the above, output is achieved by adding a class attribute in the input tag(give same name for same group of controls). Second thing is looping through this class object for getting count of non-empty or non-zero valued.

Upvotes: 1

josemigallas
josemigallas

Reputation: 3909

It's a bit ugly but you can consider test scores as booleans: if there's any test score that's worth a 1, otherwise a 0.

Since input.value is of type string, converting it to a boolean would give as a result false when the input is empty ("") or true when there's any number in it.

Using OP's smaller snippet:

document.getElementById('calcBtn').addEventListener('click', function() {
  var test1 = document.getElementById('test1').value;
  var test2 = document.getElementById('test2').value;
  var test3 = document.getElementById('test3').value;
  var testCount = Boolean(test1) + Boolean(test2) + Boolean(test3);
  // alternatively: var testCount = !!test1 + !!test2 + !!test3

  var average = document.getElementById('average');
  average.value = (Number(test1) + Number(test2) + Number(test3)) / testCount;
});
<form>
  <input type="number" id="test1">
  <input type="number" id="test2">
  <input type="number" id="test3">
  <output id="average"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
</form>

Upvotes: 5

Jordan Maduro
Jordan Maduro

Reputation: 998

A good start is to change your ID to Class to put your inputs into logical groups. The next step is to get the inputs from a particular group that has a value that is not null. We can do this by selecting for example .scienceTest and then filtering out empty string items.

I added a helper function values to extract the values from a nodelist and put them into a normal Array.

We can use a Boolean to test the empty strings. We also cast all strings to numbers using Number. This is done in the onlyNumbers function.

Next, we need to calculate the averages of each group. This is easy since we have a filtered list of numbers. All we do is calculate the sum and divide by the Array length. This is done with our little avrg function.

 

document.getElementById('calcBtn').addEventListener('click', function() {
  var scienceTest = getGrades('.scienceTest')
  var physicsTest = getGrades('.physicsTest')
  var historyTest = getGrades('.historyTest')
  
  var scienceAverage = document.getElementById('scienceAverage');
  var physicsAverage = document.getElementById('physicsAverage');
  var historyAverage = document.getElementById('historyAverage');
  
  var finalGrade = document.getElementById('finalGrade');
  
  scienceAverage.value = avrg(scienceTest)
  physicsAverage.value = avrg(physicsTest)
  historyAverage.value = avrg(historyTest)
  
  finalGrade.value = (scienceAverage.value * 5 + physicsAverage.value * 3 + historyAverage.value * 2) / 10;
  
});

function avrg(list) {
	return list.length ? list.reduce((acc, i) => acc + i, 0) / list.length : 0
}

function getGrades(selector) {
	return onlyNumbers(values(document.querySelectorAll(selector)))
}
function onlyNumbers(list) {
		return list.filter(Boolean).map(Number)
}

function values(nodelist) {
		return Array.prototype.map.call(nodelist, (node) => node.value)
}
<form>
  Science: <input type="number" class="scienceTest">
  <input type="number" class="scienceTest">
  <input type="number" class="scienceTest">
  <output id="scienceAverage"></output>
  <br> Physics: <input type="number" class="physicsTest">
  <input type="number" class="physicsTest">
  <input type="number" class="physicsTest">
  <output id="physicsAverage"></output>
  <br> History: <input type="number" class="historyTest">
  <input type="number" class="historyTest">
  <input type="number" class="historyTest">
  <output id="historyAverage"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
  <output id="finalGrade"></output>
</form>

Update: Simplified example

document.getElementById('calcBtn').addEventListener('click', function() {
  var test1 = document.getElementById('test1').value;
  var test2 = document.getElementById('test2').value;
  var test3 = document.getElementById('test3').value;
  var average = document.getElementById('average');
  // Put all field values in array, Filter empty values out, cast values to Number
  var rowValues = [test1, test2, test3].filter(Boolean).map(Number)

  console.log('Number of changed fields', rowValues.length)

  // calculate average by reducing the array to the sum of its remaining values then divide by array length
  average.value = rowValues.reduce((sum, grade) => sum + grade, 0) / rowValues.length;
});
<form>
  <input type="number" id="test1">
  <input type="number" id="test2">
  <input type="number" id="test3">
  <output id="average"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
</form>

Update Extra: Based on OP's jsfiddle example in the comments

document.getElementById('calculator').addEventListener('click', function() {
  var physicsAverage = document.getElementById('physicsAverage'),
    historyAverage = document.getElementById('historyAverage');

  physicsAverage.value = calculateAverageById('physics')
  historyAverage.value = calculateAverageById('history');
});

function calculateAverageById(id) {
	// Get all inputs under Id
  var inputs = document.getElementById(id).getElementsByTagName('input')

  var values =
    Array.prototype.slice.call(inputs) // From HTMLCollection to Array
    .map(e => e.value.trim()) // Return all .value from input elements
    .filter(Boolean) // Filter out any empty strings ""
    .map(Number) // convert remaining values to Numbers
  return (values.length) ? // if length is greater then 0
    values.reduce((sum, grade) => sum + grade, 0) / values.length // Return average
    :
    'No assessment made!' // else return this message
}
    <form>
  <p id="physics">
    Physics:
    <input type="number">
    <input type="number">
    <input type="number">
    <output id="physicsAverage"></output>
  </p>
  <p id="history">
    History:
    <input type="number">
    <input type="number">
    <input type="number">
    <output id="historyAverage"></output>
  </p>
  <button type="button" id="calculator">Calculate</button>
</form>

Upvotes: 7

ellipsis
ellipsis

Reputation: 12152

Introduce a counter to the function. After every click check if the input values is '' or not as it will be string before casting. If it is do nothing as the ternary operation will return false

 test1!=''?num++:false;

If test1=='' then false else increment the counter. Before calculating the average check if the counter is 0, if it is set the counter to one. If it is zero the result of division by zero will be infinity and we will get NaN as the output, and counter set to 1 will give 0.

document.getElementById('calcBtn').addEventListener('click', function() {
let num=0;
  var test1 = document.getElementById('test1').value;
  test1!=''?num++:false;
  var test2 = document.getElementById('test2').value;
  test2!=''?num++:false;
  var test3 = document.getElementById('test3').value;
  test3!=''?num++:false;
  var average = document.getElementById('average');
  num==0?num++:false;
  average.value = (Number(test1) + Number(test2) + Number(test3)) / num;
});
<form>
  <input type="number" id="test1">
  <input type="number" id="test2">
  <input type="number" id="test3">
  <output id="average"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
</form>

Upvotes: 3

Maheer Ali
Maheer Ali

Reputation: 36574

You are dividing the value by 3 that's why its giving less result than expected.

  • Make the `html of the of your code dynamically.
  • Don't make too much variables(scienceTest1,scienceTest2.....) instead use loops are store the values in array
  • Writing like this Number(scienceTest1) + Number(scienceTest2) + Number(scienceTest3)) / 3 is bad because you can have more test and there are more chances of type error. Instead store the values in the array and at end use Array.prototype.reduce() to add them.
  • For the values array you need to check if the value !=='' before pushing it into array so it will get correct average.

The code is completely dynamic you can have any subjects and any no of tests

//This is list of subjects. You can change it will work same
let subjects = ['science','physics','history'];
let noOfTests = 3;
//add <form> element to body
document.body.innerHTML = '<form></form>'
//getting that form as an element.
let form = document.querySelector('form')

//Creating the HTML dymamically

subjects.forEach(sub =>{
   //setting the title of the subject
   form.innerHTML += sub + ':' + '<br>'; 
   for(let i = 0;i<noOfTests;i++){
     //generating input feilds equal of 'noOfTests' for each subject
     form.innerHTML += `<input type="number" id="${sub}Test${i+1}" /><br>`
   }
   //adding the output element to after addign all inputs.
   form.innerHTML += `<output id="${sub}Average"></output><br>` 
})
//Adding calculate button and finalOuput element.
form.innerHTML += `<br><input type="button" value="Calculate" id="calcBtn">
  <output id="finalGrade"></output>`



document.getElementById('calcBtn').addEventListener('click', function() {
  //'total' is array which will contain average of each subject
  let total = [];
  //looping thorugh each subject in 'subjects' array.
  subjects.forEach(sub => {
    //'vals' will store the values currect subject we are looping
    let vals = []
    
    for(let i = 0;i<noOfTests;i++){
      //getting the value of each input feild of current subject
      
      let val = document.getElementById(`${sub}Test${i+1}`).value;
      //check if input have a value so we push it into the vals array.
      if(val !== '') vals.push(val);
    }
    //getting average of all values using reduce
    let result = vals.reduce((ac,a) => ac + Number(a),0)/vals.length;
    //adding result(average) to the output of current subject.
    document.getElementById(`${sub}Average`).innerHTML = result
    //adding the average of current subject of the 'total' array.
    total.push(result);
  })
  //At last find the average of total averages and add it to 'finalGrade'
  total = total.filter(x => !isNaN(x));
  
  document.getElementById('finalGrade').innerHTML = total.reduce((ac,a) => ac + a,0)/total.length;
});
input{
  border-radius:5px;
  padding:3px;
  margin:5px;
  font-size:20px;
}
form{
  font-size:20px;
  font-family:sans-serif;
  text-transform:capitalize;
}

Upvotes: 3

Michael Warner
Michael Warner

Reputation: 4217

Method

The issue you are having of dividing by the fixed number 3 can be solved by using the Array length property and make this value dynamic.

Step 1: Place all subjects into arrays and filter if the inputs have values.

Step 2: Get the average value by subject based.

Step 3: Calculate the final Score with weights.

There are two functions that are used multiple times. isTruthy and average.

To slim down the average function I broke it up into sum and average

document.getElementById('calcBtn').addEventListener('click', function() {

  // Helper Functions

  function isTruthy (score) {
    return !!score
  }
  
  function sum (scores) {
    var total = 0;
    for (var counter=0; counter<scores.length; counter++) {
      total += (Number(scores[counter]) || 0);
    }
    return total
  }
  
  function average (scores) {
    return (sum(scores) / scores.length) || 0
  }
  
  // Step 1
  var scienceScores = [
    document.getElementById('scienceTest1').value,
    document.getElementById('scienceTest2').value,
    document.getElementById('scienceTest3').value
  ].filter(isTruthy)
  
  var physicsScores = [
    document.getElementById('physicsTest1').value,
    document.getElementById('physicsTest2').value,
    document.getElementById('physicsTest3').value
  ].filter(isTruthy)
  
  var historyScores = [
    document.getElementById('historyTest1').value,
    document.getElementById('historyTest2').value,
    document.getElementById('historyTest3').value
  ].filter(isTruthy)
  
  var scienceAverage = document.getElementById('scienceAverage');
  var physicsAverage = document.getElementById('physicsAverage');
  var historyAverage = document.getElementById('historyAverage');
  
  var finalGrade = document.getElementById('finalGrade');
  
  // Step 2
  scienceAverage.value = average(scienceScores);
  physicsAverage.value = average(physicsScores);
  historyAverage.value = average(historyScores);
  
  // Step 3
  finalGrade.value = (scienceAverage.value * 5 + physicsAverage.value * 3 + historyAverage.value * 2) / 10;
});
<form>
  Science: <input type="number" id="scienceTest1">
  <input type="number" id="scienceTest2">
  <input type="number" id="scienceTest3">
  <output id="scienceAverage"></output>
  <br> Physics: <input type="number" id="physicsTest1">
  <input type="number" id="physicsTest2">
  <input type="number" id="physicsTest3">
  <output id="physicsAverage"></output>
  <br> History: <input type="number" id="historyTest1">
  <input type="number" id="historyTest2">
  <input type="number" id="historyTest3">
  <output id="historyAverage"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
  <output id="finalGrade"></output>
</form>

Upvotes: 2

Olafant
Olafant

Reputation: 828

There are some solutions already. Here is mine.

There is something to optimize in your code and I think it is a good idea to create the HTML dynamically based on a simple configuration array like

const subjects = [{
  name: 'science',
  numberOfTests: 3
}, {
  name: 'physics',
  numberOfTests: 2
}, {
  name: 'history',
  numberOfTests: 3
}];

so if the subjects or the number of tests change, you don't need to change the code but only this configuration. I tried to comment my code example to make understandable, what is done there. But that is not the important part. More important is that you need to understand what's going on in the calculation part to be sure, the calculation is right. So I start with that part.

If you add name attributes to your test result inputs and give each input of the same subject the same name, you can easily retrieve a NodeList of that inputs, check the values for each Node and calculate based on it. So you know the values and the number of tests the student took for each subject.

Let's have a look

/* EventListener for the calculate button */
btn.addEventListener('click', function(e) {
  e.preventDefault(); // don't submit the form
  var totalAvSum = 0; // var for the total of all subject average totals 

  /* for all subjects in your configuration array */
  subjects.forEach(function(subject) {

    /* NodeList of all inputs with Name subject.name+'Test' */
    let subjResInputs = document.getElementsByName(subject.name+'Test');
    let testTotal = 0; // sum of test results
    let testCnt = 0; // number of tests the student took
    let tval; // value of input

    /* for each input of the subject */
    Array.prototype.map.call(subjResInputs, function(t){
      tval = (t.value * 1); // make sure, value is treated as number
      if (tval > 0) { // only if there is a value
        testTotal += tval; // add test result
        testCnt += 1; // increase test count
      }
    });

    /* calculate average and show it in output */
    totalAvSum += (testTotal/testCnt);
    document.getElementById(subject.name+'Average').textContent = (testTotal/testCnt);
  });

  /* after calculating average per subject show total average */
  document.getElementById('totalAverage').textContent = totalAvSum/subjects.length;    
});

I use document.getElementsByName() here to get the NodeList of input elements for each subject. Then I use array.map() to walk through that list. To check, if an input has a value, I multiply the value by 1 (what gives a number) and calculate only, if the result is greater than 0.

The rest is the dynamical stuff for the HTML.

/* This is your configuration.
   The form will be created based on that configuration.
   So you don't need to change anything in the code if 
   subjects or number of tests change. */
const subjects = [{
    name: 'science',
    numberOfTests: 3
  }, {
    name: 'physics',
    numberOfTests: 2
  }, {
    name: 'history',
    numberOfTests: 3
  }];

/* this functioncreates the form table */
function createFormTable() {
  var tr, td, txt, outp, btn, frmTbl;
    // frmTbl = document.getElementById('formTable');

  /* create table */
  frmTbl = document.createElement('table');
  frmTbl.setAttribute('id', 'formTable'); // set id to 'formTable'

  /* create table head */
  tr = document.createElement('tr');
  td = document.createElement('th');
  txt = document.createTextNode('subject');
  td.appendChild(txt);
  tr.appendChild(td);

  td = document.createElement('th');
  txt = document.createTextNode('test results');
  td.appendChild(txt);
  tr.appendChild(td); 

  td = document.createElement('th');
  txt = document.createTextNode('arith. mean');
  td.appendChild(txt);
  tr.appendChild(td);  

  /* add table head to table */
  frmTbl.appendChild(tr);

  /* create table row for each subject 
     the table row object is created in function createSubjectRow
     and here added to the table */
  subjects.forEach(function(subject) {
    frmTbl.appendChild(createSubjectRow(subject)); // add tr to table
  });

  /* row with total average */
  /* create tr element */
  tr = document.createElement('tr');

  td = document.createElement('th'); // td for text total
  td.setAttribute('colspan', 2);
  td.style.textAlign = 'right';
  txt = document.createTextNode('total'); // textNode
  td.appendChild(txt); // add textNode to td
  tr.appendChild(td); // add td to tr
  frmTbl.appendChild(tr); // add tr to table

  td = document.createElement('td'); // td for total average output  
  outp = document.createElement('output'); // create output element
  outp.setAttribute('id', 'totalAverage'); // set id
  td.appendChild(outp); // add output to td
  tr.appendChild(td); // add td to tr
  frmTbl.appendChild(tr); // add tr to table

  /* button */
  btn = document.createElement('button');
  btn.setAttribute('id', 'calcBtn');
  txt = document.createTextNode('calculate');
  btn.appendChild(txt);
  // document.getElementById('gradesForm').appendChild(btn);

  /* add button to last row in table */
  tr = document.createElement('tr');
  td = document.createElement('th'); // td for button
  td.setAttribute('colspan', 3);
  td.appendChild(btn); // add button to td
  tr.appendChild(td); // add td to tr
  frmTbl.appendChild(tr); // add tr to table

  /* EventListener for the calculate button */
  btn.addEventListener('click', function(e) {
    e.preventDefault(); // don't submit the form
    var totalAvSum = 0; // var for the total of all subject average totals 

    /* for all subjects in your configuration array */
    subjects.forEach(function(subject) {
      /* NodeList of all inputs with Name subject.name+'Test' */
      let subjResInputs = document.getElementsByName(subject.name+'Test');
      let testTotal = 0;
      let testCnt = 0;
      let tval;
      /* for each input of the subject */
      Array.prototype.map.call(subjResInputs, function(t){
        tval = (t.value * 1); // make sure, value is treated as number
        if (tval > 0) { // only if there is a value
          testTotal += tval; // add test result
          testCnt += 1; // increase test count
        }
      });
      /* calculate average and show it in output */
      totalAvSum += (testTotal/testCnt);
      document.getElementById(subject.name+'Average').textContent = (testTotal/testCnt);
    });
    /* after calculating average per subject
       show total average */
    document.getElementById('totalAverage').textContent = totalAvSum/subjects.length;    
  });

  return frmTbl;
}

function createSubjectRow(s) {
  var tr, td, txt, inp, outp; 

  /* create tr element */
  tr = document.createElement('tr');

  /* create td elements for subject s */
  td = document.createElement('td'); // td for subject name
  txt = document.createTextNode(s.name); // textNode
  td.appendChild(txt); // add textNode to td
  tr.appendChild(td); // add td to tr

  td = document.createElement('td'); // td for subject test results
  for (var i = 0; i < s.numberOfTests; i += 1) {
    inp = document.createElement('input'); // create input
    inp.setAttribute('type', 'number');  // set input type
    // inp.setAttribute('id', s.name + 'Test' + i); // set id
    /* set name attribute of input to subject name + 'Test'
       all test result inputs for the same subject will have the same name */
    inp.setAttribute('name', s.name + 'Test');
    inp.setAttribute('step', 0.1); // in case, you give grades like 3.5
    inp.setAttribute('min', 1);
    inp.setAttribute('max', 100);
    td.appendChild(inp); // add input to td
  }
  tr.appendChild(td); // add td to tr

  td = document.createElement('td'); // td for average output  
  outp = document.createElement('output'); // create output element
  outp.setAttribute('id', s.name + 'Average'); // set id
  td.appendChild(outp); // add output to td
  tr.appendChild(td); // add td to tr

  return tr; // return the resulting table row object
}

document.getElementById('gradesForm').appendChild(createFormTable());
  #formTable td {
    border: solid 1px #000;
    padding: 6px;
    border-spacing: 3px;
  }
  #formTable th {
    border: none;
    font-size:0.9em;
    text-align: left;
  }
  input[type="number"] {
    width: 4em;
    border: solid 1px #999;
    margin: 0 3px;
  }
<form id="gradesForm">
</form>

Upvotes: 2

Vishwas R
Vishwas R

Reputation: 3420

Instead of dividing it by 3 all the time, you can calculate this number dynamically based on number of input fields updated by the student in a row.

Here is the working code:

function getValueAndTotal(element){
  var valueChanged = (element.defaultValue === element.value || element.value === "") ? 0 : 1;  
  return { value: Number(element.value), total: valueChanged };
}

document.getElementById('calcBtn').addEventListener('click', function() {
  var scienceTest1 = getValueAndTotal(document.getElementById('scienceTest1'));
  var scienceTest2 = getValueAndTotal(document.getElementById('scienceTest2'));
  var scienceTest3 = getValueAndTotal(document.getElementById('scienceTest3'));

  var physicsTest1 = getValueAndTotal(document.getElementById('physicsTest1'));
  var physicsTest2 = getValueAndTotal(document.getElementById('physicsTest2'));
  var physicsTest3 = getValueAndTotal(document.getElementById('physicsTest3'));

  var historyTest1 = getValueAndTotal(document.getElementById('historyTest1'));
  var historyTest2 = getValueAndTotal(document.getElementById('historyTest2'));
  var historyTest3 = getValueAndTotal(document.getElementById('historyTest3'));

  var scienceAverage = document.getElementById('scienceAverage');
  var physicsAverage = document.getElementById('physicsAverage');
  var historyAverage = document.getElementById('historyAverage');

  var finalGrade = document.getElementById('finalGrade');
  var scienceTotalTests = scienceTest1.total + scienceTest2.total + scienceTest3.total;
  var physicsTotalTests = physicsTest1.total + physicsTest2.total + physicsTest3.total;
  var historyTotalTests = historyTest1.total + historyTest2.total + historyTest3.total;

  scienceAverage.value = (scienceTotalTests === 0 ? 0 : (scienceTest1.value + scienceTest2.value + scienceTest3.value) / scienceTotalTests);
  physicsAverage.value = (physicsTotalTests === 0 ? 0 : (physicsTest1.value + physicsTest3.value + physicsTest3.value) / physicsTotalTests);
  historyAverage.value = (historyTotalTests === 0 ? 0 : (historyTest1.value + historyTest2.value + historyTest3.value) / historyTotalTests);

  finalGrade.value = (scienceAverage.value * 5 + physicsAverage.value * 3 + historyAverage.value * 2) / 10;
});
<form>
  Science: 
    <input type="number" id="scienceTest1" class="scienceTest">
    <input type="number" id="scienceTest2" class="scienceTest">
    <input type="number" id="scienceTest3" class="scienceTest">
    <output id="scienceAverage"></output>
  <br>Physics: 
    <input type="number" id="physicsTest1">
    <input type="number" id="physicsTest2">
    <input type="number" id="physicsTest3">
    <output id="physicsAverage"></output>
  <br>History: 
    <input type="number" id="historyTest1">
    <input type="number" id="historyTest2">
    <input type="number" id="historyTest3">
    <output id="historyAverage"></output>
  <br>
    <input type="button" value="Calculate" id="calcBtn">
    <output id="finalGrade"></output>
</form>

Upvotes: 5

Terry
Terry

Reputation: 66123

There are two major choke points in your code that you need to be aware of:

  1. You are calculating the average of each subject regardless of their value. Technically, you only want to take into account the subject if it has a defined value. In this case, 0 will be counted, but an empty field will not (since a student can technically score a 0 on his/her test)
  2. You are calculating the weighted average regardless of their value, too (see same logic as above).

Instead of trying to fix the code you have, I have actually refactored the logic so that all the calculations are abstracted into functions, based on the DRY (do not repeat yourself) principle. The functions are:

  • calculateSubjectAverage, which calculates the correct average of a given subject. It will take 0 into account, but ignore empty fields
  • setSubjectAverage, which will set the appropriate <output> element

Finally, instead of have to manually calculate the weighted average, you can easily store all that metadata in an array of objects, e.g.:

var subjects = [{
  name: 'science',
  weight: 5
}, {
  name: 'physics',
  weight: 3
}, {
  name: 'history',
  weight: 2
}];

This allows us to filter the subjects and calculate their correct weighted sum, and hence weighted average. Filtering is needed because there is a chance that an all-empty subject score will return undefined.

See proof-of-concept below:

function calculateSubjectAverage(className) {
  var inputs = document.querySelectorAll('.' + className);
  var scores = Array.prototype.map.call(inputs, function(input) {
    if (input.value === '')
      return;

    return +input.value;
  });

  var count = 0;
  var scoreSum = scores.reduce(function(acc, score) {
    if (isNaN(score))
      return acc;

    count++;
    return acc + score;
  }, 0);
  
  return scoreSum / count;
};

function setSubjectAverage(className, averageScore) {
  if (isNaN(averageScore))
    return;

  document.getElementById(className + 'Average').value = averageScore;
}

document.getElementById('calcBtn').addEventListener('click', function() {
  var subjects = [{
    name: 'science',
    weight: 5
  }, {
    name: 'physics',
    weight: 3
  }, {
    name: 'history',
    weight: 2
  }];
  
  var totalWeight = 0;

  // Go through each subject and calculate & set average score
  // Since we are iterating anyway, might want to calculate totalWeight, too
  subjects.forEach(function(subject) {
    var averageScore = calculateSubjectAverage(subject.name);
    setSubjectAverage(subject.name, averageScore);
    
    // Set average score to object
    subject.average = averageScore;
    
    if (!isNaN(averageScore))
      totalWeight += subject.weight;
  });
  
  // Only compute weighted average from subject with valid averages
  var weightedTotal = subjects.reduce(function(acc, subject) {
    if (isNaN(subject.average))
      return acc;
      
    return acc + subject.average * subject.weight;
  }, 0);
  var weightedAverage = weightedTotal / totalWeight;
  if (!isNaN(weightedTotal / totalWeight))
    document.getElementById('finalGrade').value = weightedTotal / totalWeight;
});
<form>
  Science: <input type="number" class="science">
  <input type="number" class="science">
  <input type="number" class="science">
  <output id="scienceAverage"></output>
  <br> Physics: <input type="number" class="physics">
  <input type="number" class="physics">
  <input type="number" class="physics">
  <output id="physicsAverage"></output>
  <br> History: <input type="number" class="history">
  <input type="number" class="history">
  <input type="number" class="history">
  <output id="historyAverage"></output>
  <br>
  <input type="button" value="Calculate" id="calcBtn">
  <output id="finalGrade"></output>
</form>

Upvotes: 4

Related Questions