1

Im trying to create a site that has any number of rows in a table that contain up to 2 inputs. I would like the combination of the inputs to be unique. That is, if we have the following elements

   <select><option value='0'>None</option><option value='1'>1</option><option value='2'>2</option><option value='3'>3</option></select>
   <select><option value='0'>None</option><option value='4'>4</option><option value='5'>5</option><option value='6'>6</option></select>

Then the any combinations of the two can be used 0-1 times. So, if 1 and 4 are used in one row, if 1 is selected again, 4 should not be allowed to be selected. This should work in the other direction as well. If 4 is selected then 1 should not be allowed to be selected again.

Rows can be added or removed at anytime by the user, so these checks will have to be performed every time a element is changed.

Additional Info

  • The contents of the select elements are generated by a database, they are not static.
  • They will always have a "value" attribute that contains an integer.
  • I keep a copy of the unaltered select elements in the 'masters' div, these are used to add more rows to the table (and where the PHP dumps the options from the database). These "masters" are cloned and added in when the "Add" button is clicked.
  • A value of 0 for each is allowed, but only once. This is the only guaranteed option that will appear in both select elements

Things Ive tried

  1. Detecting the change event and trying to adjust all the select elements to enforce the unique-ness, but I couldn't quite hack this. I'm pretty sure this is the way to go, but am struggling with the way to implement it.

  2. Trying to remove possible duplicates at the add row time, but you can't decide what is a duplicate until at least one selection is made.

  3. Removing any row(s) that duplicate another row's combination, but this doesn't work as expected and is not user friendly anyway (this is the current implementation in the example below).

Things that don't work

Extra Challenge

The other half of this is that either the projects column and/or the activities column may be hidden if there are no projects/activities in the database. In this case, the default value is 0, but it dramatically changes the expected behavior of the page and adds a bunch of edge cases to the mix. I left it out of the main question since I thought it would add too much confusion, but it's something else I'll need to deal with. This functionality is second to the main problem above, however.

Minimum Reproducible Example:

You should be able to copy-past this directly to a .html file for experimentation. I left some styling in to make it easier to understand what is needed.

jQuery.fn.outerHTML = function() {
  return jQuery('<div />').append(this.eq(0).clone()).html();
};

var projects = $("#projectsMaster");
var activities = $("#activitiesMaster");
var numRows = 0;

$(document).ready(function() {
  $(".entryTable").on("click", ".deleteButton", function() {
    if (numRows > 0) {
      var row = $(this).closest("tr");
      row.remove();
      numRows--;
    }
  });
  
  $(".entryTable").on('change', 'select', function() {
    enforceUniqueProjectsActivities(this)
  });
});

function enforceUniqueProjectsActivities(select) { 
  var usedCombos = new Array(); 
  
  $(".entryTable").find(".dataRow").each(function() {
    var activity = $(this).find(".activity").find("select").val();
    if (!activity) {
      activity = 0;
    }
    if (!project) {
      project = 0;
    }
    var project = $(this).find(".project").find("select").val();
    usedCombos.push(project + "," + activity);
  });
  
  //I was planning on going through each row and removing duplicates, but this isnt very user-friendly.
  $(".entryTable").find(".dataRow").each(function() {
    var activity = $(this).find(".activity").find("select").val();
    if (!activity) {
      activity = 0;
    }
    var project = $(this).find(".project").find("select").val();
    if (!project) {
      project = 0;
    }

    for (var i = 0; i < usedCombos.length; i++) {
      if (i === $(this).index() - 1) {
        continue;
      }
      if (project === usedCombos[i].split(",")[0] && activity === usedCombos[i].split(",")[1]) {
        $(this).remove();
      }
    }
  });
}

function addRow() {
  numRows++;
  var newRow = "<tr class='dataRow' '" +
    "'><td class='project'>" + projects.clone().outerHTML() + "</td><td class='activity'>" + activities.clone().outerHTML() +
    "</td><td><button class='deleteButton'>Delete</button></td></tr>";
  $(".entryTable").find('tr:last').prev().after(newRow);
}
.entryTable {
  margin: auto;
  width: 95%;
  border: 1px solid black;
  border-collapse: collapse;
}

.entryTable input,
select {
  width: 100%;
  font-size: 18px;
}

.entryTable tr {
  border-bottom: 1px solid black;
  border-color: black
}

