app/frontend/javascript/shared/components/plateScanValidators.js
// Plate Scan Validators can be passed in to a LabwareScan.vue component to
// provide custom validation.
//
// A validator is a function which:
// 1) Takes the result of the APi query (usually a plate) as a sole argument
// 2) Returns javascript object with two properties:
// a) valid: A Boolean indicating if the plate is suitable or not
// b) message: A string which will be displayed to the user. Especially
// important for failures.
//
// Dynamic validation
// In many case you may want to validate a plate against a dynamic set of
// criteria, or to provide some customization options. In these cases you can
// wrap your validator in an external function, which takes the configuration
// options as parameters. These parameters will be available to the validator
// function itself.
//
// For example:
// const myValidator(option) => {
// // This function gets called when the validator is set up. If part of a
// // computed function a new // validator will be generated each time option
// // changes. This will allow validation to respond // dynamically to changes
// // elsewhere on the page.
// // Typically you only NEED to return the validator function here
// return (plate) => {
// // This is the validator itself. It has access to option, and any other
// // parameters defined above
// // In this example, we ensure that the plate thing is option
// if (plate.thing == option) {
// return { valid: true, message: 'The plate is suitable' }
// } else {
// return { valid: false, message: 'The plate thing should be option' }
// }
// }
//}
//
// Validating multiple criteria
// Try and keep validators checking one thing only. It makes them easier to
// test and reuse. Multiple validators can be combined together using the aggregate
// function in scanValidators.js
import { validScanMessage } from './scanValidators.js'
import { requestIsLibraryCreation, requestIsActive } from '../requestHelpers.js'
import _ from 'lodash'
// Returns a validator which ensures the plate is of a particular size.
// For example, to validate your typical 12*8 96 well plate: checkSize(12,8)
const checkSize = (cols, rows) => {
return (plate) => {
if (!plate) {
return { valid: false, message: 'Plate not found' }
} else if (plate.number_of_columns !== cols || plate.number_of_rows !== rows) {
return {
valid: false,
message: `The plate should be ${cols}×${rows} wells in size`,
}
} else {
return validScanMessage()
}
}
}
// Returns a validator that ensures that the scanned item does not appear
// multiple times in the list (based on UUID).
// plateList: An array of plates that have already been scanned. Can include
// null to represent empty plate
const checkDuplicates = (plateList) => {
return (plate) => {
let occurrences = 0
for (let i = 0; i < plateList.length; i++) {
if (plateList[i] && plate && plateList[i].uuid === plate.uuid) {
occurrences++
}
}
if (occurrences > 1) {
return {
valid: false,
message: 'Barcode has been scanned multiple times',
}
} else {
return validScanMessage()
}
}
}
// Returns a validator that ensures the plate has a state that matches to the
// supplied list of states. e.g. to check a plate has a state of 'available'
// or 'exhausted':
// checkState(['available', 'exhausted'])
const checkState = (allowedStatesList) => {
return (plate) => {
if (!allowedStatesList.includes(plate.state)) {
return {
valid: false,
message: 'Plate must have a state of: ' + allowedStatesList.join(' or '),
}
} else {
return validScanMessage()
}
}
}
// Returns a validator that ensures the QCable tag plate has a walking by that
// matches the supplied walking by list. e.g. to check a QCable has a walking
// by of 'wells of plate':
// checkQCableWalkingBy(['wells of plate'])
const checkQCableWalkingBy = (allowedWalkingByList) => {
return (qcable) => {
if (!qcable.lot || !qcable.lot.tag_layout_template || !qcable.lot.tag_layout_template.walking_by) {
return {
valid: false,
message: 'QCable should have a tag layout template and walking by',
}
}
if (!allowedWalkingByList.includes(qcable.lot.tag_layout_template.walking_by)) {
return {
valid: false,
message: 'QCable layout must have a walking by of: ' + allowedWalkingByList.join(' or '),
}
} else {
return validScanMessage()
}
}
}
// Gets a request and returns if it is an active library creation request
// Args:
// request - request to check
// Returns:
// Boolean indicating if is an active library creation request
const activeLibraryCreationRequest = (request) => requestIsLibraryCreation(request) && requestIsActive(request)
// Gets a well as input and return the list of requests that correspond to an active
// library creation request
// Args:
// well - Well we want to obtain the library creation requests. The well
// needs to have the relationship `requests_as_source`
// Returns:
// Array of library creation requests, or empty list
const libraryCreationRequestsFromWell = (well) => well.requests_as_source.filter(activeLibraryCreationRequest)
// Gets a list of wells and returns from the only the wells that contain at least
// one active library creation requests
// Args:
// wells - Array of wells we want to check. They need to
// have the relationship `requests_as_source`
// Returns:
// Array of wells that match the condition
const filterWellsWithLibraryCreationRequests = (wells) => {
return wells.filter((well) => {
return libraryCreationRequestsFromWell(well).length >= 1
})
}
// Gets an integer with an integer identifying the maximum number of wells with library creation
// requests; and returns a validator method that will check if a plate has less wells containing
// library creation requests than the maximum number of wells specified.
// Args:
// maxWellsWithRequests - Integer with the maximum number of wells with library creation
// requests
// Returns:
// Validator handler, that receives as argument a plate, and returns a validation message
// specifying if the plate is valid or not with the condition
const checkMaxCountRequests = (maxWellsWithRequests) => {
return (plate) => {
const numWellsWithRequest = filterWellsWithLibraryCreationRequests(plate.wells).length
if (numWellsWithRequest > maxWellsWithRequests) {
return {
valid: false,
message:
'Plate has more than ' +
maxWellsWithRequests +
' wells with submissions for library preparation (' +
numWellsWithRequest +
')',
}
}
return validScanMessage()
}
}
// Gets an integer with an integer identifying the minimum number of wells with library creation
// requests; and returns a validator method that will check if a plate has more wells containing
// library creation requests than the minimum number of wells specified.
// Args:
// maxWellsWithRequests - Integer with the minimum number of wells with library creation
// requests
// Returns:
// Validator handler, that receives as argument a plate, and returns a validation message
// specifying if the plate is valid or not with the condition
const checkMinCountRequests = (minWellsWithRequests) => {
return (plate) => {
const numWellsWithRequest = filterWellsWithLibraryCreationRequests(plate.wells).length
if (numWellsWithRequest < minWellsWithRequests) {
return {
valid: false,
message:
'Plate should have at least ' +
minWellsWithRequests +
' wells with submissions for library preparation (' +
numWellsWithRequest +
')',
}
}
return validScanMessage()
}
}
// Gets a list of column lists specify a strings of integers (like ['1', '2', '4'], etc);
// and returns a validator method that will check if a plate has all wells containing
// library creation requests inside the columns specified.
// Args:
// maxWellsWithRequests - Integer with the minimum number of wells with library creation
// requests
// Returns:
// Validator handler, that receives as argument a plate, and returns a validation message
// specifying if the plate is valid or not with the condition
const checkAllSamplesInColumnsList = (columnsList) => {
return (plate) => {
const wells = filterWellsWithLibraryCreationRequests(plate.wells)
if (!wells.every((well) => columnsList.includes(well.position.name.slice(1)))) {
return {
valid: false,
message: 'All samples should be in the columns ' + columnsList,
}
}
return validScanMessage()
}
}
// Gets the list of missing libraries for a well given the list of library types
// that it should contain.
// Args:
// well - The well
// libraryTypes - The list of library types the well should contain
// Returns:
// Array of the missing library types for the well, or an empty array
const missingWellLibraries = (well, libraryTypes) => {
const libraryCreationRequests = libraryCreationRequestsFromWell(well)
if (libraryCreationRequests.length > 0) {
const librariesInWell = libraryCreationRequests.map((request) => request.library_type)
// return a list of missing libraries
return _.difference(libraryTypes, librariesInWell)
}
return []
}
// Gets a list of library type names and returns a validation handler that
// can check for a plate that, if they have a list of library creation requests,
// those requests are using all the library types defined as input and this has to
// happen in every different well.
// Args:
// library_types - Array of string with the name of the library types to check
// Returns:
// Validation object indicating if the plate has passed the condition
const checkLibraryTypesInAllWells = (libraryTypes) => {
return (plate) => {
const wells = plate.wells
for (let i = 0; i < wells.length; i++) {
const well = wells[i]
let missingLibraries = missingWellLibraries(well, libraryTypes)
if (missingLibraries.length != 0) {
return {
valid: false,
message: 'The well at position ' + well.position.name + ' is missing libraries: ' + missingLibraries,
}
}
}
return validScanMessage()
}
}
// Receives a plate object and a submission state, and returns a list of submission
// ids for each well where the library request submission state matches.
// Args:
// plate - Plate object that contains the wells, requests_as_source and submissions
// submission_state - String with any valid submission state value ('pending', 'canceled', etc)
// Returns:
// Array of arrays - an element for each well containing an array of integer submission ids
const getAllLibrarySubmissionsWithMatchingStateForPlate = (plate, submission_state) => {
return filterWellsWithLibraryCreationRequests(plate.wells).map((well) => {
return libraryCreationRequestsFromWell(well)
.filter((request) => request.submission.state == submission_state)
.map((request) => request.submission.id)
})
}
// Receives a plate an returns all unique submission ids for the plate
// that are in 'ready' state
// Args:
// plate - Plate object that contains the wells, requests_as_source and submissions
// Returns:
// Array of integer with the list of unique submission ids
const getAllUniqueLibrarySubmissionReadyIds = (plate) => {
return _.uniq(getAllLibrarySubmissionsWithMatchingStateForPlate(plate, 'ready').flat())
}
// Receives a plate and checks that all its wells use the same group of library submissions for
// every well.
// Args:
// plate - Plate object that contains the wells, requests_as_source and submissions
// Returns:
// Validation object indicating if the plate has passed the condition
const checkAllLibraryRequestsWithSameReadySubmissions = () => {
return (plate) => {
const [firstWellSubmissionIds, ...remainingWellsSubmissionIds] = getAllLibrarySubmissionsWithMatchingStateForPlate(
plate,
'ready',
)
// To compare lists we use _.isEqual because there is no equivalent function for lists in
// plain Javascript
if (
remainingWellsSubmissionIds.every((currentElemSubmissionIds) =>
_.isEqual(firstWellSubmissionIds.sort(), currentElemSubmissionIds.sort()),
)
) {
return validScanMessage()
} else {
return {
valid: false,
message:
'The plate has different submissions in `ready` state across its wells. All submissions should be the same for every well.',
}
}
}
}
// Checks that the library submissions for the plate is the same as the list
// of submission ids passed as argument
// Args:
// cached_submission_ids - Array of submission ids to check
// Returns:
// Validation object indicating if the plate has passed the condition
const checkPlateWithSameReadyLibrarySubmissions = (cached_submission_ids) => {
return (plate) => {
if (typeof cached_submission_ids.submission_ids === 'undefined') {
cached_submission_ids.submission_ids = getAllUniqueLibrarySubmissionReadyIds(plate)
return validScanMessage()
}
if (_.isEqual(getAllUniqueLibrarySubmissionReadyIds(plate).sort(), cached_submission_ids.submission_ids.sort())) {
return validScanMessage()
} else {
return {
valid: false,
message:
'The submission from this plate are different from the submissions from previous scanned plates in this screen.',
}
}
}
}
// Checks that the scanned plate's purpose matches one of those in the provided list.
// Args:
// acceptable_purposes - An array of acceptable plate purpose name strings e.g. ['Purpose1', 'Purpose2']
// plate - Plate object that contains the plate purpose
// Returns:
// Validation object indicating if the plate has passed the condition
const checkForUnacceptablePlatePurpose = (acceptable_purposes) => {
return (plate) => {
if (!acceptable_purposes || acceptable_purposes.length == 0) {
// return valid if no acceptable purposes are provided
return validScanMessage()
} else if (!plate.purpose) {
// guard for plate not having a purpose (should not happen)
return {
valid: false,
message: 'The scanned plate does not have a plate purpose',
}
} else if (acceptable_purposes.includes(plate.purpose.name)) {
// if we find a matching purpose, return valid
return validScanMessage()
} else {
return {
valid: false,
message:
'The scanned plate has an unacceptable plate purpose type (should be ' +
acceptable_purposes.join(' or ') +
')',
}
}
}
}
export {
checkSize,
checkDuplicates,
checkLibraryTypesInAllWells,
getAllLibrarySubmissionsWithMatchingStateForPlate,
checkAllLibraryRequestsWithSameReadySubmissions,
checkPlateWithSameReadyLibrarySubmissions,
getAllUniqueLibrarySubmissionReadyIds,
checkState,
checkQCableWalkingBy,
checkMaxCountRequests,
checkMinCountRequests,
checkAllSamplesInColumnsList,
checkForUnacceptablePlatePurpose,
}