.entryTable td {
  text-align: center;
  padding: 10px;
  border-right: 1px solid black;
}

#addRow {
  background-color: darkgray
}

.masters {
  display: none;
}
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<div class="masters">
  <select id="projectsMaster">
    <option value='0'>None</option>
    <option value='1'>1</option>
    <option value='2'>2</option>
    <option value='3'>3</option>
  </select>
  <select id="activitiesMaster">
    <option value='0'>None</option>
    <option value='4'>4</option>
    <option value='5'>5</option>
    <option value='6'>6</option>
  </select>
</div>

<br/>
<table class="entryTable" id-">
  <tr>
    <th>Project</th>
    <th>Activity</th>
    <th></th>
  </tr>
  <tr id="addRow">
    <td></td>
    <td></td>
    <td style='text-align: center'><button onclick="addRow()">Add</button></td>
  </tr>
</table>
thetechnician94
  • 545
  • 2
  • 21

2 Answers2

2

Found the exact what u were looking for... Before user see the options our function is checking the other value and other rows with the same value, and then it disables the options which are not available any more. I Changed the duplicate-checking function, and the "change" listener (i changed it into "focusin" listener).

jQuery.fn.outerHTML = function() {
  return jQuery('<div />').append(this.eq(0).clone()).html();
};

var projects = $("#projectsMaster");
var activities = $("#activitiesMaster");
var numRows = 0;

$(document).ready(function() {
  $(".entryTable").on("click", ".deleteButton", function() {
    if (numRows > 0) {
      var row = $(this).closest("tr");
      row.remove();
      numRows--;
    }
  });
  
  // run the checking before user sees the options
  $(".entryTable").on('focusin', 'select', function() {
    // we must run that function giving it the other select because i wrote it backwards ;) sorry
    var $this = $(this),
        $other = $this.closest('tr').find('select').not($(this));
    enforceUniqueProjectsActivities($other);
  });
});

function enforceUniqueProjectsActivities(select, isSecondRun) {
  var $this = $(select),
      thisVal = $this.val(),
      thisCol = $this.closest('td').is('.activity') ? 'activity':'project',
      pairCol = (thisCol == 'project') ? 'activity':'project',
      $table = $this.closest('.entryTable'),
      $thisRow = $this.closest('tr'),
      $pair = $thisRow.find('td.'+pairCol+' select'),
      $otherRows = $table.find('tr').not($thisRow),
      $otherLikeThis = $otherRows.find('.'+ thisCol+' option:selected[value="'+thisVal+'"]');
 
  // clear all disabled props of the options of the $pair element
  $pair.find('option').prop('disabled',false);
  
  if(thisVal==''||thisVal=='0')return;

  // check which pair-values are already used and disable these options
  $otherLikeThis.each(function(){
    var $currPair = $(this).closest('tr').find('td.'+pairCol+' select'),
        currPairVal = $currPair.val();
    if(currPairVal && currPairVal!='0'){
      $pair.find('option[value="'+ currPairVal +'"]').prop('disabled',true);
    }
  });
  
}

function addRow() {
  numRows++;
  var newRow = "<tr class='dataRow' '" +
    "'><td class='project'>" + projects.clone().outerHTML() + "</td><td class='activity'>" + activities.clone().outerHTML() +
    "</td><td><button class='deleteButton'>Delete</button></td></tr>";
  $(".entryTable").find('tr:last').prev().after(newRow);
}
.entryTable {
  margin: auto;
  width: 95%;
  border: 1px solid black;
  border-collapse: collapse;
}

.entryTable input,
select {
  width: 100%;
  font-size: 18px;
}

.entryTable tr {
  border-bottom: 1px solid black;
  border-color: black
}

.entryTable td {
  text-align: center;
  padding: 10px;
  border-right: 1px solid black;
}

#addRow {
  background-color: darkgray
}

.masters {
  display: none;
}
option:disabled {
  background-color:#ccc;
  color:#888;
}
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<div class="masters">
  <select id="projectsMaster">
    <option value='0'>Select</option>
    <option value='1'>1</option>
    <option value='2'>2</option>
    <option value='3'>3</option>
  </select>
  <select id="activitiesMaster">
    <option value='0'>Select</option>
    <option value='4'>4</option>
    <option value='5'>5</option>
    <option value='6'>6</option>
  </select>
</div>

<br/>
<table class="entryTable" id-">
  <tr>
    <th>Project</th>
    <th>Activity</th>
    <th></th>
  </tr>
  <tr id="addRow">
    <td></td>
    <td></td>
    <td style='text-align: center'><button onclick="addRow()">Add</button></td>
  </tr>
</table>

PS. I started with a different idea ...That idea was wrong - but the function was good (just needed the other select as argument - that's why listener function grown a bit bigger)

webdev-dan
  • 1,339
  • 7
  • 10
  • This is exactly what I looking for! Give me a bit to implement it in live and I'll return with your accepted answer! – thetechnician94 Jan 15 '21 at 16:04
  • @webdev-dan I was able to implement it. There was a way for me to select the same values twice, but through a series of interactions I'm not too worried about. I also had to remove a couple lines to enforce that the 0 value could only be selected once, just like all the rest of the values. The only other issue I see is that when the row is added, the default value can be used twice, but I think if I add a value of empty string as the first element that might fix it, but there some database complications to think about for that. – thetechnician94 Jan 19 '21 at 13:07
1

One way to achieve this would be to build an array of the selected value pairs concatenated together. Then when a new pair of values is selected you can compare that to the existing array to see if it already exists, and handle it appropriately.

Note that I also made some modifications to the logic which builds the HTML to make it more consistent and succinct.

I also removed the 0 value in the default 'Please select' option as this causes needless complication when comparing to other values.

jQuery($ => {
  $('button.add').on('click', () => {
    $('.entryTable tfoot tr').clone().insertBefore('.entryTable tbody tr:last');
  });

  $(".entryTable").on("click", ".deleteButton", e => {
    $(e.target).closest("tr").remove();
  });
  
  let getSelections = () => {
    return $('tbody .dataRow').map((i, row) => {
      let $row = $(row);
      return `${$row.find('.projectsMaster').val()}${$row.find('.activitiesMaster').val()}`;
    }).get();
  }

  $(".entryTable").on('change', 'select', e => {  
    let $row = $(e.target).closest('tr');
    let item = `${$row.find('.projectsMaster').val()}${$row.find('.activitiesMaster').val()}`;
        
    if (item.length == 2 && getSelections().filter(i => i == item).length > 1) {
      e.preventDefault();
      console.log('Already selected!');
    }
  });
});
table>tfoot {
  display: none;
}

.entryTable {
  margin: auto;
  width: 95%;
  border: 1px solid black;
  border-collapse: collapse;
}

.entryTable input,
select {
  width: 100%;
  font-size: 18px;
}

.entryTable tr {
  border-bottom: 1px solid black;
  border-color: black
}

.entryTable td {
  text-align: center;
  padding: 10px;
  border-right: 1px solid black;
}

.addRow {
  background-color: darkgray
}

.masters {
  display: none;
}
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>

<table class="entryTable">
  <tbody>
    <tr>
      <th>Project</th>
      <th>Activity</th>
      <th></th>
    </tr>
    <tr class="addRow">
      <td></td>
      <td></td>
      <td style='text-align: center'><button class="add">Add</button></td>
    </tr>
  </tbody>
  <tfoot>
    <tr class="dataRow">
      <td class="project">
        <select class="projectsMaster">
          <option value="">Select</option>
          <option value="1">1</option>
          <option value="2">2</option>
          <option value="3">3</option>
        </select>
      </td>
      <td class="activity">
        <select class="activitiesMaster">
          <option value="">Select</option>
          <option value="4">4</option>
          <option value="5">5</option>
          <option value="6">6</option>
        </select>
      </td>
      <td><button class="deleteButton">Delete</button></td>
    </tr>
  </tfoot>
</table>
Rory McCrossan
  • 331,213
  • 40
  • 305
  • 339
  • Although I feel the other answer is better suited to my specific use case, I really like the way you structured your code and made it more concise. I'll be taking bits of yours too since its sooo much cleaner than mine. +1 for showing me a better way to handle these things, thanks! – thetechnician94 Jan 19 '21 at 14:13
  • 1
    No problem, glad you got something from it. The main point to take away is that the use of globals, ie. `numRows`, is bad practice and should be avoided. – Rory McCrossan Jan 19 '21 at 14:14